Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9858b7cb92 | ||
|
|
f46d7bf676 | ||
|
|
99ceeadb83 | ||
|
|
7387f35c7e | ||
|
|
ac4fccd499 | ||
|
|
a5535f2941 | ||
|
|
d49d027020 | ||
|
|
9c352fdb77 | ||
|
|
dda2ff86a3 | ||
|
|
8ad3dc5c4f | ||
|
|
9f5f57cba4 | ||
|
|
50934e2ac6 | ||
|
|
8fbc080733 | ||
|
|
ba5fd55204 | ||
|
|
fec4b7ae8c | ||
|
|
2ca2174cea | ||
|
|
0ca2857c3a | ||
|
|
1f32e4040b | ||
|
|
e4632bb2b2 | ||
|
|
2d50744320 | ||
|
|
adc460f35d | ||
|
|
9d7cb085e9 | ||
|
|
85bd227714 | ||
|
|
3619f71aba | ||
|
|
8e081845d0 | ||
|
|
5fa74d9e34 | ||
|
|
c480276a97 | ||
|
|
ca490c34ec | ||
|
|
e144e62b31 | ||
|
|
07d10dfed7 | ||
|
|
acddcdd6fa | ||
|
|
6a712c46cb | ||
|
|
211792947d | ||
|
|
2c3955914e | ||
|
|
1b70926c36 | ||
|
|
7b531a40be | ||
|
|
19bc265a8d | ||
|
|
cc7f1cece0 | ||
|
|
a53042fe80 | ||
|
|
db520c6551 |
@@ -96,6 +96,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# The Linux job previously had no version step at all, so a tagged release
|
||||
# built Linux packages from whatever version happened to be committed.
|
||||
- name: Set app version from tag
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
- name: Build for Linux
|
||||
run: bun run tauri build
|
||||
env:
|
||||
@@ -156,15 +162,13 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# The tag is the single source of truth for a release version; the script
|
||||
# stamps every file that carries it (package.json, tauri.conf.json,
|
||||
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
|
||||
# the other three shipped whatever was committed.
|
||||
- name: Set app version from tag
|
||||
run: |
|
||||
# On a tag build the tag is the single source of truth for the version.
|
||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting version to $VERSION"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||
fi
|
||||
grep '"version"' src-tauri/tauri.conf.json
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
- name: Build Windows (NSIS installer + exe)
|
||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||
@@ -217,48 +221,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# Stamp before `android init`: it derives its generated project (including
|
||||
# the initial versionCode) from tauri.conf.json.
|
||||
- name: Set app version from tag
|
||||
run: |
|
||||
# On a tag build, the tag is the single source of truth for the
|
||||
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting version to $VERSION"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||
fi
|
||||
grep '"version"' src-tauri/tauri.conf.json
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
- name: Initialize Android project
|
||||
run: bun run tauri android init
|
||||
|
||||
# Re-run after init: tauri.properties only exists now, and its
|
||||
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
|
||||
# against the 1000 floor already shipped in the field. The script rewrites
|
||||
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
|
||||
# untagged builds get a sane code too, derived from git describe.
|
||||
- name: Pin a monotonic Android versionCode
|
||||
run: |
|
||||
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
||||
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
||||
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
||||
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
||||
# plain 15 would be a *downgrade* and Android would refuse the update.
|
||||
#
|
||||
# Derive an explicit code that is both monotonic in semver order and
|
||||
# always above the 1000 floor already in the field:
|
||||
# code = 1000 + major*10000 + minor*100 + patch
|
||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||
# Guard against a malformed/missing component so we never emit code 0.
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||
echo "version=$VERSION -> versionCode=$CODE"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
else
|
||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||
fi
|
||||
cat "$PROPS"
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
|
||||
- name: Sync custom Android sources & gradle config
|
||||
run: ./scripts/sync-android-sources.sh
|
||||
|
||||
@@ -6,6 +6,88 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
|
||||
## v0.5.0
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Android video can render on the device's own video surface.** Settings →
|
||||
Video Playback → **Native Video** (experimental, off by default) hands
|
||||
decoding to ExoPlayer, which draws into a surface composited *behind* a
|
||||
transparent WebView, with the player controls layered on top of it.
|
||||
|
||||
The backend had reported "this platform has a native video surface" on Android
|
||||
all along, but the frontend threw that answer away in two separate places, so
|
||||
the path had never actually run. Both are lifted. The setting can only ever
|
||||
*suppress* the backend's choice, never override it upward: turning it off
|
||||
forces the web player even where native is available, and turning it on does
|
||||
nothing on platforms whose backend never offered it — Linux cannot composite
|
||||
behind its webview, so it stays on the web player either way.
|
||||
|
||||
Verified playing on a physical device. Still unverified: the mini-player
|
||||
transition, audio-track switching on the native path, and whether hardware
|
||||
decoding measurably improves battery or CPU — so the toggle stays off by
|
||||
default. (UR-003, UR-004 → DR-150)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **The video surface now reaches the screen at all.** The player built its
|
||||
video surface, handed it to ExoPlayer, and then never added it to the view
|
||||
hierarchy, because the Activity reference it needed was never supplied — so
|
||||
native video would have decoded to a surface nobody could see, whatever else
|
||||
was fixed. This also silently disabled picture-in-picture for video, which
|
||||
gated on that same never-attached surface. (UR-003, UR-041 → DR-151)
|
||||
|
||||
- **Platform playback support is no longer guessed from the browser user
|
||||
agent.** The frontend re-derived "does this platform decode audio natively" by
|
||||
string-matching `navigator.userAgent` — a second copy of a decision the
|
||||
backend already makes, free to drift out of step with the backends it was
|
||||
describing. The backend now reports its own capabilities and the frontend
|
||||
consumes them. (UR-003, UR-005 → DR-152)
|
||||
|
||||
### 🔧 Internal
|
||||
|
||||
- **The git tag is now the single source of truth for a release version.** The
|
||||
version lived in four files that had to be edited in lockstep, and the release
|
||||
workflow rewrote exactly one of them — so a tagged build produced an installer
|
||||
named for the tag wrapped around package metadata naming the *previous*
|
||||
release, and the Linux job, which had no version step at all, shipped whatever
|
||||
happened to be committed. `scripts/set-version.sh` now writes all four from
|
||||
one argument and every release job calls it with the tag. The Android
|
||||
`versionCode` is derived in the same place, guarded by tests for the property
|
||||
that actually matters: it must increase monotonically and stay above the value
|
||||
already installed in the field, or Android silently refuses the update.
|
||||
(DR-153)
|
||||
|
||||
## v0.4.8
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Video with an undecodable soundtrack now transcodes instead of playing
|
||||
silent.** Advertising a webview-shaped profile (v0.4.7) turned out not to be
|
||||
enough: Jellyfin 10.11.5 enforces a direct-play profile's container and video
|
||||
codec but ignores its audio codec, offering an E-AC-3 track for direct play
|
||||
against a profile listing only AAC — and no `CodecProfile` or channel limit
|
||||
changes that. The client now checks the track it would actually be served
|
||||
against what its renderer can decode and forces the h264/AAC HLS transcode
|
||||
when it cannot, rather than trusting the negotiation.
|
||||
(UR-004 → DR-149)
|
||||
|
||||
## v0.4.7
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Video plays with sound on devices that ship a Dolby decoder.** The audio
|
||||
codec list sent to Jellyfin came from `MediaCodecList`, which describes
|
||||
ExoPlayer — but video does not play through ExoPlayer: it renders in the
|
||||
webview `<video>` element, which decodes far less. A phone whose vendor
|
||||
licenses Dolby therefore advertised `ac3`/`eac3`, got a direct play, and
|
||||
showed full picture with no audio, while a leaner device claimed neither
|
||||
codec, received an AAC transcode, and played the same file correctly. The
|
||||
video direct-play profile is now narrowed to what the webview can decode;
|
||||
audio-only playback is genuinely the native player's and keeps the full list,
|
||||
so music is not transcoded needlessly.
|
||||
(UR-004 → DR-148)
|
||||
|
||||
## v0.4.1
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
# Set NDK environment variable
|
||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
|
||||
# Gradle distribution. `tauri android init` regenerates gen/android with a
|
||||
# wrapper pointing at services.gradle.org, so every Android job would otherwise
|
||||
# download ~130MB of Gradle at build time — slow, and a hard failure when the
|
||||
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
|
||||
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
|
||||
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
|
||||
# Tauri's generated wrapper requests.
|
||||
ENV GRADLE_VERSION=8.14.3 \
|
||||
GRADLE_HOME=/opt/gradle/gradle-8.14.3
|
||||
RUN mkdir -p /opt/gradle/dist && \
|
||||
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
|
||||
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
|
||||
"$GRADLE_HOME/bin/gradle" --version
|
||||
ENV PATH="$GRADLE_HOME/bin:$PATH"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||
|
||||
+94
-12
@@ -82,6 +82,9 @@ For a narrative overview of the system design, see
|
||||
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
|
||||
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
|
||||
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
||||
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
||||
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,6 +169,7 @@ API endpoints and data contracts required for Jellyfin integration.
|
||||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
||||
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
|
||||
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
||||
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
|
||||
|
||||
### 2.3 Development Requirements
|
||||
|
||||
@@ -299,6 +303,42 @@ Internal architecture, components, and application logic.
|
||||
| DR-128 | Audio-only playback of *downloaded* media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video | Playback | UR-071 | Done |
|
||||
| DR-129 | A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) *Phantom end* — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the *absolute* position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) *Recoverable error* — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item **in place** (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping | Playback | UR-040, UR-004 | Done |
|
||||
| DR-130 | A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the *loaded* file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a *recoverable* error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded` → `player_on_playback_ended`). Android reports errors it has already declined as *unrecoverable*, so the echo never asks twice | Playback | UR-004, UR-040 | Done |
|
||||
| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done |
|
||||
| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done |
|
||||
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
|
||||
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else | Security | UR-071 | Done |
|
||||
| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done |
|
||||
| DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
|
||||
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done |
|
||||
| DR-141 | The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device *decode* 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has | Playback | UR-004 | Done |
|
||||
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
|
||||
| DR-146 | The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log | Playback | UR-004 | Done |
|
||||
| DR-148 | The video direct-play profile advertises only what the **webview** can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by *capability*, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile *only* — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio | Playback | UR-004 | Done |
|
||||
| DR-149 | The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but **ignores its `AudioCodec`**, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play | Playback | UR-004 | Done |
|
||||
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
|
||||
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
|
||||
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
|
||||
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
|
||||
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
|
||||
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
|
||||
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
|
||||
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
|
||||
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
||||
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
||||
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
||||
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
||||
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
|
||||
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
||||
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
||||
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
||||
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
||||
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
||||
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
|
||||
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
|
||||
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
|
||||
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
|
||||
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
|
||||
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
|
||||
---
|
||||
@@ -312,7 +352,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
@@ -333,7 +373,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||
| UR-024 | IR-010 | DR-027 |
|
||||
| UR-025 | IR-015 | DR-028 |
|
||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132 |
|
||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||
| UR-027 | IR-020 | DR-030 |
|
||||
| UR-028 | - | DR-031 |
|
||||
@@ -348,37 +388,40 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-048 | - | DR-061, DR-062, DR-142 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087 |
|
||||
| UR-058 | - | DR-087, DR-142 |
|
||||
| UR-060 | - | DR-090, DR-091, DR-111 |
|
||||
| UR-061 | - | DR-092 |
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||
| UR-066 | IR-031 | DR-112 |
|
||||
| UR-066 | IR-031 | DR-112, DR-157 |
|
||||
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| UR-070 | - | DR-121, DR-122 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171 |
|
||||
| UR-072 | - | DR-156 |
|
||||
| UR-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162 |
|
||||
|
||||
---
|
||||
|
||||
@@ -505,7 +548,46 @@ Internal architecture, components, and application logic.
|
||||
| UT-120 | Expiry reclaim takes only expired temporary entries: derived from `completed_at`+TTL, honouring an `expires_at` override, never a user download, and disabled by a zero TTL | DR-127 | Done |
|
||||
| UT-108 | LRU eviction reclaims only `'auto'` downloads and never a user's own, even when the user's is the oldest | DR-126 | Done |
|
||||
| UT-117 | A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded | DR-129 | Done |
|
||||
| UT-124 | `downloadedFilePath` leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative | DR-133 | Done |
|
||||
| UT-125 | A NULL `media_type` resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit `media_type` overrides the item | DR-135 | Done |
|
||||
| UT-126 | Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone | DR-136 | Done |
|
||||
| UT-127 | The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes | DR-137 | Done |
|
||||
| UT-121 | An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's | DR-130 | Done |
|
||||
| UT-122 | The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after `MAX_SYNC_ATTEMPTS`, ignores other users' rows, and parses both payload dialects | DR-131 | Done |
|
||||
| UT-123 | Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first | DR-132 | Done |
|
||||
| UT-130 | Video and background-audio stream URLs omit `AudioStreamIndex` when no track was chosen, and carry the exact index when one was | DR-140 | Done |
|
||||
| UT-131 | The Episode Focus View hero offers a download control | DR-142 | Done |
|
||||
| UT-132 | The series name links to the series and the `SxEy` badge to that season's anchor | DR-142 | Done |
|
||||
| UT-133 | Cast renders below the "More Episodes" strip, never above it | DR-062, DR-142 | Done |
|
||||
| UT-134 | The episode strip is hidden when the episode has no siblings | DR-142 | Done |
|
||||
| UT-135 | An episode with no `seriesId` still renders the Focus View, with title, Play and download | DR-142 | Done |
|
||||
| UT-136 | `episodeRedirectTarget` sends a bare episode page into its series' Focus View, and returns null with no series | DR-142 | Done |
|
||||
| UT-137 | Going offline with the toggle off pushes the closed gate and bumps `catalogFilterVersion` | DR-143 | Done |
|
||||
| UT-138 | The version bumps only after `set_show_server_catalog` resolves, never before | DR-143 | Done |
|
||||
| UT-139 | A failed visibility push is retried on the next identical transition rather than latched | DR-143 | Done |
|
||||
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
|
||||
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
||||
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
|
||||
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
|
||||
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
||||
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
|
||||
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
||||
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
||||
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
||||
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
||||
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
||||
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
|
||||
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
||||
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
||||
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
||||
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
||||
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
# Spec: Android native video — transparent-webview spike
|
||||
|
||||
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
|
||||
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
|
||||
**Status:** Spike succeeded — native video confirmed working on a physical
|
||||
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
|
||||
default off. Branch `feat/android-native-video`.
|
||||
|
||||
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
|
||||
composited behind a transparent Tauri WebView on Android. Nothing upstream
|
||||
blocked it and nothing upstream demonstrated it — this is, as far as the issue
|
||||
trackers show, the first working instance. The remaining flag is about test
|
||||
coverage and the unverified cases below, not about viability.
|
||||
**Requirements:** IR-004, UR-003, UR-004, UR-041 → DR-001, DR-004, DR-150, DR-151, DR-152
|
||||
**Note:** the original draft cited DR-023/DR-024 here. Those are the *subtitle*
|
||||
and *audio-track selection UI* requirements — unrelated to this work. The IDs
|
||||
actually implemented are DR-150 (native rendering behind the flag), DR-151 (the
|
||||
severed SurfaceView attach chain) and DR-152 (capabilities reported by Rust).
|
||||
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
|
||||
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
@@ -109,6 +121,46 @@ in here rather than leaving a second, subtler copy of the bug behind. If
|
||||
`get_player_status` does not currently expose enough to cover the audio case, add
|
||||
the field — that is backend work, and correct.
|
||||
|
||||
### Implementation findings (2026-08-11)
|
||||
|
||||
Two blockers existed that this spec did not anticipate. Both were in code the
|
||||
spec assumed was merely *unreachable*; it was also *broken*.
|
||||
|
||||
**1. The Kotlin attach chain was severed.** `JellyTauPlayer.setActivity()` had
|
||||
**zero callers** anywhere in the tree. `currentActivity` was therefore always
|
||||
null, so `autoAttachSurface()` logged "Cannot attach surface - no Activity
|
||||
reference" and returned. The `SurfaceView` was created and wired to ExoPlayer but
|
||||
never added to the view hierarchy — video would have decoded to a surface that
|
||||
was never on screen, *regardless* of webview transparency. Fixed by calling
|
||||
`JellyTauPlayer.setActivity(this)` from `MainActivity.onCreate`.
|
||||
|
||||
Note the knock-on: `PictureInPictureManager.canEnterPip()` gates on
|
||||
`VideoOverlayManager.isVideoSurfaceAttached()`, which was permanently false. PiP
|
||||
on the video path was dead for the same reason.
|
||||
|
||||
**2. `createAdapter()` was not the real gate.** It is never called by production
|
||||
code — `VideoPlayer.svelte` constructs `Html5PlayerAdapter` directly. The actual
|
||||
override was `VideoPlayer.svelte`'s INTERIM block, which read Rust's
|
||||
`useHtml5Element`, forced it to `true`, and called `playerStop()` to kill the
|
||||
native backend `player_play_item` had just started. Both sites are now fixed;
|
||||
`VideoPlayer.svelte` routes through `createAdapter()` so there is one gate.
|
||||
|
||||
**Transparency needs two independent layers cleared,** not one. The spec's
|
||||
Phase 1 named only `html, body`. Clearing just the page leaves the WebView
|
||||
widget's own background opaque, which is a black screen with audio — the exact
|
||||
symptom the INTERIM comment described as "native surface not visible". Both are
|
||||
now toggled together by `$lib/utils/videoSurface.ts`:
|
||||
|
||||
| Layer | Cleared by | Reachable from |
|
||||
|-------|-----------|----------------|
|
||||
| WebView widget background + window drawable | `AndroidVideoSurface.setTransparent()` (MainActivity) | Kotlin only |
|
||||
| `html`/`body` + app-shell `--color-background` | `data-native-video` attribute → app.css | CSS only |
|
||||
|
||||
Transparency is scoped to `tauri.android.conf.json` rather than the base config:
|
||||
a transparent window on Linux is a regression, since nothing renders behind it.
|
||||
It is also toggled per-session rather than set once — a permanently transparent
|
||||
window shows the launcher through the rest of the app.
|
||||
|
||||
### Phase 3 — surface positioning
|
||||
|
||||
The hard part, and where this most likely fails. The webview's `<video>` element
|
||||
@@ -124,6 +176,32 @@ the video is effectively fullscreen on Android, which it is in the player route.
|
||||
rotation or the mini-player transition without visible artefacts, the spike fails
|
||||
and we keep HTML5. Do not ship a janky native path for a codec win.
|
||||
|
||||
**Update: no rect plumbing was needed.** The premise — that the surface must be
|
||||
positioned to match a laid-out `<video>` box — does not hold on the player route,
|
||||
where video is fullscreen. `VideoOverlayManager` adds the SurfaceView at index 0
|
||||
of `android.R.id.content` with `MATCH_PARENT`, and `fitSurfaceToScreen()`
|
||||
(`JellyTauPlayer.kt`) already letterboxes/pillarboxes to the real video aspect
|
||||
ratio and re-centres via a `Gravity.CENTER` `FrameLayout.LayoutParams`. Rotation
|
||||
is handled by an `OnLayoutChangeListener` that re-fits on any bounds change. The
|
||||
frontend's native branch is a bare `flex-1` box, so there is no rect to report
|
||||
and nothing to keep in sync.
|
||||
|
||||
Fullscreen playback is confirmed working on device. But this reasoning rests
|
||||
entirely on the fullscreen assumption, so **the mini-player transition is the
|
||||
known gap** — it is the one case where the surface is *not* fullscreen, and
|
||||
therefore the one case where the "no rect plumbing needed" conclusion could
|
||||
still turn out to be wrong. If artefacts appear there, the fix is the rect
|
||||
reporting this section originally proposed, scoped to that transition alone.
|
||||
|
||||
### A trap for the next implementer
|
||||
|
||||
There is a **stale duplicate player** at
|
||||
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt`
|
||||
(only commit: `cfddc1e` "First working POC"). No `sourceSets` entry points at it,
|
||||
so it is not compiled — but edits made there silently do nothing. The canonical
|
||||
tree is `src-tauri/android/src`, synced into `gen/` by
|
||||
`scripts/sync-android-sources.sh`.
|
||||
|
||||
### What we gain if it works
|
||||
|
||||
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
||||
@@ -145,12 +223,13 @@ and we keep HTML5. Do not ship a janky native path for a codec win.
|
||||
The spike is **complete** when one of these is true:
|
||||
|
||||
**Success path**
|
||||
- [ ] Transparent WebView confirmed working on a physical device.
|
||||
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
|
||||
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
|
||||
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
|
||||
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
|
||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
|
||||
- [x] Transparent WebView confirmed working on a physical device (reported by the maintainer; the config that enables it is now committed in `tauri.android.conf.json`).
|
||||
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
|
||||
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
||||
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
|
||||
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
|
||||
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
|
||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
|
||||
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
|
||||
|
||||
**Failure path**
|
||||
@@ -159,8 +238,15 @@ The spike is **complete** when one of these is true:
|
||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||
|
||||
Either way:
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
|
||||
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
|
||||
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
||||
|
||||
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
|
||||
> no `bun`, so all of the above were run inside the CI builder image
|
||||
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
|
||||
> mount needs `:z` for SELinux, and `scripts/build-android.sh` hardcodes
|
||||
> `ANDROID_HOME="$HOME/Android/Sdk"`, so the image's SDK at `/opt/android-sdk`
|
||||
> must be symlinked there rather than passed by env var.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -179,7 +265,7 @@ native adapter), write the failing test first.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-023, DR-024`
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
|
||||
- Adapter-selection tests → `UT-xxx`
|
||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Spec: streaming bitrate cap
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
|
||||
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
|
||||
|
||||
## Summary
|
||||
|
||||
The viewer picks a bandwidth ceiling for video — from `Original` (no client
|
||||
limit) down to 720 kbps — and every video the app opens is fetched within it,
|
||||
live TV included. The choice is made once in Settings and persists across
|
||||
restarts; a single video can be moved to another ceiling from the player, which
|
||||
re-opens the stream and resumes where it was without changing the saved default.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every video URL the app built carried a fixed allowance —
|
||||
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
|
||||
negotiation asked for 20 Mbps, and the device profile advertised
|
||||
`999999999`, which invites the server to direct-play a source of any size. On a
|
||||
metered or slow connection there was no lever at all short of not watching.
|
||||
|
||||
The related UR-070 asks for something adjacent but different: a list of the
|
||||
renditions *the server can produce for this item*. That needs per-item
|
||||
`MediaSources` negotiation and is still proposed. What was missing first is
|
||||
cruder and more valuable: a device-wide budget that holds regardless of what is
|
||||
playing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
|
||||
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
|
||||
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
|
||||
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
|
||||
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
|
||||
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
|
||||
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
|
||||
|
||||
The frontend holds one string — the serde token for the chosen variant — and
|
||||
labels/details it received from Rust. It never encodes a bitrate, a resolution
|
||||
or a parameter name.
|
||||
|
||||
## Design
|
||||
|
||||
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
|
||||
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
|
||||
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
|
||||
`video_bitrate()` (= total − audio), `max_height()`, `label()`, `detail()`.
|
||||
|
||||
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
|
||||
`repository/online.rs`, read by every builder. Process-wide rather than a field
|
||||
on `OnlineRepository` because it is a preference about *this device's
|
||||
connection*: it must survive a repository rebuilt on re-login, and the URL
|
||||
builders and the negotiation have to agree on it or the cap leaks. This mirrors
|
||||
`offline::INCLUDE_CATALOG_BROWSE`.
|
||||
|
||||
Enforcement points — all four are required:
|
||||
|
||||
| Point | What the cap sets |
|
||||
|-------|-------------------|
|
||||
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
|
||||
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
|
||||
| `open_live_stream` | `MaxStreamingBitrate` |
|
||||
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
|
||||
|
||||
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
|
||||
the server refuse to *direct play* a source fatter than the ceiling; without it
|
||||
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
|
||||
it.
|
||||
|
||||
IPC:
|
||||
|
||||
```rust
|
||||
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
|
||||
player_set_stream_quality(repository_handle, quality, use_html5,
|
||||
current_position, media_source_id, audio_stream_index)
|
||||
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
|
||||
```
|
||||
|
||||
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
|
||||
persisted before the field existed load as uncapped).
|
||||
`player_set_video_settings` applies it and writes it to `app_settings`;
|
||||
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
|
||||
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
|
||||
|
||||
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
|
||||
tauri-specta only camelCases the `strategy` tag. The facade
|
||||
(`playerController.setStreamQuality`) dispatches `reloadSource` for
|
||||
`reloadStream` and does nothing for `native`, because the backend has already
|
||||
reloaded itself.
|
||||
|
||||
Mid-playback the change applies to the current video **and** becomes the process
|
||||
ceiling for what follows, but it is not persisted: the in-player menu is a "this
|
||||
film, this connection" control and Settings owns the durable default.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
|
||||
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
|
||||
- Adaptive/automatic selection from measured throughput.
|
||||
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `bun run check` passes.
|
||||
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
|
||||
- [x] `bun run test` passes.
|
||||
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
|
||||
- [x] New code carries `// TRACES:` comments.
|
||||
- [x] `bindings.ts` regenerated from Rust.
|
||||
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
|
||||
|
||||
## Testing
|
||||
|
||||
Rust (`cargo test`):
|
||||
|
||||
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
|
||||
- `test_video_stream_url_uncapped_keeps_legacy_allowance` — `Original` is unchanged and adds no `MaxHeight`.
|
||||
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
|
||||
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
|
||||
|
||||
The ceiling is process-wide, so tests that depend on it serialise on a guard
|
||||
(`QualityFixture`) that restores `Original` on drop — including the two
|
||||
pre-existing stream-URL tests, which would otherwise see another test's cap.
|
||||
|
||||
`get_playback_info` and `open_live_stream` need a live server and are not unit
|
||||
tested; their behaviour is the enum's `max_bitrate()`, which is.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `StreamingQuality`, `VideoSettings.streaming_quality` — `UR-074 | DR-162`
|
||||
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
|
||||
- Audio-only handoff — `UR-040, UR-074 | DR-162`
|
||||
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
|
||||
- Tests — `UT-156`, `UT-157`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
|
||||
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
|
||||
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
|
||||
- A parallel Claude session may be active in this repo; `git diff` before
|
||||
repairing unexpected changes. DR-160/161 were claimed by such a session while
|
||||
this feature was in flight, which is why it is DR-162.
|
||||
+5616
-2027
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(71);
|
||||
expect(defined.UR).toBe(74);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(127);
|
||||
expect(defined.JA).toBe(34);
|
||||
expect(defined.total).toBe(264);
|
||||
expect(defined.DR).toBe(163);
|
||||
expect(defined.JA).toBe(35);
|
||||
expect(defined.total).toBe(304);
|
||||
});
|
||||
});
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
# Stamp the release version into every file that carries it.
|
||||
#
|
||||
# The git tag is the single source of truth for a release version. The versions
|
||||
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
|
||||
# dev builds; a tagged build overwrites all of them from the tag so they cannot
|
||||
# disagree with each other or with the tag.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/set-version.sh 0.5.0 # explicit
|
||||
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
|
||||
# ./scripts/set-version.sh # derive from git describe (dev builds)
|
||||
#
|
||||
# Accepts the version with or without a leading "v".
|
||||
#
|
||||
# Why a script and not four sed lines in CI: the version lived in four files and
|
||||
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
|
||||
# a matching installer name and mismatched package metadata. Keeping the write in
|
||||
# one place is what makes "the tag is authoritative" actually true.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VERSION="${1:-${JELLYTAU_VERSION:-}}"
|
||||
|
||||
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
|
||||
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
|
||||
# bare version as "no version given" and fall through to git describe, so a
|
||||
# branch build gets a sane dev version instead of failing the job.
|
||||
case "$VERSION" in
|
||||
refs/*) VERSION="" ;;
|
||||
esac
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
# No explicit version: derive from the most recent tag. Dev builds land on
|
||||
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
|
||||
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
|
||||
fi
|
||||
|
||||
# Tags are written v0.5.0; the files carry a bare semver.
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
# Validate before writing anything — a malformed version silently propagated
|
||||
# into four files is far worse than a failed script.
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||
echo "❌ Not a valid semver: '$VERSION'" >&2
|
||||
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📌 Setting version to $VERSION"
|
||||
|
||||
# --- The three committed manifests -----------------------------------------
|
||||
# Anchored to the first "version" key so a dependency's version is never hit.
|
||||
|
||||
# package.json — the top-level "version", which sits in the first few lines.
|
||||
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
|
||||
|
||||
# tauri.conf.json — likewise; this is the one the bundler reads for installer
|
||||
# names, and the one CI used to patch alone.
|
||||
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
|
||||
|
||||
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
|
||||
# the first occurrence of a line-anchored `version = "..."`.
|
||||
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
|
||||
|
||||
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
|
||||
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
|
||||
# lock mid-build and dirty the tree.
|
||||
if [ -f src-tauri/Cargo.lock ]; then
|
||||
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
||||
fi
|
||||
|
||||
# --- Android versionCode ----------------------------------------------------
|
||||
# Only when the generated Android project exists (i.e. after `tauri android
|
||||
# init`); on Linux/Windows jobs there is nothing to stamp.
|
||||
#
|
||||
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
|
||||
# That is both tiny and NOT monotonic across our history: earlier local/dev
|
||||
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
|
||||
# *downgrade* and Android refuses the update.
|
||||
#
|
||||
# The floor has to clear the highest code actually in the field, which is not the
|
||||
# same as the highest this formula has produced. v0.5.2 shipped versionCode
|
||||
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
|
||||
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
|
||||
# every 0.5.x release built from it was an un-installable downgrade for anyone
|
||||
# already on v0.5.2, which is exactly the failure this block exists to prevent.
|
||||
# The multipliers are widened and the floor raised past 5002 accordingly.
|
||||
#
|
||||
# code = 10000 + major*1000000 + minor*1000 + patch
|
||||
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
if [ -f "$PROPS" ]; then
|
||||
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
|
||||
# $(( )) would abort the script under `set -e`.
|
||||
CORE="${VERSION%%-*}"
|
||||
CORE="${CORE%%+*}"
|
||||
MAJ=$(echo "$CORE" | cut -d. -f1)
|
||||
MIN=$(echo "$CORE" | cut -d. -f2)
|
||||
PAT=$(echo "$CORE" | cut -d. -f3)
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
|
||||
echo " versionCode=$CODE (from $CORE)"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
else
|
||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Report -----------------------------------------------------------------
|
||||
echo "✅ Version stamped:"
|
||||
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
|
||||
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
|
||||
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
|
||||
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
|
||||
exit 0
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Guards for scripts/set-version.sh — the release version stamper.
|
||||
*
|
||||
* TRACES: DR-153 | UT-150
|
||||
*
|
||||
* These run the real script against a throwaway copy of the manifests, because
|
||||
* the failure modes are all in the shell, not in any TS logic: a regex that also
|
||||
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
|
||||
* a CI ref reaching the validator verbatim.
|
||||
*
|
||||
* The versionCode formula matters most. Android refuses an update whose code is
|
||||
* lower than the installed one, and builds already in the field shipped code
|
||||
* 1000 — so any formula that can emit a smaller number for a *newer* release
|
||||
* bricks updates for those users, silently and irreversibly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||
const script = path.join(repoRoot, "scripts", "set-version.sh");
|
||||
|
||||
let tmp: string;
|
||||
|
||||
/** A minimal repo skeleton: just the files the script rewrites. */
|
||||
function seed(dir: string) {
|
||||
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
||||
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
|
||||
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "tauri.conf.json"),
|
||||
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
|
||||
);
|
||||
// A dependency carrying its own `version =` is the trap: a greedy regex
|
||||
// rewrites it too and the build then resolves the wrong crate.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "Cargo.toml"),
|
||||
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "Cargo.lock"),
|
||||
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||
"tauri.android.versionCode=1\n"
|
||||
);
|
||||
}
|
||||
|
||||
function run(version: string, dir = tmp) {
|
||||
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
|
||||
cwd: dir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return fs.readFileSync(path.join(tmp, rel), "utf-8");
|
||||
}
|
||||
|
||||
function versionCode(): number {
|
||||
const m = read("src-tauri/gen/android/app/tauri.properties").match(
|
||||
/^tauri\.android\.versionCode=(\d+)$/m
|
||||
);
|
||||
return m ? Number(m[1]) : NaN;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
|
||||
seed(tmp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("set-version.sh", () => {
|
||||
it("stamps the version into all four manifests", () => {
|
||||
run("0.5.0");
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
|
||||
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
|
||||
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
|
||||
});
|
||||
|
||||
it("accepts a leading v, as git tags are written", () => {
|
||||
run("v0.5.0");
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||
});
|
||||
|
||||
// The regression that motivates anchoring the patterns.
|
||||
it("does not rewrite dependency versions", () => {
|
||||
run("0.5.0");
|
||||
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
|
||||
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
|
||||
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
|
||||
});
|
||||
|
||||
describe("Android versionCode", () => {
|
||||
// A newer release must never produce a smaller number than an older one, or
|
||||
// Android refuses the update. The floor tracks the highest code actually in
|
||||
// the field, which is NOT the same as the highest this formula has produced:
|
||||
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
|
||||
// the `minor*100` formula that replaced it yields only 1502 for that same
|
||||
// version — so every 0.5.x release built from it was an un-installable
|
||||
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
|
||||
it("clears the highest code shipped by earlier builds", () => {
|
||||
run("0.0.1");
|
||||
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
|
||||
expect(versionCode()).toBeGreaterThan(5002);
|
||||
});
|
||||
|
||||
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
|
||||
run("0.5.3");
|
||||
expect(versionCode()).toBeGreaterThan(5002);
|
||||
});
|
||||
|
||||
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
["0.0.14", 10014],
|
||||
["0.0.15", 10015],
|
||||
["0.1.0", 11000],
|
||||
["0.4.8", 14008],
|
||||
["0.5.0", 15000],
|
||||
["0.5.3", 15003],
|
||||
["1.0.0", 1010000],
|
||||
];
|
||||
for (const [version, code] of cases) {
|
||||
seed(tmp);
|
||||
run(version);
|
||||
expect(versionCode(), `versionCode for ${version}`).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("increases monotonically across an upgrade sequence", () => {
|
||||
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
|
||||
const codes = ordered.map((v) => {
|
||||
seed(tmp);
|
||||
run(v);
|
||||
return versionCode();
|
||||
});
|
||||
const sorted = [...codes].sort((a, b) => a - b);
|
||||
expect(codes).toEqual(sorted);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
|
||||
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
|
||||
// stripped before the arithmetic.
|
||||
it("derives the code from the numeric core of a prerelease", () => {
|
||||
run("0.6.0-rc1");
|
||||
expect(versionCode()).toBe(16000);
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation", () => {
|
||||
it("rejects a malformed version without writing anything", () => {
|
||||
expect(() => run("not-a-version")).toThrow();
|
||||
// The manifests must be untouched, not half-written.
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
|
||||
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
|
||||
});
|
||||
|
||||
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
|
||||
// that is still a full ref, and must not fail the job.
|
||||
it("falls back to a dev version when handed a non-tag ref", () => {
|
||||
const out = run("refs/heads/master");
|
||||
expect(out).not.toMatch(/refs\/heads/);
|
||||
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,16 @@ if [ -d "$RES_SRC" ]; then
|
||||
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
||||
echo " Copied res: values"
|
||||
fi
|
||||
|
||||
# xml/ (network_security_config.xml): referenced from the manifest, so a
|
||||
# missing copy fails the resource link rather than degrading quietly.
|
||||
# Merged into Tauri's generated xml/ (which holds file_paths.xml) rather
|
||||
# than replacing it.
|
||||
if [ -d "$RES_SRC/xml" ]; then
|
||||
mkdir -p "$RES_DST/xml"
|
||||
cp "$RES_SRC"/xml/*.xml "$RES_DST/xml/"
|
||||
echo " Copied res: xml"
|
||||
fi
|
||||
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||
@@ -108,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
|
||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||
fi
|
||||
|
||||
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
|
||||
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
|
||||
# slow, and a hard failure when the CDN drops the connection mid-transfer
|
||||
# ("Unexpected end of file from server"), which is what broke the release APK
|
||||
# job. The builder image ships the matching distribution under /opt/gradle/dist,
|
||||
# so when it's present repoint the wrapper at that local zip and build offline.
|
||||
# Outside the image (dev machines) the properties file is left untouched and the
|
||||
# wrapper downloads as usual.
|
||||
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
|
||||
if [ -f "$WRAPPER_PROPS" ]; then
|
||||
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
|
||||
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
|
||||
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
|
||||
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
|
||||
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
|
||||
"$WRAPPER_PROPS"
|
||||
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
|
||||
elif [ -n "$WANTED_VERSION" ]; then
|
||||
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✓ Android sources synced successfully"
|
||||
|
||||
Generated
+39
-1
@@ -150,6 +150,12 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -552,6 +558,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -1671,12 +1683,24 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1994,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.4.1"
|
||||
version = "0.5.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
@@ -2025,6 +2049,7 @@ dependencies = [
|
||||
"tauri-plugin-os",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-rusqlite",
|
||||
"tokio-util",
|
||||
@@ -4192,6 +4217,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4571,6 +4597,18 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.4.1"
|
||||
version = "0.5.4"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -23,7 +23,12 @@ debug = "line-tables-only"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
# protocol-asset serves downloaded media and cached thumbnails to the webview
|
||||
# over http://asset.localhost; without it convertFileSrc yields a URL nothing
|
||||
# answers. Paired with app.security.assetProtocol in tauri.conf.json, which
|
||||
# scopes it to $APPDATA/**.
|
||||
# TRACES: UR-071 | DR-134
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -54,6 +59,7 @@ env_logger = "0.11"
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
specta-typescript = "=0.0.9"
|
||||
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||
tiny_http = { version = "0.12.0", default-features = false }
|
||||
|
||||
# Linux-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* Hides and restores the Android system bars for full-screen video.
|
||||
*
|
||||
* TRACES: UR-066 | DR-157
|
||||
*
|
||||
* ## Why the web layer cannot do this
|
||||
*
|
||||
* `document.documentElement.requestFullscreen()` is the only fullscreen control
|
||||
* the frontend has, and inside an Android WebView it does nothing to the
|
||||
* *Activity*: it expands the fullscreen element within the web viewport and
|
||||
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` — which
|
||||
* MainActivity must call, and which SDK 36 makes non-optional — the WebView
|
||||
* already spans the whole window, so "fullscreen" was a no-op that changed
|
||||
* nothing on screen while the status bar and navigation/gesture bar stayed
|
||||
* painted over the video.
|
||||
*
|
||||
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
|
||||
* which is reachable only from native code. Hence this bridge.
|
||||
*
|
||||
* ## Behaviour
|
||||
*
|
||||
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
|
||||
* a swipe from either edge brings them back *transiently* — over the video,
|
||||
* auto-hiding again — rather than permanently resizing the window mid-playback.
|
||||
* That is the standard behaviour for immersive video and keeps the system's own
|
||||
* back/home gestures reachable.
|
||||
*
|
||||
* [exit] restores them. It must be called when leaving fullscreen **and** when
|
||||
* the player is torn down, or the bars stay hidden on the library screens behind
|
||||
* it.
|
||||
*
|
||||
* Both must run on the main thread; the callers in MainActivity post them there,
|
||||
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
|
||||
*
|
||||
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
|
||||
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
|
||||
* republishes them and the player's control layer stops reserving space it no
|
||||
* longer needs.
|
||||
*/
|
||||
object ImmersiveModeBridge {
|
||||
|
||||
private fun controller(activity: Activity): WindowInsetsControllerCompat =
|
||||
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
|
||||
|
||||
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
|
||||
fun enter(activity: Activity) {
|
||||
controller(activity).apply {
|
||||
systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
android.util.Log.d("ImmersiveMode", "system bars hidden")
|
||||
}
|
||||
|
||||
/** Restore the system bars. Safe to call when they are already showing. */
|
||||
fun exit(activity: Activity) {
|
||||
controller(activity).show(WindowInsetsCompat.Type.systemBars())
|
||||
android.util.Log.d("ImmersiveMode", "system bars restored")
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,20 @@ class MainActivity : TauriActivity() {
|
||||
// so on devices with a tall opaque 3-button bar. (UR-066)
|
||||
WindowInsetsBridge.install(this)
|
||||
|
||||
// Hand the player an Activity reference so it can attach its video
|
||||
// SurfaceView to the content view behind the WebView.
|
||||
//
|
||||
// Without this, JellyTauPlayer.currentActivity stays null forever and
|
||||
// autoAttachSurface() logs "Cannot attach surface - no Activity reference"
|
||||
// and returns — so the SurfaceView is created, wired to ExoPlayer, and then
|
||||
// never added to the view hierarchy. Native video decoded to a surface that
|
||||
// was never on screen. setActivity() stores into a companion-object
|
||||
// WeakReference, so calling it here (before Rust initializes the player over
|
||||
// JNI) is safe and is the case it was written for.
|
||||
//
|
||||
// TRACES: UR-003, UR-041 | DR-151
|
||||
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
|
||||
|
||||
// Configure WebView for media playback after Tauri initialization
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -232,6 +246,19 @@ class MainActivity : TauriActivity() {
|
||||
fun setAutoEnterEnabled(enabled: Boolean) {
|
||||
autoEnterPipEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state.
|
||||
*
|
||||
* Without this PiP only ever knew about the native ExoPlayer surface,
|
||||
* which is behind an experimental flag that defaults to off — so in the
|
||||
* shipping configuration nothing ever satisfied canEnterPip and the
|
||||
* button did nothing. (DR-160)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
@@ -271,6 +298,68 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Native video compositing: let the frontend make the WebView transparent
|
||||
// so the ExoPlayer SurfaceView behind it is visible (UR-003, UR-004).
|
||||
//
|
||||
// Toggled rather than set once because a transparent WebView is only
|
||||
// correct while a native video is on screen — every other screen needs its
|
||||
// opaque background, and leaving the window transparent shows the
|
||||
// launcher/wallpaper through the app.
|
||||
//
|
||||
// The CSS in app.css clears the *web* layer's backgrounds; this clears the
|
||||
// WebView widget's own background, which CSS cannot reach. Both are
|
||||
// required — an opaque WebView hides the surface no matter what the page
|
||||
// paints.
|
||||
//
|
||||
// TRACES: UR-003, UR-004 | DR-150
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Make the WebView background transparent (true) or opaque (false). */
|
||||
@JavascriptInterface
|
||||
fun setTransparent(transparent: Boolean) {
|
||||
handler.post {
|
||||
val color = if (transparent) {
|
||||
android.graphics.Color.TRANSPARENT
|
||||
} else {
|
||||
android.graphics.Color.BLACK
|
||||
}
|
||||
mediaWebView?.setBackgroundColor(color)
|
||||
// The WebView's window/surface must also stop painting opaque, or a
|
||||
// hardware-accelerated WebView still composites its own background.
|
||||
window.setBackgroundDrawable(
|
||||
android.graphics.drawable.ColorDrawable(color)
|
||||
)
|
||||
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether native-video compositing is available on this platform. */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidVideoSurface")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
|
||||
|
||||
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
|
||||
// inside a WebView cannot touch the Activity window, so without this the
|
||||
// status and navigation bars stayed painted over full-screen video.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Hide the system bars for full-screen playback. */
|
||||
@JavascriptInterface
|
||||
fun enter() {
|
||||
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Restore the system bars on leaving fullscreen or the player. */
|
||||
@JavascriptInterface
|
||||
fun exit() {
|
||||
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Whether native immersive mode exists (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidImmersive")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
|
||||
|
||||
// Window insets (safe areas). The push path above races the page load, so
|
||||
// the frontend pulls the current values on mount through this bridge.
|
||||
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||
|
||||
+141
-33
@@ -46,6 +46,62 @@ object PictureInPictureManager {
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var hiddenWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* State of an HTML5 `<video>` playing inside the WebView, reported by the
|
||||
* frontend.
|
||||
*
|
||||
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
|
||||
* required a SurfaceView to be attached and rendering. But native video is
|
||||
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
|
||||
* shipping configuration video plays in the WebView's `<video>` element and
|
||||
* every one of those conditions is false. `enterPip` therefore always bailed
|
||||
* with "no local video playing": PiP could not work at all, however the
|
||||
* button was pressed.
|
||||
*
|
||||
* On this path the WebView *is* the video, which inverts two things: the
|
||||
* WebView must stay visible in PiP rather than be hidden, and play/pause has
|
||||
* to reach the element rather than ExoPlayer. Both are handled below.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
@Volatile
|
||||
private var html5VideoActive = false
|
||||
|
||||
@Volatile
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
@Volatile
|
||||
private var html5AspectRatio: Rational? = null
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state from the frontend.
|
||||
*
|
||||
* @param active whether a video element is currently the playback surface
|
||||
* @param width intrinsic video width, for the PiP window's aspect ratio
|
||||
* @param height intrinsic video height
|
||||
* @param playing whether it is playing right now, for the PiP play/pause action
|
||||
*/
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
html5VideoActive = active
|
||||
html5VideoPlaying = playing
|
||||
html5AspectRatio = if (active && width > 0 && height > 0) {
|
||||
clampedRatio(width.toDouble() / height.toDouble())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when PiP would be showing the native surface rather than the WebView. */
|
||||
private fun isNativeVideoPath(): Boolean = try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "native video path check failed", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
||||
* and the user (or device manufacturer) can disable the feature per-app.
|
||||
@@ -64,15 +120,10 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun canEnterPip(activity: Activity): Boolean {
|
||||
if (!isPipSupported(activity)) return false
|
||||
return try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "canEnterPip check failed", e)
|
||||
false
|
||||
}
|
||||
// Either surface will do: the native one, or the WebView's `<video>`,
|
||||
// which is what actually plays while experimentalNativeVideo is off.
|
||||
// (DR-160)
|
||||
return isNativeVideoPath() || html5VideoActive
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,32 +176,47 @@ object PictureInPictureManager {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
null
|
||||
}
|
||||
|
||||
val surface = player.getSurfaceView() ?: return null
|
||||
// The surface has already been letterboxed to the video's aspect ratio
|
||||
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
||||
val width = surface.width
|
||||
val height = surface.height
|
||||
if (width <= 0 || height <= 0) return null
|
||||
val surface = player?.getSurfaceView()
|
||||
if (surface != null && surface.width > 0 && surface.height > 0) {
|
||||
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
||||
}
|
||||
|
||||
val ratio = width.toDouble() / height.toDouble()
|
||||
val minRatio = 1.0 / 2.39
|
||||
val maxRatio = 2.39
|
||||
val clamped = ratio.coerceIn(minRatio, maxRatio)
|
||||
// No native surface: the WebView is the video, so use the intrinsic size
|
||||
// the frontend reported. (DR-160)
|
||||
return html5AspectRatio
|
||||
}
|
||||
|
||||
// Scale to integers; Rational(width, height) directly can overflow for
|
||||
// large surfaces, and the clamped value may not match the raw pixels.
|
||||
/**
|
||||
* Clamp a ratio to the range Android accepts and express it as a [Rational].
|
||||
*
|
||||
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
|
||||
* IllegalArgumentException, which would otherwise take down the Activity on
|
||||
* unusually tall or wide content. Scaled to integers because
|
||||
* `Rational(width, height)` can overflow for large surfaces, and the clamped
|
||||
* value may not match the raw pixels anyway.
|
||||
*/
|
||||
private fun clampedRatio(ratio: Double): Rational {
|
||||
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
|
||||
return Rational((clamped * 1000).toInt(), 1000)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||
val isPlaying = try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
|
||||
// and the button would be stuck showing "Play" mid-playback. (DR-160)
|
||||
val isPlaying = if (isNativeVideoPath()) {
|
||||
try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
html5VideoPlaying
|
||||
}
|
||||
|
||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||
@@ -222,11 +288,20 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||
if (isInPipMode) {
|
||||
hideWebView(activity)
|
||||
// Hiding the WebView is correct only when the video is *behind* it on
|
||||
// the native surface. On the HTML5 path the WebView is the video, so
|
||||
// hiding it would leave an empty black PiP window — the frontend
|
||||
// instead strips its own chrome when it hears the event below.
|
||||
// (DR-160)
|
||||
if (isNativeVideoPath()) {
|
||||
hideWebView(activity)
|
||||
}
|
||||
registerReceiver(activity)
|
||||
dispatchWebEvent(activity, "jellytau-pip-entered")
|
||||
} else {
|
||||
unregisterReceiver(activity)
|
||||
showWebView()
|
||||
dispatchWebEvent(activity, "jellytau-pip-exited")
|
||||
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
||||
// the restored full-screen bounds or the video stays postage-stamp sized.
|
||||
try {
|
||||
@@ -237,6 +312,23 @@ object PictureInPictureManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a DOM event into the WebView.
|
||||
*
|
||||
* The HTML5 PiP path is a conversation with the frontend rather than
|
||||
* something native can do alone: it has to be told to strip its chrome when
|
||||
* the window shrinks, and to play/pause the element. (DR-160)
|
||||
*/
|
||||
private fun dispatchWebEvent(activity: Activity, name: String) {
|
||||
val webView = findWebView(activity.window.decorView) ?: return
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideWebView(activity: Activity) {
|
||||
val webView = findWebView(activity.window.decorView)
|
||||
if (webView == null) {
|
||||
@@ -264,14 +356,30 @@ object PictureInPictureManager {
|
||||
val r = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||
|
||||
if (isNativeVideoPath()) {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (control) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
}
|
||||
} else {
|
||||
// The WebView owns playback here, so the command has to reach
|
||||
// the `<video>` element. Driving ExoPlayer instead would do
|
||||
// nothing at all, which is what a PiP button on the HTML5 path
|
||||
// used to do. (DR-160)
|
||||
val name = when (control) {
|
||||
CONTROL_PLAY -> "jellytau-pip-play"
|
||||
CONTROL_PAUSE -> "jellytau-pip-pause"
|
||||
else -> return
|
||||
}
|
||||
dispatchWebEvent(activity, name)
|
||||
html5VideoPlaying = control == CONTROL_PLAY
|
||||
}
|
||||
// Swap the button to reflect the new state.
|
||||
updatePipActions(activity)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
|
||||
/**
|
||||
* Detects hardware codec capabilities using MediaCodecList.
|
||||
@@ -75,6 +79,36 @@ object CodecDetector {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Report how many channels the *current audio output route* can voice.
|
||||
*
|
||||
* This is a different question from "can this device decode 5.1", which the
|
||||
* codec list already answers: a phone decodes a 5.1 AC-3 track happily and
|
||||
* still has only two channels to play it out of. Left unreported, Jellyfin
|
||||
* is free to direct-play the multichannel track, and what the user hears is
|
||||
* device dependent — a failed AudioSink configuration (silence) or dialogue
|
||||
* folded into the surround channels and lost.
|
||||
*
|
||||
* Returns 0 when there is no answer; Rust reads that as "unknown" and falls
|
||||
* back to stereo rather than claiming a capability we have not observed.
|
||||
*/
|
||||
@UnstableApi
|
||||
fun detectMaxAudioChannels(context: Context): Int {
|
||||
return try {
|
||||
val capabilities = AudioCapabilities.getCapabilities(
|
||||
context,
|
||||
AudioAttributes.DEFAULT,
|
||||
/* routedDevice= */ null
|
||||
)
|
||||
val channels = capabilities.maxChannelCount
|
||||
Log.i(TAG, "Audio route max channel count: $channels")
|
||||
channels.coerceAtLeast(0)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error querying audio capabilities", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Android MIME types to Jellyfin codec names.
|
||||
*
|
||||
|
||||
+77
-19
@@ -119,6 +119,54 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
|
||||
// media3 seeks by more routes than seekTo(long), and the ones below
|
||||
// reach the *real* ExoPlayer if they are not overridden — bypassing
|
||||
// Rust entirely and operating on the handoff stream's relative
|
||||
// timeline. That is the same mechanism as the truncation bug, reached
|
||||
// by a different door.
|
||||
//
|
||||
// seekToDefaultPosition is deliberately swallowed rather than
|
||||
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
|
||||
// player and then calls play(); on a handoff stream the seek lands at
|
||||
// stream zero — the point the screen was locked at — which is exactly
|
||||
// the reported jump-back. Sending "seek:0.0" instead would be worse
|
||||
// still, restarting the whole episode. Rust already owns what "play
|
||||
// after the stream ended" means (truncation recovery, or advancing to
|
||||
// the next episode), and the play() that follows reaches it, so the
|
||||
// right move here is to not move at all.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-159
|
||||
override fun seekToDefaultPosition() {
|
||||
android.util.Log.d(
|
||||
"JellyTauPlaybackService",
|
||||
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
|
||||
)
|
||||
}
|
||||
|
||||
override fun seekToDefaultPosition(mediaItemIndex: Int) {
|
||||
android.util.Log.d(
|
||||
"JellyTauPlaybackService",
|
||||
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
|
||||
)
|
||||
}
|
||||
|
||||
// `currentPosition` is ExoPlayer's own, so it is relative during a
|
||||
// handoff; the base makes the target absolute, which is what Rust
|
||||
// expects from every command on this boundary.
|
||||
override fun seekBack() {
|
||||
val target =
|
||||
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
|
||||
.coerceAtLeast(0.0)
|
||||
nativeOnMediaCommand("seek:$target")
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
val target =
|
||||
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
|
||||
.coerceAtLeast(0.0)
|
||||
nativeOnMediaCommand("seek:$target")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
nativeOnMediaCommand("stop")
|
||||
}
|
||||
@@ -262,23 +310,32 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
private var lastArtist: String = ""
|
||||
private var lastIsPlaying: Boolean = false
|
||||
|
||||
// Base offset (ms) added to every position reported to the lockscreen
|
||||
// MediaSession. During a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||
// however, is the full absolute length — so without this base the scrubber
|
||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||
// position via setPositionOffset(); 0 for normal playback.
|
||||
private var positionOffsetMs: Long = 0L
|
||||
// The handoff base (ms): during a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
|
||||
// starts at 0 *there* and every position it reports is relative to it. This
|
||||
// is the number that converts one back to a real position on the episode.
|
||||
//
|
||||
// It is deliberately read, not applied, here. This used to be a display-only
|
||||
// correction added at the two setPlaybackState calls below, which left every
|
||||
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
|
||||
// seeks — working in the relative timeline while treating it as absolute, each
|
||||
// crossing silently losing exactly `base` seconds. The conversion now happens
|
||||
// once, in JellyTauPlayer's position tick, so everything downstream of it
|
||||
// speaks the episode's timeline; applying it again here would double-count.
|
||||
//
|
||||
// TRACES: UR-040 | DR-159
|
||||
@Volatile
|
||||
var handoffBaseMs: Long = 0L
|
||||
private set
|
||||
|
||||
/**
|
||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||
* Set the handoff base (seconds). Called by the native layer when entering or
|
||||
* leaving a background-audio handoff; 0 clears it for normal playback, where
|
||||
* ExoPlayer's position is already absolute.
|
||||
*/
|
||||
fun setPositionOffset(offsetSeconds: Double) {
|
||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||
fun setHandoffBase(offsetSeconds: Double) {
|
||||
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,8 +371,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
session.setMetadata(metadataBuilder.build())
|
||||
|
||||
// Update MediaSession playback state (position made absolute via the base offset).
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
// Already absolute: this call comes from Rust, whose stored position is on
|
||||
// the episode's timeline. (DR-159)
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
|
||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||
// arrive on the session poller thread and can race with (or arrive
|
||||
@@ -337,15 +395,15 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||
* from the last play/pause and drifts out of sync with actual playback.
|
||||
*
|
||||
* @param position Position in milliseconds
|
||||
* @param position Absolute position in milliseconds, on the item's own
|
||||
* timeline — the caller has already applied [handoffBaseMs].
|
||||
* @param isPlaying Whether playback is currently active
|
||||
*/
|
||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||
lastIsPlaying = isPlaying
|
||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
// Only rebuild the notification when the play/pause icon actually flips.
|
||||
if (notificationStateChanged) {
|
||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||
|
||||
@@ -130,7 +130,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
|
||||
// Detect and report hardware codec capabilities to Rust
|
||||
detectAndReportCodecs()
|
||||
detectAndReportCodecs(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,23 +141,31 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* Called during player initialization.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun detectAndReportCodecs() {
|
||||
fun detectAndReportCodecs(context: Context) {
|
||||
val capabilities = CodecDetector.detectHardwareCodecs()
|
||||
|
||||
// Convert lists to comma-separated strings for JNI transfer
|
||||
val videoCodecsStr = capabilities.videoCodecs.joinToString(",")
|
||||
val audioCodecsStr = capabilities.audioCodecs.joinToString(",")
|
||||
|
||||
android.util.Log.i("JellyTauPlayer", "Reporting codecs to Rust: video=$videoCodecsStr, audio=$audioCodecsStr")
|
||||
// What the route can *voice*, which the codec list does not answer.
|
||||
// 0 means "no answer"; Rust falls back to stereo.
|
||||
val maxAudioChannels = CodecDetector.detectMaxAudioChannels(context)
|
||||
|
||||
android.util.Log.i("JellyTauPlayer", "Reporting codecs to Rust: video=$videoCodecsStr, audio=$audioCodecsStr, maxAudioChannels=$maxAudioChannels")
|
||||
|
||||
// Call native method to store in Rust
|
||||
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr)
|
||||
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr, maxAudioChannels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Native method to report detected codecs to Rust.
|
||||
*/
|
||||
private external fun nativeOnCodecsDetected(videoCodecs: String, audioCodecs: String)
|
||||
private external fun nativeOnCodecsDetected(
|
||||
videoCodecs: String,
|
||||
audioCodecs: String,
|
||||
maxAudioChannels: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if the player is initialized.
|
||||
@@ -232,6 +240,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
|
||||
/**
|
||||
* Set when a video was loaded but audio focus was not granted outright.
|
||||
*
|
||||
* `setAcceptsDelayedFocusGain(true)` means the system may answer DELAYED and
|
||||
* hand us focus later; until then it withholds our audio. Starting playback
|
||||
* anyway plays the video silently, which is exactly the "video has no sound"
|
||||
* symptom. We hold playback and start it from the AUDIOFOCUS_GAIN callback.
|
||||
*/
|
||||
private var pendingPlayOnFocusGain = false
|
||||
|
||||
/** Whether we currently hold audio focus, so `play()` does not re-request
|
||||
* (and leak) a focus request we already own. */
|
||||
private var hasAudioFocus = false
|
||||
|
||||
init {
|
||||
// Configure audio attributes for music playback with audio focus handling
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
@@ -361,29 +383,61 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", " Video group: ${group.length} tracks, selected=${group.isSelected}")
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Auto-select first audio track if none is selected
|
||||
// This fixes the issue where some videos play without audio
|
||||
// TRACES: UR-004 | DR-146
|
||||
// Auto-select an audio track if none is selected — some videos
|
||||
// otherwise play with no sound. Pick a track the renderer says it
|
||||
// *supports*: when nothing was selected because track 0 failed to
|
||||
// initialize, forcing track 0 again just reinstates the silence.
|
||||
if (!hasSelectedAudio && audioTracks.isNotEmpty() && currentMediaType == MediaType.VIDEO) {
|
||||
android.util.Log.w("JellyTauPlayer", "⚠️ NO AUDIO TRACK SELECTED! Auto-selecting first audio track...")
|
||||
android.util.Log.w("JellyTauPlayer", "⚠️ NO AUDIO TRACK SELECTED! Looking for a supported audio track...")
|
||||
|
||||
val trackSelector = exoPlayer.trackSelector
|
||||
if (trackSelector != null) {
|
||||
try {
|
||||
// Select the first audio track group
|
||||
val firstAudioGroup = audioTracks[0]
|
||||
val override = androidx.media3.common.TrackSelectionOverride(
|
||||
firstAudioGroup.mediaTrackGroup,
|
||||
0 // Select the first track in this group
|
||||
)
|
||||
var chosenGroup: androidx.media3.common.Tracks.Group? = null
|
||||
var chosenIndex = -1
|
||||
outer@ for (group in audioTracks) {
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSupported(i)) {
|
||||
chosenGroup = group
|
||||
chosenIndex = i
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val parameters = trackSelector.parameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
.addOverride(override)
|
||||
.build()
|
||||
if (chosenGroup == null) {
|
||||
// Every track is undecodable on this device. The
|
||||
// server should have transcoded; say so loudly
|
||||
// rather than leaving a silent video unexplained.
|
||||
android.util.Log.e(
|
||||
"JellyTauPlayer",
|
||||
"✗ No supported audio track in ${audioTracks.size} group(s) - " +
|
||||
"device cannot decode any of them, expected a transcode"
|
||||
)
|
||||
} else {
|
||||
val format = chosenGroup.getTrackFormat(chosenIndex)
|
||||
val override = androidx.media3.common.TrackSelectionOverride(
|
||||
chosenGroup.mediaTrackGroup,
|
||||
chosenIndex
|
||||
)
|
||||
|
||||
trackSelector.setParameters(parameters)
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Auto-selected first audio track")
|
||||
val parameters = trackSelector.parameters
|
||||
.buildUpon()
|
||||
// Audio may also be off because the track type
|
||||
// was disabled; an override alone would not
|
||||
// bring it back.
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
.addOverride(override)
|
||||
.build()
|
||||
|
||||
trackSelector.setParameters(parameters)
|
||||
android.util.Log.d(
|
||||
"JellyTauPlayer",
|
||||
"✓ Auto-selected supported audio track $chosenIndex (${format.sampleMimeType}, ${format.channelCount}ch)"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Failed to auto-select audio track", e)
|
||||
}
|
||||
@@ -423,6 +477,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
*/
|
||||
fun play() {
|
||||
mainHandler.post {
|
||||
// Video manages focus by hand, so an explicit play after a refusal (or
|
||||
// after a LOSS paused us) has to ask again — otherwise it resumes into
|
||||
// a stream the system is still muting.
|
||||
if (currentMediaType == MediaType.VIDEO && !hasAudioFocus && !requestAudioFocus()) {
|
||||
pendingPlayOnFocusGain = true
|
||||
android.util.Log.d("JellyTauPlayer", "play() without audio focus - holding until GAIN")
|
||||
return@post
|
||||
}
|
||||
pendingPlayOnFocusGain = false
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
@@ -811,14 +874,17 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
|
||||
|
||||
// Setup video surface if needed
|
||||
var focusGranted = true
|
||||
if (currentMediaType == MediaType.VIDEO) {
|
||||
getOrCreateSurfaceView()
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
|
||||
// Automatically attach the surface to the Activity
|
||||
autoAttachSurface()
|
||||
|
||||
// CRITICAL: Request audio focus for video playback
|
||||
requestAudioFocus()
|
||||
// CRITICAL: Request audio focus for video playback. Video manages
|
||||
// focus by hand (handleAudioFocus=false above), so nothing else
|
||||
// will hold playback back if the request is delayed or refused.
|
||||
focusGranted = requestAudioFocus()
|
||||
} else {
|
||||
clearVideoSurface()
|
||||
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
|
||||
@@ -888,8 +954,13 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Current volume: ${exoPlayer.volume}, deviceVolume: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}/${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
||||
|
||||
exoPlayer.playWhenReady = true
|
||||
android.util.Log.d("JellyTauPlayer", "playWhenReady set to TRUE. Current state: ${exoPlayer.playbackState}")
|
||||
// Only roll if we hold audio focus. A DELAYED grant means the system
|
||||
// is withholding our audio until it calls back with AUDIOFOCUS_GAIN;
|
||||
// playing through it produces picture with no sound. Playback resumes
|
||||
// from the focus listener instead.
|
||||
pendingPlayOnFocusGain = !focusGranted
|
||||
exoPlayer.playWhenReady = focusGranted
|
||||
android.util.Log.d("JellyTauPlayer", "playWhenReady set to $focusGranted (pendingPlayOnFocusGain=$pendingPlayOnFocusGain). Current state: ${exoPlayer.playbackState}")
|
||||
|
||||
// Start the foreground service for lockscreen controls
|
||||
startPlaybackService()
|
||||
@@ -959,16 +1030,41 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||
while (isActive) {
|
||||
if (exoPlayer.isPlaying) {
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
||||
// THE boundary between the two timelines, and the only place
|
||||
// the conversion happens.
|
||||
//
|
||||
// During a background-audio handoff the stream is requested
|
||||
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
|
||||
// is that point and everything it reports is relative to it.
|
||||
// The base used to be added only where a position was *shown*
|
||||
// (the lockscreen scrubber), leaving progress reports to
|
||||
// Jellyfin, the frontend and the truncation maths all working
|
||||
// in the relative timeline while treating it as absolute —
|
||||
// each crossing losing exactly `base` seconds, which is why the
|
||||
// jump-back distance varied with where the screen was locked.
|
||||
// Shifting once, here, means every consumer downstream speaks
|
||||
// the episode's timeline and none of them needs to know a
|
||||
// handoff happened.
|
||||
//
|
||||
// The duration is shifted with it, so position and duration
|
||||
// stay on the same timeline — the stream's own length is only
|
||||
// what remains after the handoff point.
|
||||
//
|
||||
// TRACES: UR-040 | DR-159
|
||||
val service = JellyTauPlaybackService.getInstance()
|
||||
val baseMs = service?.handoffBaseMs ?: 0L
|
||||
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
|
||||
val position = positionMs / 1000.0
|
||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||
val duration =
|
||||
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
|
||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||
nativeOnPositionUpdate(position, duration)
|
||||
|
||||
// Keep the lockscreen scrubber live. Without this the
|
||||
// MediaSession position only refreshes on play/pause, so the
|
||||
// scrubber freezes mid-track and drifts out of sync.
|
||||
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
||||
service?.updatePlaybackPosition(positionMs, true)
|
||||
}
|
||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
@@ -1146,8 +1242,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/**
|
||||
* Request audio focus for video playback.
|
||||
* This is critical for video to have audio on Android.
|
||||
*
|
||||
* TRACES: UR-004 | DR-145
|
||||
*
|
||||
* @return true if focus was granted outright and playback may start now.
|
||||
* false for a DELAYED or refused request — the caller must hold playback
|
||||
* and let the AUDIOFOCUS_GAIN callback start it, or the video plays mute.
|
||||
*/
|
||||
private fun requestAudioFocus() {
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
android.util.Log.d("JellyTauPlayer", "Requesting audio focus for video playback")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -1164,17 +1266,29 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
|
||||
hasAudioFocus = true
|
||||
if (exoPlayer.volume < 1.0f) {
|
||||
exoPlayer.volume = 1.0f
|
||||
android.util.Log.d("JellyTauPlayer", " Volume restored to 1.0 from ${exoPlayer.volume}")
|
||||
}
|
||||
// A delayed grant arriving: this is the point at which
|
||||
// the video may actually be heard, so start it now.
|
||||
if (pendingPlayOnFocusGain) {
|
||||
pendingPlayOnFocusGain = false
|
||||
android.util.Log.d("JellyTauPlayer", " Delayed focus granted - starting held playback")
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
|
||||
hasAudioFocus = false
|
||||
pendingPlayOnFocusGain = false
|
||||
pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
|
||||
hasAudioFocus = false
|
||||
pendingPlayOnFocusGain = false
|
||||
pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
@@ -1185,16 +1299,25 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
.build()
|
||||
|
||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
when (result) {
|
||||
return when (val result = audioManager.requestAudioFocus(audioFocusRequest!!)) {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
|
||||
hasAudioFocus = true
|
||||
true
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
|
||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED!")
|
||||
// Something holds exclusive focus (a call, say). Playing now
|
||||
// would be a silent video, so hold and wait for the grant.
|
||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED - holding playback")
|
||||
false
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
|
||||
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED")
|
||||
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED - holding playback until GAIN")
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
android.util.Log.w("JellyTauPlayer", "Unknown audio focus result: $result - holding playback")
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1206,10 +1329,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED (legacy)")
|
||||
hasAudioFocus = true
|
||||
true
|
||||
} else {
|
||||
// Pre-O has no delayed grant and no listener to resume from, so a
|
||||
// refusal is terminal for this attempt; the user can hit play again.
|
||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED (legacy)!")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,6 +1348,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
private fun abandonAudioFocus() {
|
||||
android.util.Log.d("JellyTauPlayer", "Abandoning audio focus")
|
||||
|
||||
// No focus, nothing to resume: a stale flag would start playback the next
|
||||
// time some unrelated GAIN arrives.
|
||||
pendingPlayOnFocusGain = false
|
||||
hasAudioFocus = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let {
|
||||
val result = audioManager.abandonAudioFocusRequest(it)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Cleartext HTTP stays blocked everywhere except loopback.
|
||||
|
||||
Downloaded media is served to the webview by a local HTTP server on
|
||||
127.0.0.1 (see media_server.rs / DR-137). Release builds set
|
||||
usesCleartextTraffic="false", so Android's network security policy rejected
|
||||
those requests before any I/O happened and offline video failed instantly with
|
||||
NETWORK_NO_SOURCE.
|
||||
|
||||
Only 127.0.0.1 is exempted. The base config keeps the release default, so a
|
||||
remote server still has to be HTTPS — this must not become a blanket
|
||||
cleartext opt-in.
|
||||
|
||||
TRACES: UR-071 | DR-138
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -106,6 +106,14 @@ const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||
"Playlist",
|
||||
];
|
||||
|
||||
/// Jellyfin item types whose download is a *video* stream rather than an audio
|
||||
/// one. The download queue stores an opaque `media_type` ('audio'/'video'); this
|
||||
/// is where the taxonomy that produces it lives, so the frontend never has to
|
||||
/// know which item types are video.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-135
|
||||
const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
|
||||
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogSyncResult {
|
||||
@@ -463,6 +471,51 @@ pub struct ResumeQueuedResult {
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
/// Requeue video downloads that were fetched as audio.
|
||||
///
|
||||
/// Before [`resolve_pending_download_urls`] consulted the item's type, a row
|
||||
/// with no `media_type` — which is every row queued from a media card, since
|
||||
/// `download_item` does not record one — resolved against
|
||||
/// `get_audio_stream_url`. A movie queued that way completed with an audio-only
|
||||
/// transcode on disk, so playing it offline could only ever fail. Those rows are
|
||||
/// identifiable after the fact (no `media_type`, but a video item), so reset them
|
||||
/// to pending with no URL and let the resolver fetch the real video.
|
||||
///
|
||||
/// Rows carrying an explicit `media_type` were resolved correctly and are left
|
||||
/// alone, as are genuine audio downloads.
|
||||
///
|
||||
/// Returns the number of rows requeued.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-136 | UT-126
|
||||
pub(crate) async fn requeue_mistyped_video_downloads(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
) -> Result<usize, String> {
|
||||
let video_types = VIDEO_ITEM_TYPES
|
||||
.iter()
|
||||
.map(|t| format!("'{t}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let query = Query::new(&format!(
|
||||
"UPDATE downloads
|
||||
SET status = 'pending', stream_url = NULL, progress = 0,
|
||||
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
|
||||
WHERE media_type IS NULL
|
||||
AND status = 'completed'
|
||||
AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
|
||||
));
|
||||
|
||||
let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
|
||||
|
||||
if n > 0 {
|
||||
info!(
|
||||
"[Catalog] Requeued {} video download(s) that were fetched as audio",
|
||||
n
|
||||
);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||
@@ -476,11 +529,31 @@ where
|
||||
F: Fn(String, String, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Option<String>>,
|
||||
{
|
||||
let rows_query = Query::new(
|
||||
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
||||
FROM downloads
|
||||
WHERE status = 'pending' AND stream_url IS NULL",
|
||||
);
|
||||
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
||||
// queued from a media card never carry one (`download_item` does not record
|
||||
// it), and defaulting that NULL to 'audio' resolved movies against
|
||||
// `get_audio_stream_url` — the file on disk was an audio-only transcode, so
|
||||
// offline video could never play. Falling back to 'audio' only when the item
|
||||
// is unknown keeps the historical behaviour for uncached items.
|
||||
// TRACES: UR-071, UR-052 | DR-135
|
||||
let video_types = VIDEO_ITEM_TYPES
|
||||
.iter()
|
||||
.map(|t| format!("'{t}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let rows_query = Query::new(&format!(
|
||||
"SELECT d.id, d.item_id,
|
||||
COALESCE(
|
||||
d.media_type,
|
||||
CASE WHEN i.item_type IN ({video_types}) THEN 'video'
|
||||
WHEN i.item_type IS NOT NULL THEN 'audio'
|
||||
END,
|
||||
'audio'),
|
||||
COALESCE(d.quality_preset, 'original')
|
||||
FROM downloads d
|
||||
LEFT JOIN items i ON i.id = d.item_id
|
||||
WHERE d.status = 'pending' AND d.stream_url IS NULL"
|
||||
));
|
||||
let rows: Vec<(i64, String, String, String)> = db_service
|
||||
.query_many(rows_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
@@ -558,8 +631,6 @@ pub async fn resume_queued_downloads(
|
||||
) -> Result<ResumeQueuedResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
use crate::repository::HybridRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
// The pump needs a target_dir; use the same storage root the other download
|
||||
@@ -590,6 +661,16 @@ pub async fn resume_queued_downloads(
|
||||
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
||||
}
|
||||
|
||||
// Repair rows that completed as audio because their media_type was missing;
|
||||
// they hold an audio-only transcode where a video should be, so requeue them
|
||||
// for the resolver below. TRACES: UR-071 | DR-136
|
||||
if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
|
||||
warn!(
|
||||
"[Catalog] Failed to requeue mis-typed video downloads: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve each row's URL against the (now reachable) repository.
|
||||
let repo_for_resolve = Arc::clone(&repo);
|
||||
let outcome = resolve_pending_download_urls(
|
||||
@@ -600,12 +681,13 @@ pub async fn resume_queued_downloads(
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
@@ -699,7 +781,15 @@ mod tests {
|
||||
stream_url TEXT,
|
||||
target_dir TEXT,
|
||||
media_type TEXT,
|
||||
quality_preset TEXT
|
||||
quality_preset TEXT,
|
||||
progress REAL DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_type TEXT
|
||||
);
|
||||
"#,
|
||||
)
|
||||
@@ -707,6 +797,18 @@ mod tests {
|
||||
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||
}
|
||||
|
||||
async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO items (id, item_type) VALUES (?, ?)",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(item_type.to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn insert_download(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
@@ -799,6 +901,137 @@ mod tests {
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
/// A movie queued from a media card has no `media_type` — `download_item`
|
||||
/// never records one. Defaulting that NULL to 'audio' resolved the row
|
||||
/// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
|
||||
/// audio-only transcode and offline video playback could never work. The
|
||||
/// item's own type is the authority.
|
||||
///
|
||||
/// TRACES: UR-071, UR-052 | DR-135 | UT-125
|
||||
#[tokio::test]
|
||||
async fn null_media_type_resolves_from_the_item_type_not_audio() {
|
||||
let db = test_db();
|
||||
insert_item(&db, "movie-1", "Movie").await;
|
||||
insert_item(&db, "ep-1", "Episode").await;
|
||||
insert_item(&db, "track-1", "Audio").await;
|
||||
for id in ["movie-1", "ep-1", "track-1"] {
|
||||
insert_download(&db, id, "pending", None, None).await;
|
||||
}
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen_c = Arc::clone(&seen);
|
||||
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
seen.lock().unwrap().push((item_id.clone(), media_type));
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let of = |id: &str| {
|
||||
seen.iter()
|
||||
.find(|(i, _)| i == id)
|
||||
.map(|(_, m)| m.clone())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(of("movie-1"), "video", "a Movie must download as video");
|
||||
assert_eq!(of("ep-1"), "video", "an Episode must download as video");
|
||||
assert_eq!(of("track-1"), "audio", "a track is still audio");
|
||||
}
|
||||
|
||||
/// An unknown item (never cached locally) has no type to derive from, so it
|
||||
/// keeps the historical audio default rather than failing the row.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-135 | UT-125
|
||||
#[tokio::test]
|
||||
async fn unknown_item_falls_back_to_audio() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "ghost", "pending", None, None).await;
|
||||
|
||||
let seen = Arc::new(Mutex::new(String::new()));
|
||||
let seen_c = Arc::clone(&seen);
|
||||
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
*seen.lock().unwrap() = media_type;
|
||||
Some("http://x".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock().unwrap(), "audio");
|
||||
}
|
||||
|
||||
/// An explicit `media_type` on the row always wins over the item's type.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-135 | UT-125
|
||||
#[tokio::test]
|
||||
async fn explicit_media_type_beats_the_item_type() {
|
||||
let db = test_db();
|
||||
insert_item(&db, "odd", "Audio").await;
|
||||
insert_download(&db, "odd", "pending", None, Some("video")).await;
|
||||
|
||||
let seen = Arc::new(Mutex::new(String::new()));
|
||||
let seen_c = Arc::clone(&seen);
|
||||
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
*seen.lock().unwrap() = media_type;
|
||||
Some("http://x".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock().unwrap(), "video");
|
||||
}
|
||||
|
||||
/// Rows already downloaded under the audio default hold an audio-only
|
||||
/// transcode on disk, so they play as a broken video forever. They are
|
||||
/// identifiable — no `media_type` but a video item — and are requeued so the
|
||||
/// resolver fetches the real video. Correctly-typed rows and genuine audio
|
||||
/// downloads must be left alone.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-136 | UT-126
|
||||
#[tokio::test]
|
||||
async fn requeues_video_downloaded_under_the_audio_default() {
|
||||
let db = test_db();
|
||||
insert_item(&db, "movie-1", "Movie").await;
|
||||
insert_item(&db, "track-1", "Audio").await;
|
||||
insert_item(&db, "movie-ok", "Movie").await;
|
||||
// Mis-downloaded: completed, no media_type, video item.
|
||||
insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
|
||||
// A real audio download: untouched.
|
||||
insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
|
||||
// A correctly-typed video download: untouched.
|
||||
insert_download(
|
||||
&db,
|
||||
"movie-ok",
|
||||
"completed",
|
||||
Some("http://video/ok"),
|
||||
Some("video"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
|
||||
assert_eq!(requeued, 1);
|
||||
|
||||
let (status, url, _t) = get_row(&db, "movie-1").await;
|
||||
assert_eq!(status, "pending", "the mis-typed row must download again");
|
||||
assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
|
||||
|
||||
let (status, url, _t) = get_row(&db, "track-1").await;
|
||||
assert_eq!(status, "completed", "a real audio download is untouched");
|
||||
assert_eq!(url.as_deref(), Some("http://audio/ok"));
|
||||
|
||||
let (status, _u, _t) = get_row(&db, "movie-ok").await;
|
||||
assert_eq!(status, "completed", "a correct video download is untouched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn video_rows_use_media_type_in_resolver() {
|
||||
let db = test_db();
|
||||
|
||||
@@ -845,7 +845,19 @@ pub async fn get_downloads(
|
||||
Ok(DownloadsResponse { downloads, stats })
|
||||
}
|
||||
|
||||
/// Pause a download
|
||||
/// Pause a download.
|
||||
///
|
||||
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||
/// streaming task knew nothing about the row and kept running, then overwrote it
|
||||
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||
/// undid itself — the reported "pause does not work". Signalling the worker is
|
||||
/// what actually stops the bytes; it leaves the `.part` file in place so
|
||||
/// [`resume_download`] can continue from it.
|
||||
///
|
||||
/// A queued (not yet started) download has no worker to signal, and the status
|
||||
/// write alone is enough — the pump skips anything that is not `pending`.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn pause_download(
|
||||
@@ -858,19 +870,34 @@ pub async fn pause_download(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'",
|
||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let was_running = crate::download::stop::signal(download_id);
|
||||
info!(
|
||||
"[pause] Download {} paused (in flight: {})",
|
||||
download_id, was_running
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a paused download
|
||||
/// Resume a paused download.
|
||||
///
|
||||
/// Flipping the row back to `pending` is likewise not enough on its own: the
|
||||
/// pump is not a poller, it runs when something calls it, so a resumed download
|
||||
/// sat untouched until some unrelated event happened to pump the queue. That is
|
||||
/// the other half of "resume does not work".
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resume_download(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
@@ -879,11 +906,22 @@ pub async fn resume_download(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'",
|
||||
"UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Drop any stale stop flag before the pump can start this id again, or the
|
||||
// resumed run would read the pause that stopped it and halt immediately.
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -923,6 +961,13 @@ pub async fn cancel_download(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Stop the worker if this download is actually running. Without this the
|
||||
// task keeps streaming into a `.part` file whose `downloads` row has just
|
||||
// been deleted — bytes with nothing pointing at them, and the file below is
|
||||
// removed while still being written to. (DR-168)
|
||||
crate::download::stop::signal(download_id);
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
// Unregister from download manager (in case it was active)
|
||||
{
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -934,10 +979,12 @@ pub async fn cancel_download(
|
||||
);
|
||||
}
|
||||
|
||||
// Delete partial file if exists
|
||||
// Delete the partial file, and any completed file, if present. Both go
|
||||
// through `partial_path` so this cannot drift from what the worker writes —
|
||||
// it did, and every cancelled download leaked its partial. (DR-169)
|
||||
if let Some(path) = file_path {
|
||||
let partial_path = format!("{}.part", path);
|
||||
let _ = std::fs::remove_file(&partial_path); // Ignore errors
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1244,8 +1291,6 @@ pub async fn enqueue_video_downloads(
|
||||
download_ids: Vec<i64>,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
@@ -1270,10 +1315,12 @@ pub async fn enqueue_video_downloads(
|
||||
}
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, None);
|
||||
// Build the download URL, resolving the source's audio codec first so a
|
||||
// track this device cannot decode is re-encoded on the way down rather
|
||||
// than saved as a silent file (DR-167).
|
||||
let stream_url =
|
||||
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
||||
.await;
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
@@ -1530,7 +1577,11 @@ fn spawn_download_worker(
|
||||
let _ = progress_app.emit("download-event", event);
|
||||
};
|
||||
|
||||
let result = worker.download(&task, on_progress).await;
|
||||
// Registering returns a fresh flag, so a download resumed after a pause
|
||||
// does not inherit the stop that ended its previous run. (DR-168)
|
||||
let stop_flag = crate::download::stop::register(download_id);
|
||||
let result = worker.download(&task, &stop_flag, on_progress).await;
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
@@ -1601,6 +1652,17 @@ fn spawn_download_worker(
|
||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
// A pause or cancel is not a failure. The row already says `paused`
|
||||
// (or the row is gone, for a cancel), and overwriting that with
|
||||
// `failed` is what made a pause look like an error and stranded the
|
||||
// download outside the resumable set. The `.part` file is deliberately
|
||||
// left alone — it is what the resume continues from. (DR-168)
|
||||
Err(e) if e.is_stopped() => {
|
||||
info!(
|
||||
"[pump] Download {} stopped by request; partial file kept for resume",
|
||||
download_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Download failed: {:?}", e);
|
||||
|
||||
@@ -1848,17 +1910,26 @@ pub async fn clear_stale_downloads(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// Get file paths for stale downloads (pending/paused/failed)
|
||||
// Ids as well as paths: a stale row may still have a worker attached (a
|
||||
// 'downloading' row that was paused mid-flight is 'paused' here), and
|
||||
// deleting the row without stopping the task leaves it writing to a file we
|
||||
// are about to remove. (DR-168)
|
||||
let file_query = Query::with_params(
|
||||
"SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
"SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
vec![QueryParam::String(user_id.clone())],
|
||||
);
|
||||
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
let stale: Vec<(i64, String)> = db_service
|
||||
.query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for (id, _) in &stale {
|
||||
crate::download::stop::signal(*id);
|
||||
crate::download::stop::clear(*id);
|
||||
}
|
||||
let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
|
||||
|
||||
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
@@ -1870,10 +1941,12 @@ pub async fn clear_stale_downloads(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete any partial files
|
||||
// Delete any partial files, via the shared helper so this cannot drift from
|
||||
// what the worker actually writes. (DR-169)
|
||||
for path in file_paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod sync_drain;
|
||||
|
||||
pub use auth::*;
|
||||
pub use catalog::*;
|
||||
@@ -34,3 +35,4 @@ pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
pub use sync_drain::*;
|
||||
|
||||
@@ -202,6 +202,27 @@ pub struct PlayItemRequest {
|
||||
/// look up the next episode when a background-audio track ends.
|
||||
#[serde(default)]
|
||||
pub series_id: Option<String>,
|
||||
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||
///
|
||||
/// Only the native backends use these: on Android they become the
|
||||
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||
/// builds its own `<track>` children instead and ignores this list.
|
||||
///
|
||||
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||
/// *text track groups* — i.e. the position of the sideloaded configuration,
|
||||
/// not the Jellyfin stream index (which is kept on each entry for the UI's
|
||||
/// benefit). So `n` must be a position in this very array, and the array
|
||||
/// must not be reordered or filtered between building it and sending it.
|
||||
/// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
|
||||
/// list that is sent here, for exactly this reason.
|
||||
///
|
||||
/// Defaulted so the background-audio handoff and the autoplay/next-episode
|
||||
/// callers, which have no subtitles to offer, need not send the field.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-016, JA-008 | UT-145
|
||||
#[serde(default)]
|
||||
pub subtitles: Vec<crate::player::SubtitleTrack>,
|
||||
}
|
||||
|
||||
/// Queue context for remote transfer - what type of queue is this?
|
||||
@@ -321,6 +342,29 @@ pub enum AudioTrackSwitchResponse {
|
||||
},
|
||||
}
|
||||
|
||||
/// Response for a mid-playback streaming-quality change.
|
||||
///
|
||||
/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
|
||||
/// has to reload anything, so no strategy branch lives in the UI.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[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.
|
||||
Native {
|
||||
/// Position playback resumed at.
|
||||
position: f64,
|
||||
},
|
||||
/// HTML5 must reload its element with this URL.
|
||||
ReloadStream {
|
||||
/// New stream URL, already transcoded to the requested ceiling.
|
||||
new_url: String,
|
||||
/// Position to resume from.
|
||||
position: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Helper function to create MediaItem from video request
|
||||
///
|
||||
/// PlayItemRequest is now video-only, so we create a video MediaItem.
|
||||
@@ -373,7 +417,10 @@ pub(super) async fn create_media_item(
|
||||
needs_transcoding: req.needs_transcoding,
|
||||
video_width: None, // Not available from video-only request
|
||||
video_height: None, // Not available from video-only request
|
||||
subtitles: vec![],
|
||||
// Sideloaded subtitles, in the order the frontend sent them — that order
|
||||
// is what `player_set_subtitle_track(n)` indexes into on Android.
|
||||
// TRACES: UR-020 | IR-016 | UT-145
|
||||
subtitles: req.subtitles,
|
||||
series_id: None, // Not available from video-only request
|
||||
server_id: None, // Not available from video-only request
|
||||
})
|
||||
@@ -746,22 +793,23 @@ pub async fn player_enter_background_audio(
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
) -> Result<f64, String> {
|
||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Zero after a backend-driven
|
||||
// episode advance, whose stream already starts at its own zero.
|
||||
let base = controller.exit_background_audio();
|
||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||
let relative = controller.position();
|
||||
|
||||
// Read the position BEFORE clearing either base. The position tick applies the
|
||||
// base natively, so a tick landing between "base cleared" and "position read"
|
||||
// would hand back a relative position — the whole bug, reintroduced at the one
|
||||
// moment it matters most. Capturing into a `let` before stop() is also the
|
||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||
// (DR-159)
|
||||
let absolute = controller.position();
|
||||
|
||||
// Now safe to tear the handoff down, native side first.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
controller.exit_background_audio();
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
let absolute = base + relative;
|
||||
info!(
|
||||
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
|
||||
base, relative, absolute
|
||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
||||
absolute
|
||||
);
|
||||
Ok(absolute)
|
||||
}
|
||||
@@ -979,6 +1027,16 @@ pub async fn player_stop(
|
||||
.clone()
|
||||
};
|
||||
client.send_session_command(session_id, "Stop").await?;
|
||||
|
||||
// Stopping the remote session ends the cast, so the manager returns to
|
||||
// Idle — same as a local stop. This is also what hands OS volume control
|
||||
// back to this device: set_mode releases the Android remote volume
|
||||
// provider on any exit from remote mode. Without it the mode stayed
|
||||
// Remote and the system volume slider remained stuck on the remote
|
||||
// session with no way back to the local speaker.
|
||||
playback_mode
|
||||
.0
|
||||
.set_mode(crate::playback_mode::PlaybackMode::Idle);
|
||||
} else {
|
||||
// Local playback
|
||||
let controller = player.0.lock().await;
|
||||
@@ -1173,9 +1231,12 @@ pub async fn player_seek(
|
||||
let position_ticks = (position * 10_000_000.0) as i64;
|
||||
client.session_seek(session_id, position_ticks).await?;
|
||||
} else {
|
||||
// Local playback
|
||||
// Local playback. seek_absolute, not seek: the position came from the UI,
|
||||
// which shows the whole item, so during a background-audio handoff it has
|
||||
// to be resolved against the episode's timeline rather than the handoff
|
||||
// stream's. (DR-159)
|
||||
let controller = player.0.lock().await;
|
||||
controller.seek(position).map_err(|e| e.to_string())?;
|
||||
controller.seek_absolute(position).await?;
|
||||
}
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
@@ -1409,6 +1470,112 @@ pub async fn player_switch_audio_track(
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the bandwidth ceiling of the video that is playing *right now*.
|
||||
///
|
||||
/// A cap is a property of the stream the server is producing, so unlike a volume
|
||||
/// change it cannot be applied to a stream already in flight — the stream has to
|
||||
/// be re-opened at the new quality and resumed at the current position. That is
|
||||
/// the same reload the transcoded-seek and audio-track paths use, and the same
|
||||
/// 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.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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,
|
||||
current_position: Option<f64>,
|
||||
media_source_id: Option<String>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<StreamQualityResponse, String> {
|
||||
info!(
|
||||
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
|
||||
quality.label(),
|
||||
use_html5,
|
||||
current_position
|
||||
);
|
||||
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
let jellyfin_item_id = {
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let current_item = queue.current().ok_or("No item currently playing")?;
|
||||
|
||||
if current_item.media_type != MediaType::Video {
|
||||
return Err("Current item is not a video".to_string());
|
||||
}
|
||||
|
||||
current_item
|
||||
.jellyfin_id()
|
||||
.ok_or("Current item has no Jellyfin ID")?
|
||||
.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;
|
||||
}
|
||||
|
||||
let position = current_position.unwrap_or(0.0);
|
||||
let new_url = repository
|
||||
.get_video_stream_url(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
current_position,
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||
|
||||
if use_html5 {
|
||||
return Ok(StreamQualityResponse::ReloadStream { new_url, position });
|
||||
}
|
||||
|
||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||
// The URL already carries `StartTimeTicks`, so the reloaded stream begins at
|
||||
// the current position rather than at zero.
|
||||
{
|
||||
let controller = player.0.lock().await;
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
|
||||
let queue_arc = controller.queue();
|
||||
{
|
||||
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
if !queue.update_current_stream_url(new_url.clone()) {
|
||||
return Err("Failed to update stream URL in queue".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
let updated_item = queue.current().ok_or("No current item after URL update")?;
|
||||
controller
|
||||
.load_and_play(updated_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(StreamQualityResponse::Native { position })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_audio_track(
|
||||
@@ -1630,6 +1797,43 @@ pub async fn player_get_queue(
|
||||
Ok(get_queue_status(&controller))
|
||||
}
|
||||
|
||||
/// What playback facilities this platform's backend actually provides.
|
||||
///
|
||||
/// The frontend is presentation-only and must not re-derive backend facts from
|
||||
/// `navigator.userAgent` — that sniffing was a second copy of the same platform
|
||||
/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
|
||||
/// single source of truth; the frontend consumes them.
|
||||
///
|
||||
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackCapabilities {
|
||||
/// True when audio is rendered by a webview `<audio>` element rather than a
|
||||
/// native backend. Native audio exists on Linux (mpv) and Android
|
||||
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
||||
pub uses_webview_audio: bool,
|
||||
/// True when video can be rendered by a native surface composited *behind*
|
||||
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
|
||||
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||
/// compositing), so it stays on the HTML5 element.
|
||||
pub supports_native_video: bool,
|
||||
}
|
||||
|
||||
/// Report this platform's playback capabilities to the frontend.
|
||||
///
|
||||
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
||||
// Mirrors the cfg gates the backends themselves are built under.
|
||||
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
|
||||
|
||||
Ok(PlaybackCapabilities {
|
||||
uses_webview_audio: !native_audio,
|
||||
supports_native_video: cfg!(target_os = "android"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
||||
// Determine backend at compile time based on platform
|
||||
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
||||
@@ -2459,6 +2663,141 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
||||
/// up on the `MediaItem` the native backend loads.
|
||||
///
|
||||
/// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
|
||||
/// then dropped it on the floor — `PlayItemRequest` had no field to put it
|
||||
/// in — so `create_media_item` always produced `subtitles: vec![]`,
|
||||
/// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
|
||||
/// `MediaItem` with zero `SubtitleConfiguration`s. Every later
|
||||
/// `setSubtitleTrack(n)` then found no text track groups and logged
|
||||
/// "Invalid subtitle track index".
|
||||
///
|
||||
/// The payload below is exactly what the frontend sends: camelCase for the
|
||||
/// top-level command params (Tauri v2 converts them), and the subtitle
|
||||
/// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-016 | UT-145
|
||||
#[tokio::test]
|
||||
async fn test_play_item_request_carries_subtitles_into_media_item() {
|
||||
use super::{create_media_item, PlayItemRequest};
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"id": "ep-1",
|
||||
"title": "Pilot",
|
||||
"streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
|
||||
"videoCodec": "h264",
|
||||
"needsTranscoding": false,
|
||||
"subtitles": [
|
||||
{
|
||||
"index": 2,
|
||||
"url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
|
||||
"language": "eng",
|
||||
"label": "English (SRT)",
|
||||
"mime_type": "text/vtt"
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
|
||||
"language": null,
|
||||
"label": null,
|
||||
"mime_type": "text/vtt"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let req: PlayItemRequest =
|
||||
serde_json::from_value(payload).expect("frontend payload must deserialize");
|
||||
assert_eq!(
|
||||
req.subtitles.len(),
|
||||
2,
|
||||
"PlayItemRequest must carry the subtitle tracks, not silently ignore them"
|
||||
);
|
||||
|
||||
let media = create_media_item(req, None).await.unwrap();
|
||||
assert_eq!(
|
||||
media.subtitles.len(),
|
||||
2,
|
||||
"create_media_item must thread the tracks onto the MediaItem the backend loads"
|
||||
);
|
||||
assert_eq!(media.subtitles[0].index, 2);
|
||||
assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
|
||||
assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
|
||||
assert_eq!(media.subtitles[0].mime_type, "text/vtt");
|
||||
// Order is the contract: `player_set_subtitle_track(n)` is a position in
|
||||
// this list (see the note on `PlayItemRequest::subtitles`).
|
||||
assert_eq!(media.subtitles[1].index, 3);
|
||||
assert!(media.subtitles[1].language.is_none());
|
||||
}
|
||||
|
||||
/// A request without subtitles must still deserialize — the field is
|
||||
/// defaulted so the background-audio handoff and the autoplay/next-episode
|
||||
/// callers keep compiling and sending what they always sent.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-016 | UT-145
|
||||
#[tokio::test]
|
||||
async fn test_play_item_request_without_subtitles_defaults_to_empty() {
|
||||
use super::{create_media_item, PlayItemRequest};
|
||||
|
||||
let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
|
||||
"id": "movie-1",
|
||||
"title": "Movie",
|
||||
"streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
|
||||
"videoCodec": "h264",
|
||||
"needsTranscoding": false
|
||||
}))
|
||||
.expect("a subtitle-less payload must still deserialize");
|
||||
|
||||
assert!(req.subtitles.is_empty());
|
||||
assert!(create_media_item(req, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.subtitles
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// The JSON handed to Kotlin over JNI must use the keys
|
||||
/// `JellyTauPlayer.load()` actually reads.
|
||||
///
|
||||
/// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
|
||||
/// house IPC rule) is to camelCase nested structs too — but
|
||||
/// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
|
||||
/// the field would not fail to compile or fail the IPC; it would silently
|
||||
/// fall back to the default MIME type for every track, so this is asserted
|
||||
/// on the exact bytes `android/mod.rs` sends.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||
#[test]
|
||||
fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
|
||||
use crate::player::media::SubtitleTrack;
|
||||
|
||||
let subtitles = vec![SubtitleTrack {
|
||||
index: 2,
|
||||
url: "https://jelly.example/subs.vtt".to_string(),
|
||||
language: Some("eng".to_string()),
|
||||
label: Some("English".to_string()),
|
||||
mime_type: "text/vtt".to_string(),
|
||||
}];
|
||||
|
||||
// Exactly what player/android/mod.rs passes to loadWithMetadata.
|
||||
let json = serde_json::to_string(&subtitles).unwrap();
|
||||
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
|
||||
let obj = parsed[0].as_object().unwrap();
|
||||
|
||||
for key in ["url", "language", "label", "mime_type"] {
|
||||
assert!(
|
||||
obj.contains_key(key),
|
||||
"JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
|
||||
obj.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!obj.contains_key("mimeType"),
|
||||
"camelCasing mime_type silently drops every track's MIME type on Android"
|
||||
);
|
||||
}
|
||||
|
||||
/// The audio-only handoff must play a downloaded file when there is one,
|
||||
/// rather than fetching an audio-only stream for media already on disk.
|
||||
///
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
|
||||
|
||||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::player::AutoplaySettings;
|
||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||
use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// `app_settings` key holding the persisted streaming bandwidth ceiling.
|
||||
///
|
||||
/// The cap is persisted (unlike the rest of `VideoSettings`, which is
|
||||
/// process-lifetime state) because forgetting it is the one failure that costs
|
||||
/// the user something real: a limit set for a metered connection that silently
|
||||
/// reverts to uncapped on the next launch spends their data allowance without
|
||||
/// ever showing them a changed setting.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
|
||||
pub async fn player_set_video_settings(
|
||||
video_settings: State<'_, VideoSettingsWrapper>,
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
settings: VideoSettings,
|
||||
) -> Result<VideoSettings, String> {
|
||||
let validated = settings.with_countdown_clamped();
|
||||
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
|
||||
*current = validated.clone();
|
||||
} // Drop MutexGuard before await
|
||||
|
||||
// The bandwidth ceiling is read by the repository's URL builders and by the
|
||||
// PlaybackInfo negotiation, neither of which can see this wrapper.
|
||||
// TRACES: UR-074 | DR-162
|
||||
crate::repository::online::set_streaming_quality(validated.streaming_quality);
|
||||
persist_streaming_quality(&db, validated.streaming_quality).await;
|
||||
|
||||
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
|
||||
let controller = player.0.lock().await;
|
||||
controller.set_autoplay_settings(AutoplaySettings {
|
||||
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
/// The bandwidth ceilings the quality picker may offer, each with the label and
|
||||
/// one-line detail to show for it, highest first.
|
||||
///
|
||||
/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||
/// frontend reads them here rather than encoding them — the same arrangement as
|
||||
/// [`player_get_eq_presets`].
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_streaming_qualities(
|
||||
) -> Result<Vec<(StreamingQuality, String, String)>, String> {
|
||||
Ok(StreamingQuality::ALL
|
||||
.iter()
|
||||
.map(|q| (*q, q.label().to_string(), q.detail().to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
|
||||
/// setting has already been applied in memory, and refusing the whole call
|
||||
/// because the write failed would leave the UI showing a cap that *is* active.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
|
||||
let db_service = {
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let encoded = match serde_json::to_string(&quality) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
|
||||
QueryParam::String(encoded),
|
||||
],
|
||||
);
|
||||
|
||||
if let Err(e) = db_service.execute(query).await {
|
||||
warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the persisted bandwidth ceiling at startup, into both the repository
|
||||
/// (which enforces it) and `VideoSettings` (which the settings UI reads).
|
||||
///
|
||||
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||
/// default — uncapped — in place, so a database problem degrades to the old
|
||||
/// behaviour rather than to an arbitrary limit.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
|
||||
let db_service = {
|
||||
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||
warn!("[VideoSettings] No database available; streaming quality stays uncapped");
|
||||
return;
|
||||
};
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
|
||||
);
|
||||
|
||||
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[VideoSettings] Failed to read streaming quality: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(stored) = stored else { return };
|
||||
let quality: StreamingQuality = match serde_json::from_str(&stored) {
|
||||
Ok(quality) => quality,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
|
||||
stored, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::repository::online::set_streaming_quality(quality);
|
||||
if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
|
||||
video_settings.0.lock_safe().streaming_quality = quality;
|
||||
}
|
||||
info!(
|
||||
"[VideoSettings] Restored streaming quality cap: {}",
|
||||
quality.label()
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_video_settings(
|
||||
|
||||
@@ -712,9 +712,19 @@ pub async fn repository_report_playback_progress(
|
||||
}
|
||||
|
||||
/// Report playback stopped
|
||||
///
|
||||
/// A stop-report that cannot reach the server is queued rather than dropped:
|
||||
/// this is the position the resume point is built from, and losing it is
|
||||
/// exactly the "it forgot where I was" the sync queue exists to prevent. The
|
||||
/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
||||
/// failing the command because the *queue* write failed would tell the caller
|
||||
/// the report was lost when the local position was already saved.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-154 | UT-151
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_report_playback_stopped(
|
||||
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
@@ -723,10 +733,39 @@ pub async fn repository_report_playback_stopped(
|
||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
|
||||
let result = repo
|
||||
.as_ref()
|
||||
.report_playback_stopped(&item_id, position_ticks)
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|err| err.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
let user_id = repo.user_id().to_string();
|
||||
if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
|
||||
&db_service,
|
||||
&user_id,
|
||||
&item_id,
|
||||
position_ticks,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
{
|
||||
warn!(
|
||||
"[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
|
||||
item_id, e, queue_err
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
|
||||
item_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get image URL for an item
|
||||
@@ -765,7 +804,7 @@ pub fn repository_get_subtitle_url(
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(dead_code)]
|
||||
pub fn repository_get_video_download_url(
|
||||
pub async fn repository_get_video_download_url(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
@@ -773,9 +812,16 @@ pub fn repository_get_video_download_url(
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
// Async because the audio-codec policy has to know what the source's audio
|
||||
// is before it can decide whether the file may be copied verbatim (DR-171).
|
||||
// The frontend calls this exactly as before — the decision stays in Rust.
|
||||
Ok(crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
media_source_id.as_deref(),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Mark an item as favorite
|
||||
|
||||
@@ -80,6 +80,30 @@ pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
|
||||
Ok(database.path().to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// A playable URL for a downloaded file on disk.
|
||||
///
|
||||
/// Local media is served over a loopback HTTP server rather than handed to the
|
||||
/// webview as a `file://`/asset URL, because the asset protocol cannot stream a
|
||||
/// large file — it answers a range-less request with the whole thing, which
|
||||
/// Chromium abandons. See `media_server` for why real HTTP is used.
|
||||
///
|
||||
/// The returned URL carries the server's per-session token, so it is only valid
|
||||
/// for this run of the app and must not be persisted.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn media_local_url(
|
||||
server: State<crate::media_server::MediaServerWrapper>,
|
||||
path: String,
|
||||
) -> Result<String, String> {
|
||||
server
|
||||
.0
|
||||
.as_ref()
|
||||
.map(|s| 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]
|
||||
@@ -843,6 +867,86 @@ pub async fn storage_mark_played(
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the watched flag locally for an item **and everything inside it**.
|
||||
///
|
||||
/// This backs the watched toggle, and is deliberately separate from
|
||||
/// [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
/// increments `play_count` — because the toggle has two directions and applies
|
||||
/// to containers.
|
||||
///
|
||||
/// The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
/// online the server fixes up the children on the next read; with no server to
|
||||
/// ask, marking a season watched would otherwise tick the season and leave every
|
||||
/// episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
/// columns the rest of the offline layer uses, so an id that is not cached
|
||||
/// selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
///
|
||||
/// Un-marking clears the resume position too, matching the server, so an item
|
||||
/// un-marked offline does not come back offering to resume from a position it is
|
||||
/// no longer meant to have.
|
||||
///
|
||||
/// `pending_sync = 1` hands the rows to the sync drain.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn storage_set_watched(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
watched: bool,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself plus its descendants: a season's episodes reach it by
|
||||
// season_id, a series' by series_id, its seasons by parent_id, an album's
|
||||
// tracks by album_id.
|
||||
let targets = "SELECT id FROM items
|
||||
WHERE id = ? OR parent_id = ? OR album_id = ?
|
||||
OR season_id = ? OR series_id = ?";
|
||||
|
||||
let sql = if watched {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
|
||||
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 1,
|
||||
play_count = MAX(user_data.play_count, 1),
|
||||
last_played_at = CURRENT_TIMESTAMP,
|
||||
pending_sync = 1"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
|
||||
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 0,
|
||||
play_count = 0,
|
||||
playback_position_ticks = 0,
|
||||
pending_sync = 1"
|
||||
)
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get playback progress for an item
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014
|
||||
//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
|
||||
//! read side the UI lists from (DR-132).
|
||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
@@ -24,6 +26,12 @@ pub struct SyncQueueItem {
|
||||
pub retry_count: i32,
|
||||
pub created_at: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
/// Cached title of the item the operation is about, when the catalog knows
|
||||
/// it. Resolved here rather than by a per-row frontend fetch — the queue
|
||||
/// list is otherwise a wall of opaque ids.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-132
|
||||
pub item_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Queue a mutation for sync to server
|
||||
@@ -74,20 +82,20 @@ pub async fn sync_get_pending(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let sql = if let Some(l) = limit {
|
||||
format!(
|
||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||
FROM sync_queue
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT {}",
|
||||
l
|
||||
)
|
||||
} else {
|
||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||
FROM sync_queue
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||
ORDER BY created_at ASC".to_string()
|
||||
// The `items` join names the queued item where the catalog has it; a row for
|
||||
// an item that was never cached still lists, with a null name.
|
||||
// `abandoned` rows (DR-131 gave up on them) are excluded here for the same
|
||||
// reason they are excluded from the count — they are no longer waiting.
|
||||
const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
|
||||
COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
|
||||
FROM sync_queue q
|
||||
LEFT JOIN items i ON i.id = q.item_id
|
||||
WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
|
||||
ORDER BY q.created_at ASC, q.id ASC";
|
||||
|
||||
let sql = match limit {
|
||||
Some(l) => format!("{} LIMIT {}", SELECT, l),
|
||||
None => SELECT.to_string(),
|
||||
};
|
||||
|
||||
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
|
||||
@@ -104,6 +112,7 @@ pub async fn sync_get_pending(
|
||||
retry_count: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
error_message: row.get(8)?,
|
||||
item_name: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -256,6 +265,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: Some("2024-02-14T08:00:00Z".to_string()),
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
// Should serialize successfully
|
||||
@@ -280,6 +290,7 @@ mod tests {
|
||||
retry_count: 3,
|
||||
created_at: Some("2024-02-14T07:00:00Z".to_string()),
|
||||
error_message: Some("Connection timeout".to_string()),
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -300,6 +311,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -323,6 +335,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -363,6 +376,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
// Simulate retries
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
pub mod stop;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Stop signalling for in-flight downloads.
|
||||
//!
|
||||
//! TRACES: UR-055 | DR-168
|
||||
//!
|
||||
//! Pausing and cancelling used to be database-only: `pause_download` wrote
|
||||
//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
|
||||
//! download stack — no token, no flag, no abort — so the streaming task kept
|
||||
//! running, kept writing bytes, and on finishing overwrote the row with
|
||||
//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
|
||||
//! which is precisely the reported "pause does not work".
|
||||
//!
|
||||
//! This is the missing half: a flag per in-flight download that the worker reads
|
||||
//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
|
||||
//! leave the `.part` file **intact**, which is what lets a resume pick up from
|
||||
//! where it stopped via the existing HTTP Range request.
|
||||
//!
|
||||
//! Kept as a module-level registry rather than on `DownloadManager` because the
|
||||
//! two sides never meet: the command handler holds the manager's lock, while the
|
||||
//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
|
||||
//! Tauri state. A registry both can reach is the smallest thing that works.
|
||||
//!
|
||||
//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// download id → its stop flag, for downloads currently in flight.
|
||||
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
|
||||
static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Register `download_id` as in-flight and hand back its stop flag.
|
||||
///
|
||||
/// Called by the worker as it starts. A previous flag for the same id is
|
||||
/// replaced, so a download that is paused and later resumed does not inherit the
|
||||
/// set flag from its last run and stop immediately.
|
||||
pub fn register(download_id: i64) -> Arc<AtomicBool> {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
registry().lock_safe().insert(download_id, flag.clone());
|
||||
flag
|
||||
}
|
||||
|
||||
/// Ask an in-flight download to stop.
|
||||
///
|
||||
/// Returns whether one was actually in flight — the caller uses this to tell a
|
||||
/// running download (which will stop shortly) from a merely queued one (which
|
||||
/// the database update alone has already handled).
|
||||
pub fn signal(download_id: i64) -> bool {
|
||||
match registry().lock_safe().get(&download_id) {
|
||||
Some(flag) => {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a download's flag. Called when its task finishes, however it ended.
|
||||
pub fn clear(download_id: i64) {
|
||||
registry().lock_safe().remove(&download_id);
|
||||
}
|
||||
|
||||
/// Whether a stop has been requested for `download_id`.
|
||||
///
|
||||
/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
|
||||
/// up, so this exists for the tests that assert the registry's behaviour.
|
||||
#[cfg(test)]
|
||||
pub fn is_stopping(download_id: i64) -> bool {
|
||||
registry()
|
||||
.lock_safe()
|
||||
.get(&download_id)
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ids are per-test so the shared registry cannot leak between them.
|
||||
fn unique_id(seed: i64) -> i64 {
|
||||
900_000 + seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_registered_download_starts_unflagged() {
|
||||
let id = unique_id(1);
|
||||
let flag = register(id);
|
||||
assert!(!flag.load(Ordering::SeqCst));
|
||||
assert!(!is_stopping(id));
|
||||
clear(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_sets_the_flag_the_worker_reads() {
|
||||
let id = unique_id(2);
|
||||
let flag = register(id);
|
||||
|
||||
assert!(signal(id), "a registered download reports as in flight");
|
||||
assert!(
|
||||
flag.load(Ordering::SeqCst),
|
||||
"the worker's own handle sees it"
|
||||
);
|
||||
assert!(is_stopping(id));
|
||||
|
||||
clear(id);
|
||||
}
|
||||
|
||||
/// The pump only needs to abort a task that exists; a queued row is handled
|
||||
/// by its database status alone.
|
||||
#[test]
|
||||
fn test_signalling_an_unregistered_download_reports_not_in_flight() {
|
||||
assert!(!signal(unique_id(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_forgets_the_download() {
|
||||
let id = unique_id(4);
|
||||
register(id);
|
||||
signal(id);
|
||||
clear(id);
|
||||
|
||||
assert!(!is_stopping(id));
|
||||
assert!(!signal(id), "a cleared download is no longer in flight");
|
||||
}
|
||||
|
||||
/// The bug this guards: pause sets the flag, and resume re-runs the same
|
||||
/// download id. If registering reused the old flag, the resumed run would see
|
||||
/// a set flag and stop instantly — a download that could never be resumed.
|
||||
#[test]
|
||||
fn test_reregistering_clears_a_previous_stop() {
|
||||
let id = unique_id(5);
|
||||
register(id);
|
||||
signal(id);
|
||||
assert!(is_stopping(id));
|
||||
|
||||
let fresh = register(id);
|
||||
assert!(!fresh.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
!is_stopping(id),
|
||||
"a resumed download must not inherit the pause"
|
||||
);
|
||||
|
||||
clear(id);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||
|
||||
use log::warn;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -31,10 +32,18 @@ impl DownloadWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file with retry logic and progress tracking
|
||||
/// Download a file with retry logic and progress tracking.
|
||||
///
|
||||
/// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
|
||||
/// checked between chunks and again between retries, so a paused download
|
||||
/// stops promptly rather than after its next backoff — up to 45 seconds
|
||||
/// away, which reads as the pause having done nothing.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
pub async fn download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -43,7 +52,10 @@ impl DownloadWorker {
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
match self.try_download(task, &on_progress).await {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Err(DownloadError::Stopped);
|
||||
}
|
||||
match self.try_download(task, stop, &on_progress).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||
retries += 1;
|
||||
@@ -63,6 +75,7 @@ impl DownloadWorker {
|
||||
async fn try_download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: &F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -76,7 +89,7 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
// Check for partial download
|
||||
let temp_path = task.target_path.with_extension("part");
|
||||
let temp_path = partial_path(&task.target_path);
|
||||
let existing_bytes = if temp_path.exists() {
|
||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
@@ -100,22 +113,32 @@ impl DownloadWorker {
|
||||
return Err(DownloadError::Http(response.status().as_u16()));
|
||||
}
|
||||
|
||||
// Get content length
|
||||
// Did the server actually honour the Range? A transcode does not, and
|
||||
// answers 200 with the whole stream — appending that would duplicate what
|
||||
// we already hold. (DR-170)
|
||||
let resume_from = resume_offset(existing_bytes, response.status().as_u16());
|
||||
if existing_bytes > 0 && resume_from == 0 {
|
||||
warn!(
|
||||
"Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
|
||||
instead of appending to {} existing bytes",
|
||||
response.status().as_u16(),
|
||||
task.target_path.display(),
|
||||
existing_bytes
|
||||
);
|
||||
}
|
||||
|
||||
// Get content length. Absent on a chunked transcode, which is why progress
|
||||
// for a non-`original` preset has no percentage to show.
|
||||
let _total_bytes = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| {
|
||||
if existing_bytes > 0 {
|
||||
len + existing_bytes
|
||||
} else {
|
||||
len
|
||||
}
|
||||
});
|
||||
.map(|len| len + resume_from);
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
// Append only when resuming a range the server agreed to; otherwise
|
||||
// create/truncate so the restarted stream replaces the stale bytes.
|
||||
let mut file = if resume_from > 0 {
|
||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
@@ -123,11 +146,22 @@ impl DownloadWorker {
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// Stream download with progress tracking
|
||||
let mut downloaded = existing_bytes;
|
||||
let mut downloaded = resume_from;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_progress_emit = std::time::Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
// Checked before writing, so a paused download stops on a byte
|
||||
// boundary the `.part` file already accounts for — the Range request
|
||||
// on resume then asks for exactly what is missing. Flushing what we
|
||||
// have and leaving the file in place is the whole mechanism behind
|
||||
// "resume", so this must never delete it. (DR-168)
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
return Err(DownloadError::Stopped);
|
||||
}
|
||||
|
||||
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||
|
||||
file.write_all(&chunk)
|
||||
@@ -138,7 +172,7 @@ impl DownloadWorker {
|
||||
|
||||
// Emit progress every 500ms or every MB
|
||||
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||
|| downloaded % (1024 * 1024) == 0
|
||||
|| downloaded.is_multiple_of(1024 * 1024)
|
||||
{
|
||||
last_progress_emit = std::time::Instant::now();
|
||||
on_progress(downloaded, _total_bytes);
|
||||
@@ -167,6 +201,56 @@ impl DownloadWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to resume writing a partial download, given how the server answered.
|
||||
///
|
||||
/// A byte offset of 0 means "start the file again"; anything else means "append
|
||||
/// from here".
|
||||
///
|
||||
/// This is what makes non-`original` downloads survive. Those presets ask
|
||||
/// Jellyfin to **transcode**, and a live transcode is chunked with no
|
||||
/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
|
||||
/// answers `200` with the whole stream from the beginning, not `206` with the
|
||||
/// requested tail. The worker sent the header and appended the body regardless,
|
||||
/// so every retry — and every resume — concatenated a fresh copy of the whole
|
||||
/// transcode onto the bytes already on disk. The file grew past its real size
|
||||
/// and would not play. Only a `206` actually promises the tail; a `200` means we
|
||||
/// must discard what we have and take the stream from the top.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-170
|
||||
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
||||
if existing_bytes == 0 {
|
||||
return 0;
|
||||
}
|
||||
// 206 Partial Content is the only answer that honours the Range request.
|
||||
if status == 206 {
|
||||
existing_bytes
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The partial-download sidecar for `target`.
|
||||
///
|
||||
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||
/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
|
||||
/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
|
||||
/// `movie.mp4.part` — so nothing ever matched and the partial file of every
|
||||
/// cancelled or failed download was left on disk forever, invisible to the
|
||||
/// disk-usage totals because no `downloads` row pointed at it. That is the
|
||||
/// reported "failure is not cleaned".
|
||||
///
|
||||
/// Appending also removes a collision the old form had: `movie.mp4` and
|
||||
/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
|
||||
///
|
||||
/// One function so the writer and the cleaners cannot disagree again.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-169
|
||||
pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut s = target.as_os_str().to_os_string();
|
||||
s.push(".part");
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Result of a successful download
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadResult {
|
||||
@@ -179,6 +263,10 @@ pub enum DownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
FileSystem(String),
|
||||
/// The download was asked to stop (paused or cancelled). Not a failure: the
|
||||
/// row's status already says what happened, and the partial file is kept so a
|
||||
/// resume can continue from it.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl DownloadError {
|
||||
@@ -188,8 +276,16 @@ impl DownloadError {
|
||||
DownloadError::Network(_) => true,
|
||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||
DownloadError::FileSystem(_) => false,
|
||||
// Retrying would restart the very download the user just paused.
|
||||
DownloadError::Stopped => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this outcome means "the user stopped it", rather than a failure to
|
||||
/// record and report.
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
matches!(self, DownloadError::Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DownloadError {
|
||||
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
|
||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||
DownloadError::Stopped => write!(f, "Download stopped by request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +305,64 @@ impl std::error::Error for DownloadError {}
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||
/// duplicated them, so every retry grew the file past its real size and left
|
||||
/// it unplayable. Only `206` promises the requested tail.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-170 | UT-164
|
||||
#[test]
|
||||
fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
|
||||
// Nothing on disk: start at the beginning either way.
|
||||
assert_eq!(resume_offset(0, 200), 0);
|
||||
assert_eq!(resume_offset(0, 206), 0);
|
||||
|
||||
// The server agreed to the range — append to what we have.
|
||||
assert_eq!(resume_offset(5_000, 206), 5_000);
|
||||
|
||||
// The server ignored it and is sending the whole file (a transcode).
|
||||
// Restart, or the bytes are duplicated.
|
||||
assert_eq!(
|
||||
resume_offset(5_000, 200),
|
||||
0,
|
||||
"a 200 carries the whole stream; appending it corrupts the file"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression: `with_extension` replaced the extension, so the worker
|
||||
/// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
|
||||
/// Nothing matched, and partial files accumulated forever.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-169 | UT-163
|
||||
#[test]
|
||||
fn test_partial_path_appends_rather_than_replacing_the_extension() {
|
||||
use std::path::Path;
|
||||
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/movie.mp4")),
|
||||
Path::new("/media/movie.mp4.part"),
|
||||
"the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
|
||||
);
|
||||
|
||||
// Two sources for one title must not fight over a single partial file.
|
||||
assert_ne!(
|
||||
partial_path(Path::new("/media/movie.mp4")),
|
||||
partial_path(Path::new("/media/movie.mkv")),
|
||||
);
|
||||
|
||||
// Extension-less targets still get a sidecar rather than being clobbered.
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/track")),
|
||||
Path::new("/media/track.part"),
|
||||
);
|
||||
|
||||
// A dotted name keeps every part of its own name.
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/S01.E02.episode.mkv")),
|
||||
Path::new("/media/S01.E02.episode.mkv.part"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(
|
||||
@@ -231,5 +386,9 @@ mod tests {
|
||||
assert!(DownloadError::Http(503).is_retryable());
|
||||
assert!(!DownloadError::Http(404).is_retryable());
|
||||
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||
// Retrying a paused download would restart what the user just stopped.
|
||||
assert!(!DownloadError::Stopped.is_retryable());
|
||||
assert!(DownloadError::Stopped.is_stopped());
|
||||
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
||||
}
|
||||
}
|
||||
|
||||
+82
-7
@@ -5,6 +5,7 @@ mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod media_server;
|
||||
mod playback_mode;
|
||||
mod playback_reporting;
|
||||
mod player;
|
||||
@@ -85,6 +86,7 @@ use commands::{
|
||||
lms_unsync_player,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
offline_get_items,
|
||||
offline_is_available,
|
||||
offline_search,
|
||||
@@ -121,12 +123,14 @@ use commands::{
|
||||
player_get_audio_settings,
|
||||
player_get_autoplay_settings,
|
||||
player_get_cache_config,
|
||||
player_get_capabilities,
|
||||
player_get_eq_presets,
|
||||
player_get_queue,
|
||||
// Session management commands
|
||||
player_get_session,
|
||||
player_get_sleep_timer,
|
||||
player_get_status,
|
||||
player_get_streaming_qualities,
|
||||
player_get_video_settings,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
@@ -156,6 +160,7 @@ use commands::{
|
||||
player_set_cache_config,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_set_stream_quality,
|
||||
player_set_subtitle_track,
|
||||
player_set_video_settings,
|
||||
player_set_volume,
|
||||
@@ -262,6 +267,7 @@ use commands::{
|
||||
storage_save_user,
|
||||
storage_search_items,
|
||||
storage_set_active_user,
|
||||
storage_set_watched,
|
||||
storage_toggle_favorite,
|
||||
storage_update_playback_context,
|
||||
storage_update_playback_progress,
|
||||
@@ -273,6 +279,7 @@ use commands::{
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_mark_processing,
|
||||
sync_process_pending,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation,
|
||||
thumbnail_clear_cache,
|
||||
@@ -420,6 +427,28 @@ impl MediaSessionHandler {
|
||||
|
||||
/// Drive the local player for a transport command.
|
||||
fn handle_local_command(&self, command: &str) {
|
||||
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
|
||||
// whole episode — and resolving it during a background-audio handoff means
|
||||
// re-opening the stream, which is async. So it runs on the runtime and,
|
||||
// critically, is handled *before* the blocking lock below: taking that
|
||||
// guard and then spawning a task that waits for the same mutex would
|
||||
// deadlock the media session. (DR-159)
|
||||
if let Some(raw) = command.strip_prefix("seek:") {
|
||||
match raw.parse::<f64>() {
|
||||
Ok(position) => {
|
||||
let player = self.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let controller = player.lock().await;
|
||||
if let Err(e) = controller.seek_absolute(position).await {
|
||||
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use blocking_lock since this is called from a non-async JNI callback
|
||||
let controller = self.player.blocking_lock();
|
||||
|
||||
@@ -429,13 +458,6 @@ impl MediaSessionHandler {
|
||||
"next" => controller.next(),
|
||||
"previous" => controller.previous(),
|
||||
"stop" => controller.stop(),
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(pos) => controller.seek(pos),
|
||||
Err(_) => {
|
||||
warn!("[MediaSession] Bad seek command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
Ok(())
|
||||
@@ -679,6 +701,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_cycle_repeat,
|
||||
player_get_status,
|
||||
player_get_queue,
|
||||
player_get_capabilities,
|
||||
player_add_to_queue,
|
||||
player_add_track_by_id,
|
||||
player_add_tracks_by_ids,
|
||||
@@ -690,6 +713,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_get_eq_presets,
|
||||
player_set_video_settings,
|
||||
player_get_video_settings,
|
||||
player_get_streaming_qualities,
|
||||
player_set_stream_quality,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_cancel_sleep_timer,
|
||||
@@ -784,6 +809,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
storage_update_playback_progress,
|
||||
storage_update_playback_context,
|
||||
storage_mark_played,
|
||||
storage_set_watched,
|
||||
storage_get_playback_progress,
|
||||
storage_mark_synced,
|
||||
storage_toggle_favorite,
|
||||
@@ -806,6 +832,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
get_download_storage_stats,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
start_download,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
@@ -846,6 +873,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_get_pending_count,
|
||||
sync_process_pending,
|
||||
sync_cleanup_completed,
|
||||
sync_clear_user,
|
||||
// Thumbnail cache and image commands
|
||||
@@ -1001,6 +1029,16 @@ fn set_env_if_unset(key: &str, value: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloaded media and cached thumbnails are handed to the webview as
|
||||
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that
|
||||
/// origin when the `protocol-asset` cargo feature is compiled in *and*
|
||||
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`, which also
|
||||
/// scopes it to `$APPDATA/**` — the storage root holding the database,
|
||||
/// `downloads/` and the thumbnail cache. Both are required together: with either
|
||||
/// missing the URL resolves to nothing and the webview reports
|
||||
/// `NETWORK_NO_SOURCE`, which is how offline video came to fail silently.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-134
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Initialize logger
|
||||
@@ -1208,6 +1246,20 @@ pub fn run() {
|
||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||
app.manage(video_settings);
|
||||
|
||||
// Restore the persisted streaming bandwidth ceiling. Deferred to the
|
||||
// async runtime because the read is async, and ordered after the
|
||||
// wrapper above because it writes into it. Until it lands, streams
|
||||
// are uncapped — the pre-existing behaviour — and no playback can
|
||||
// have started this early anyway (login happens after setup).
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::commands::restore_streaming_quality(&handle).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
@@ -1228,6 +1280,22 @@ pub fn run() {
|
||||
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
||||
app.manage(smart_cache_wrapper);
|
||||
|
||||
// Serve downloaded media over loopback HTTP. The webview cannot
|
||||
// stream a large file through the asset protocol (see media_server),
|
||||
// so local playback resolves its URL from here instead.
|
||||
// TRACES: UR-071 | DR-137
|
||||
info!("[INIT] Starting local media server...");
|
||||
let media_server = match media_server::start(app_data_dir.clone()) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
// Not fatal: streaming still works, and the command reports
|
||||
// a clear error if local playback is attempted.
|
||||
error!("[INIT ERROR] Local media server failed to start: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
app.manage(media_server::MediaServerWrapper(media_server));
|
||||
|
||||
// Initialize download manager
|
||||
info!("[INIT] Initializing download manager...");
|
||||
let download_dir = app_data_dir.join("downloads");
|
||||
@@ -1309,6 +1377,13 @@ pub fn run() {
|
||||
info!("[INIT] Starting favourites drain...");
|
||||
commands::favorites::spawn_favorites_drain(app.handle().clone());
|
||||
|
||||
// Push playback reports queued while the server was unreachable.
|
||||
// Without this the `sync_queue` rows the reporter writes offline
|
||||
// are never sent and the offline banner's count only grows.
|
||||
// TRACES: UR-025, UR-002 | DR-131
|
||||
info!("[INIT] Starting sync-queue drain...");
|
||||
commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
|
||||
|
||||
info!("[INIT] Application setup completed successfully");
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! A loopback HTTP server for locally downloaded media.
|
||||
//!
|
||||
//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
|
||||
//! webview. Its response to a request *without* a `Range` header reads the whole
|
||||
//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
|
||||
//! inside the range branch — so the first request never learns ranges are
|
||||
//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
|
||||
//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
|
||||
//! "downloaded video does not play offline".
|
||||
//!
|
||||
//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
|
||||
//! deliberate: it makes range support a property of the transport instead of
|
||||
//! depending on whether a platform's webview forwards `Range` to a custom
|
||||
//! scheme, which differs between Android and the desktop webviews.
|
||||
//!
|
||||
//! Two things confine it, because **loopback is shared between apps on
|
||||
//! Android** — any other installed app can connect to this port:
|
||||
//!
|
||||
//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
|
||||
//! - every URL carries a random per-session token, so another app cannot guess a
|
||||
//! working URL, and paths are confined to the app data directory even if one
|
||||
//! did.
|
||||
//!
|
||||
//! Phase 1 serves local files only. The same origin is the intended home for
|
||||
//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
|
||||
//!
|
||||
//! TRACES: UR-071 | DR-137 | UT-127
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rand::Rng;
|
||||
use tiny_http::{Header, Response, Server, StatusCode};
|
||||
|
||||
/// Bytes per response. Large enough that a film needs relatively few round
|
||||
/// trips, small enough that one response is never a memory problem on a phone.
|
||||
/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
|
||||
/// multi-gigabyte files this exists to serve.
|
||||
const CHUNK_LEN: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// Managed state. `None` when the server could not bind — local playback then
|
||||
/// fails with a clear error instead of the app refusing to start.
|
||||
pub struct MediaServerWrapper(pub Option<MediaServer>);
|
||||
|
||||
/// A running server. Dropping this does not stop the thread; the server lives
|
||||
/// for the life of the process by design, since playback can start at any time.
|
||||
pub struct MediaServer {
|
||||
port: u16,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl MediaServer {
|
||||
/// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/{}", self.port, self.token)
|
||||
}
|
||||
|
||||
/// A playable URL for an absolute on-disk path.
|
||||
pub fn url_for(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url(), urlencoding::encode(path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind to an ephemeral loopback port and start serving `root` in a background
|
||||
/// thread.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137
|
||||
pub fn start(root: PathBuf) -> Result<MediaServer, String> {
|
||||
// Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
|
||||
// this off the network.
|
||||
let server =
|
||||
Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
|
||||
|
||||
let port = server
|
||||
.server_addr()
|
||||
.to_ip()
|
||||
.ok_or_else(|| "Media server bound to a non-IP address".to_string())?
|
||||
.port();
|
||||
|
||||
let token: String = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..32)
|
||||
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
|
||||
.collect()
|
||||
};
|
||||
|
||||
info!(
|
||||
"[MediaServer] Serving {} on 127.0.0.1:{}",
|
||||
root.display(),
|
||||
port
|
||||
);
|
||||
|
||||
let server = Arc::new(server);
|
||||
let shared = Arc::new((root, token.clone()));
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("media-server".into())
|
||||
.spawn(move || loop {
|
||||
let request = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("[MediaServer] accept failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let shared = Arc::clone(&shared);
|
||||
// A thread per request: media clients open several connections at
|
||||
// once, and a blocking read of one must not stall the others.
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("media-server-req".into())
|
||||
.spawn(move || {
|
||||
let (root, token) = &*shared;
|
||||
handle(request, root, token);
|
||||
})
|
||||
{
|
||||
error!("[MediaServer] could not spawn handler: {e}");
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Failed to start media server thread: {e}"))?;
|
||||
|
||||
Ok(MediaServer { port, token })
|
||||
}
|
||||
|
||||
fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||
.expect("static header name/value are valid")
|
||||
}
|
||||
|
||||
fn empty(status: u16) -> Response<std::io::Empty> {
|
||||
Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
|
||||
}
|
||||
|
||||
fn handle(request: tiny_http::Request, root: &Path, token: &str) {
|
||||
let url = request.url().to_string();
|
||||
let method = request.method().as_str().to_string();
|
||||
|
||||
let outcome = match route(&url, root, token) {
|
||||
Ok(path) => path,
|
||||
Err(status) => {
|
||||
let _ = request.respond(empty(status));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if method != "GET" && method != "HEAD" {
|
||||
let _ = request.respond(empty(405));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut file = match File::open(&outcome) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("[MediaServer] {}: {}", outcome.display(), e);
|
||||
let _ = request.respond(empty(404));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let len = match file.metadata() {
|
||||
Ok(m) => m.len(),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[MediaServer] metadata failed for {}: {}",
|
||||
outcome.display(),
|
||||
e
|
||||
);
|
||||
let _ = request.respond(empty(404));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let range = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
|
||||
debug!(
|
||||
"[MediaServer] {} {} ({} bytes) range={:?}",
|
||||
method,
|
||||
outcome.display(),
|
||||
len,
|
||||
range
|
||||
);
|
||||
|
||||
let Some(span) = span_for(range.as_deref(), len) else {
|
||||
let _ = request
|
||||
.respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
|
||||
return;
|
||||
};
|
||||
|
||||
// Sniff before seeking to the span, for extension-less files.
|
||||
let mut head = [0u8; 16];
|
||||
let head_len = file.read(&mut head).unwrap_or(0);
|
||||
let mime = content_type(&outcome, &head[..head_len]);
|
||||
|
||||
if method == "HEAD" {
|
||||
let _ = request.respond(
|
||||
empty(200)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("Content-Length", &len.to_string())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
|
||||
warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
|
||||
let _ = request.respond(empty(500));
|
||||
return;
|
||||
}
|
||||
|
||||
// Streamed straight from the file handle: at no point is more than the
|
||||
// span in memory, and the span is capped at CHUNK_LEN.
|
||||
let nbytes = span.len();
|
||||
let body = file.take(nbytes);
|
||||
// tiny_http switches to chunked transfer above a 32 KiB default, which drops
|
||||
// Content-Length — and a 206 without one is unusable to Chromium's media
|
||||
// loader, which needs the range's size. Raising the threshold past our own
|
||||
// cap keeps every response length-delimited.
|
||||
let response = Response::new(
|
||||
StatusCode(206),
|
||||
vec![
|
||||
header("Accept-Ranges", "bytes"),
|
||||
header("Content-Type", mime),
|
||||
header(
|
||||
"Content-Range",
|
||||
&format!("bytes {}-{}/{}", span.start, span.end, len),
|
||||
),
|
||||
],
|
||||
body,
|
||||
Some(nbytes as usize),
|
||||
None,
|
||||
)
|
||||
.with_chunked_threshold(usize::MAX);
|
||||
|
||||
if let Err(e) = request.respond(response) {
|
||||
// A client that seeks away closes the connection mid-body; that is
|
||||
// normal and must not be logged as a failure.
|
||||
debug!("[MediaServer] response ended early: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the token and resolve the path, or return the status to answer with.
|
||||
fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
|
||||
let trimmed = url.trim_start_matches('/');
|
||||
let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
|
||||
|
||||
// Constant-time-ish: length check first, then a byte compare. The token is
|
||||
// the only thing standing between another app on the device and this server.
|
||||
if got_token.len() != token.len() || got_token != token {
|
||||
warn!("[MediaServer] Rejected a request with a bad token");
|
||||
return Err(403);
|
||||
}
|
||||
|
||||
// Strip any query string before decoding.
|
||||
let raw = rest.split('?').next().unwrap_or("");
|
||||
match resolve_path(raw, root) {
|
||||
Resolved::Allow(p) => Ok(p),
|
||||
Resolved::Forbidden => {
|
||||
warn!("[MediaServer] Refused a path outside the app data directory");
|
||||
Err(403)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a request path resolved to, before any file is touched.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Resolved {
|
||||
Allow(PathBuf),
|
||||
/// Escaped the allowed root.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
/// Resolve a percent-encoded request path to a file inside `root`.
|
||||
///
|
||||
/// `..` segments are folded away lexically rather than through `canonicalize`,
|
||||
/// so a missing file still resolves (and then 404s) instead of being reported as
|
||||
/// a scope violation.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
|
||||
let decoded = match urlencoding::decode(raw) {
|
||||
Ok(d) => d.into_owned(),
|
||||
Err(_) => raw.to_string(),
|
||||
};
|
||||
|
||||
let mut normalised = PathBuf::new();
|
||||
for part in Path::new(&decoded).components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
normalised.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
other => normalised.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if normalised.starts_with(root) {
|
||||
Resolved::Allow(normalised)
|
||||
} else {
|
||||
Resolved::Forbidden
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte range a response should carry. `end` is inclusive.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub fn len(&self) -> u64 {
|
||||
self.end + 1 - self.start
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which span to send for a `Range` header (or its absence).
|
||||
///
|
||||
/// `None` means unsatisfiable — answer 416. A missing or unparseable header
|
||||
/// yields the first chunk, so a client that did not ask for a range still gets a
|
||||
/// bounded response it can continue from, which is exactly the case the asset
|
||||
/// protocol answered with the whole file.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
|
||||
if len == 0 {
|
||||
return Some(Span { start: 0, end: 0 });
|
||||
}
|
||||
let last = len - 1;
|
||||
let first_chunk = Span {
|
||||
start: 0,
|
||||
end: (CHUNK_LEN - 1).min(last),
|
||||
};
|
||||
|
||||
let Some(raw) = range else {
|
||||
return Some(first_chunk);
|
||||
};
|
||||
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
|
||||
return Some(first_chunk);
|
||||
};
|
||||
// Only the first range of a multi-range request is honoured; media clients
|
||||
// ask for one, and a single 206 is a valid answer either way.
|
||||
let spec = spec.split(',').next().unwrap_or("").trim();
|
||||
let Some((from, to)) = spec.split_once('-') else {
|
||||
return Some(first_chunk);
|
||||
};
|
||||
|
||||
let (start, end) = if from.is_empty() {
|
||||
// Suffix form: `-500` is the final 500 bytes.
|
||||
let suffix: u64 = match to.parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Some(first_chunk),
|
||||
};
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
}
|
||||
(len.saturating_sub(suffix), last)
|
||||
} else {
|
||||
let start: u64 = match from.parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Some(first_chunk),
|
||||
};
|
||||
let end = if to.is_empty() {
|
||||
last
|
||||
} else {
|
||||
match to.parse::<u64>() {
|
||||
Ok(n) => n.min(last),
|
||||
Err(_) => return Some(first_chunk),
|
||||
}
|
||||
};
|
||||
(start, end)
|
||||
};
|
||||
|
||||
if start > last || end < start {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Span {
|
||||
start,
|
||||
end: end.min(start + CHUNK_LEN - 1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Guess a content type.
|
||||
///
|
||||
/// **Magic bytes win over the extension.** Downloading at `original` quality
|
||||
/// asks Jellyfin for a direct static copy, which returns the *source file's*
|
||||
/// bytes under a `.mp4` name whatever the real container is — a downloaded film
|
||||
/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
|
||||
/// there labels it `video/mp4` and the player is handed a container that is not
|
||||
/// what the header claims. The extension is only a fallback for a file whose
|
||||
/// bytes are unrecognised, and for the extension-less files the offline queue
|
||||
/// writes under an item id.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
fn content_type(path: &Path, head: &[u8]) -> &'static str {
|
||||
// `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
|
||||
if head.len() > 11 && &head[4..8] == b"ftyp" {
|
||||
return "video/mp4";
|
||||
}
|
||||
if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
|
||||
return "video/x-msvideo";
|
||||
}
|
||||
if head.starts_with(b"\x1aE\xdf\xa3") {
|
||||
return "video/x-matroska";
|
||||
}
|
||||
if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
|
||||
return "audio/mpeg";
|
||||
}
|
||||
if head.starts_with(b"OggS") {
|
||||
return "audio/ogg";
|
||||
}
|
||||
if head.starts_with(b"fLaC") {
|
||||
return "audio/flac";
|
||||
}
|
||||
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("mp4" | "m4v" | "mov") => return "video/mp4",
|
||||
Some("mkv") => return "video/x-matroska",
|
||||
Some("webm") => return "video/webm",
|
||||
Some("mp3") => return "audio/mpeg",
|
||||
Some("m4a" | "aac") => return "audio/mp4",
|
||||
Some("flac") => return "audio/flac",
|
||||
Some("ogg" | "opus") => return "audio/ogg",
|
||||
Some("wav") => return "audio/wav",
|
||||
Some("avi") => return "video/x-msvideo",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
"application/octet-stream"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The whole point: a request with no `Range` must still come back bounded.
|
||||
/// That is the case Tauri's asset protocol answers with the entire file —
|
||||
/// the read Chromium abandoned after 31s.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
|
||||
let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
|
||||
let span = span_for(None, huge).unwrap();
|
||||
assert_eq!(span.start, 0);
|
||||
assert_eq!(span.len(), CHUNK_LEN);
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn no_response_ever_exceeds_one_chunk() {
|
||||
let len = 8 * 1024 * 1024 * 1024;
|
||||
for h in [
|
||||
"bytes=0-",
|
||||
"bytes=0-99999999999",
|
||||
"bytes=1024-",
|
||||
"bytes=-99999999",
|
||||
] {
|
||||
let span = span_for(Some(h), len).unwrap();
|
||||
assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn ranges_are_honoured() {
|
||||
let len = 1000u64;
|
||||
assert_eq!(
|
||||
span_for(Some("bytes=100-199"), len).unwrap(),
|
||||
Span {
|
||||
start: 100,
|
||||
end: 199
|
||||
}
|
||||
);
|
||||
// Open-ended runs to the end of a small file.
|
||||
assert_eq!(
|
||||
span_for(Some("bytes=900-"), len).unwrap(),
|
||||
Span {
|
||||
start: 900,
|
||||
end: 999
|
||||
}
|
||||
);
|
||||
// Suffix form.
|
||||
assert_eq!(
|
||||
span_for(Some("bytes=-100"), len).unwrap(),
|
||||
Span {
|
||||
start: 900,
|
||||
end: 999
|
||||
}
|
||||
);
|
||||
// Past the end is unsatisfiable, not a clamp — a clamp would make a
|
||||
// seek past the end silently replay earlier bytes.
|
||||
assert!(span_for(Some("bytes=1000-"), len).is_none());
|
||||
}
|
||||
|
||||
/// A malformed header must not fail the request: playing from the start is
|
||||
/// strictly better than refusing to open the file.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn a_malformed_range_falls_back_to_the_first_chunk() {
|
||||
assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
|
||||
assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn reads_are_confined_to_the_app_data_directory() {
|
||||
let root = Path::new("/data/user/0/app");
|
||||
|
||||
assert_eq!(
|
||||
resolve_path("/data/user/0/app/videos/f.mp4", root),
|
||||
Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
|
||||
);
|
||||
// Percent-encoded, as the URL builder produces.
|
||||
assert_eq!(
|
||||
resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
|
||||
Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
|
||||
);
|
||||
// Traversal out of the root, and an unrelated absolute path, are refused.
|
||||
assert_eq!(
|
||||
resolve_path("/data/user/0/app/../../../etc/passwd", root),
|
||||
Resolved::Forbidden
|
||||
);
|
||||
assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
|
||||
}
|
||||
|
||||
/// Loopback is shared between apps on Android, so the token is the only
|
||||
/// thing stopping another installed app from reading downloaded media.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn a_request_without_the_right_token_is_refused() {
|
||||
let root = Path::new("/data/user/0/app");
|
||||
let good = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
assert_eq!(
|
||||
route(
|
||||
&format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
|
||||
root,
|
||||
good
|
||||
),
|
||||
Ok(PathBuf::from("/data/user/0/app/f.mp4"))
|
||||
);
|
||||
assert_eq!(
|
||||
route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
|
||||
Err(403)
|
||||
);
|
||||
// No token segment at all.
|
||||
assert_eq!(route("/f.mp4", root, good), Err(404));
|
||||
// Right token, but a path outside the root is still refused.
|
||||
assert_eq!(
|
||||
route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
|
||||
Err(403)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn content_type_uses_the_extension_then_the_magic_bytes() {
|
||||
assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
|
||||
assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
|
||||
// A `.mp4` that is really an AVI: downloading at `original` quality
|
||||
// copies the source bytes under an mp4 name, so the extension lies and
|
||||
// the magic bytes must win.
|
||||
let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
|
||||
assert_eq!(
|
||||
content_type(Path::new("/a/film.mp4"), avi_head),
|
||||
"video/x-msvideo"
|
||||
);
|
||||
// Extension-less, as the offline queue writes them: sniff instead.
|
||||
let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
|
||||
assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
|
||||
assert_eq!(
|
||||
content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
|
||||
"audio/mpeg"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ const TICKS_PER_SECOND: f64 = 10_000_000.0;
|
||||
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
||||
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
||||
|
||||
/// Volume level (0-100) the remote volume slider starts at. The real level is
|
||||
/// corrected by the session poller once the remote session reports its volume.
|
||||
const DEFAULT_REMOTE_VOLUME: i32 = 50;
|
||||
|
||||
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
||||
/// hand to a remote session, or `None` if we're effectively at the start.
|
||||
///
|
||||
@@ -42,6 +46,50 @@ fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform hook for attaching/detaching the OS remote-volume control.
|
||||
///
|
||||
/// On Android, entering remote mode hands the `MediaSession` a
|
||||
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
|
||||
/// the *remote* session; leaving remote mode must hand it back to the local
|
||||
/// media stream. Behind a trait so the routing rule (see
|
||||
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
|
||||
/// implementation is JNI and only exists on Android.
|
||||
pub trait RemoteVolumeControl: Send + Sync {
|
||||
/// Attach remote-volume control (and, on Android, start the playback service).
|
||||
fn enable(&self, initial_volume: i32);
|
||||
/// Return volume control to the local device speaker.
|
||||
fn disable(&self);
|
||||
}
|
||||
|
||||
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
|
||||
struct PlatformRemoteVolumeControl;
|
||||
|
||||
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
|
||||
#[allow(unused_variables)]
|
||||
fn enable(&self, initial_volume: i32) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||
e
|
||||
);
|
||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disable(&self) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::disable_remote_volume() {
|
||||
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
||||
// Non-fatal - the mode change itself has already happened.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages playback mode transfers between local and remote sessions
|
||||
pub struct PlaybackModeManager {
|
||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||
@@ -51,6 +99,8 @@ pub struct PlaybackModeManager {
|
||||
/// Optional emitter used to notify the frontend when the mode changes, so its
|
||||
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||
/// Platform hook for OS-level remote volume routing (swapped in tests).
|
||||
remote_volume: Arc<dyn RemoteVolumeControl>,
|
||||
}
|
||||
|
||||
impl PlaybackModeManager {
|
||||
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
|
||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||
event_emitter: Arc::new(Mutex::new(None)),
|
||||
remote_volume: Arc::new(PlatformRemoteVolumeControl),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct with a custom remote-volume hook (tests).
|
||||
#[cfg(test)]
|
||||
fn with_remote_volume(
|
||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||
player_controller: Arc<TokioMutex<PlayerController>>,
|
||||
remote_volume: Arc<dyn RemoteVolumeControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
jellyfin_client,
|
||||
player_controller,
|
||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||
event_emitter: Arc::new(Mutex::new(None)),
|
||||
remote_volume,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,19 +154,39 @@ impl PlaybackModeManager {
|
||||
/// the frontend's mirror store reconciles to this authoritative value. The
|
||||
/// write lock is released before emitting to avoid holding it across the
|
||||
/// emitter call.
|
||||
///
|
||||
/// Also owns **OS volume routing**, which is derived from the transition
|
||||
/// rather than from each call site: entering remote mode attaches the remote
|
||||
/// volume control, and *any* exit from remote mode hands it back to the local
|
||||
/// speaker. Doing this per-call-site is what caused the bug where stopping a
|
||||
/// remote session (`player_stop` → Idle) left Android stuck on the remote
|
||||
/// volume slider — only the transfer-to-local path tore it down.
|
||||
///
|
||||
/// TRACES: UR-010 | DR-059, IR-021
|
||||
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||
let changed = {
|
||||
let (changed, was_remote) = {
|
||||
let mut current = self.current_mode.write_safe();
|
||||
let changed = *current != mode;
|
||||
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
||||
*current = mode.clone();
|
||||
changed
|
||||
(changed, was_remote)
|
||||
};
|
||||
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
|
||||
// Volume routing follows the transition. Note remote->remote (switching
|
||||
// target session) re-arms rather than releasing control.
|
||||
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
|
||||
if is_remote {
|
||||
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||
} else if was_remote {
|
||||
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
|
||||
self.remote_volume.disable();
|
||||
}
|
||||
|
||||
let (mode_str, session_id) = match &mode {
|
||||
PlaybackMode::Local => ("local".to_string(), None),
|
||||
PlaybackMode::Idle => ("idle".to_string(), None),
|
||||
@@ -122,18 +210,13 @@ impl PlaybackModeManager {
|
||||
/// Both symptoms share this one cause, so this must not be skipped on any
|
||||
/// remote-entry path (notably the empty-queue early return in
|
||||
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
||||
#[allow(unused_variables)]
|
||||
///
|
||||
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
|
||||
/// calling it again is harmless (the service start is idempotent) and keeps
|
||||
/// the guarantee when the mode was already remote, which `set_mode` skips as
|
||||
/// a no-op transition.
|
||||
fn enable_remote_control(&self) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||
e
|
||||
);
|
||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||
}
|
||||
}
|
||||
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||
}
|
||||
|
||||
/// Check if currently transferring
|
||||
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
|
||||
// This will be improved in Phase 3 when repository is migrated to Rust.
|
||||
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
||||
|
||||
// Update mode to local
|
||||
// Update mode to local. This also returns volume control to the local
|
||||
// device speaker — set_mode owns that for every exit from remote mode.
|
||||
self.set_mode(PlaybackMode::Local);
|
||||
|
||||
// Disable remote volume control on Android (return to system volume)
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::disable_remote_volume() {
|
||||
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
||||
// Non-fatal - continue with transfer
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[PlaybackMode] Successfully transferred to local");
|
||||
Ok(())
|
||||
}
|
||||
@@ -893,6 +968,118 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records enable/disable calls so tests can assert volume routing.
|
||||
struct RecordingVolumeControl {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
|
||||
impl RemoteVolumeControl for RecordingVolumeControl {
|
||||
fn enable(&self, _initial_volume: i32) {
|
||||
self.calls.lock().unwrap().push("enable");
|
||||
}
|
||||
fn disable(&self) {
|
||||
self.calls.lock().unwrap().push("disable");
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
|
||||
let volume = Arc::new(RecordingVolumeControl {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
});
|
||||
let manager = PlaybackModeManager::with_remote_volume(
|
||||
Arc::new(Mutex::new(None)),
|
||||
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||
volume.clone(),
|
||||
);
|
||||
(manager, volume)
|
||||
}
|
||||
|
||||
/// Leaving remote mode must hand volume control back to the local device.
|
||||
///
|
||||
/// Stopping a remote session (`player_stop`) drives the manager
|
||||
/// Remote -> Idle without going through `transfer_to_local`. Before this was
|
||||
/// centralised in `set_mode`, only the transfer path tore the Android
|
||||
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
|
||||
/// remote volume slider with no way back to the phone speaker.
|
||||
///
|
||||
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||
#[test]
|
||||
fn test_leaving_remote_mode_restores_local_volume() {
|
||||
let (manager, volume) = manager_with_volume_control();
|
||||
|
||||
manager.set_mode(PlaybackMode::Remote {
|
||||
session_id: "sess-1".to_string(),
|
||||
});
|
||||
// The stop path: remote -> idle, no transfer involved.
|
||||
manager.set_mode(PlaybackMode::Idle);
|
||||
|
||||
assert_eq!(
|
||||
*volume.calls.lock().unwrap(),
|
||||
vec!["enable", "disable"],
|
||||
"remote->idle must return volume control to the local speaker"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same must hold for remote -> local (transfer back to this device).
|
||||
///
|
||||
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||
#[test]
|
||||
fn test_remote_to_local_restores_local_volume() {
|
||||
let (manager, volume) = manager_with_volume_control();
|
||||
|
||||
manager.set_mode(PlaybackMode::Remote {
|
||||
session_id: "sess-1".to_string(),
|
||||
});
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
|
||||
assert_eq!(
|
||||
*volume.calls.lock().unwrap(),
|
||||
vec!["enable", "disable"],
|
||||
"remote->local must return volume control to the local speaker"
|
||||
);
|
||||
}
|
||||
|
||||
/// Volume routing must not be touched by transitions that never involve
|
||||
/// remote mode — an idle->local start would otherwise issue a pointless
|
||||
/// `setPlaybackToLocal` on every playback start.
|
||||
///
|
||||
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||
#[test]
|
||||
fn test_non_remote_transitions_leave_volume_routing_alone() {
|
||||
let (manager, volume) = manager_with_volume_control();
|
||||
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
manager.set_mode(PlaybackMode::Idle);
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
|
||||
assert!(
|
||||
volume.calls.lock().unwrap().is_empty(),
|
||||
"local/idle transitions must not touch remote volume routing"
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching directly between two remote sessions stays remote: control must
|
||||
/// remain attached (re-armed for the new session), never handed back local.
|
||||
///
|
||||
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||
#[test]
|
||||
fn test_remote_to_remote_keeps_remote_volume() {
|
||||
let (manager, volume) = manager_with_volume_control();
|
||||
|
||||
manager.set_mode(PlaybackMode::Remote {
|
||||
session_id: "sess-1".to_string(),
|
||||
});
|
||||
manager.set_mode(PlaybackMode::Remote {
|
||||
session_id: "sess-2".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
*volume.calls.lock().unwrap(),
|
||||
vec!["enable", "enable"],
|
||||
"remote->remote re-arms control without releasing it to local"
|
||||
);
|
||||
}
|
||||
|
||||
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
||||
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
||||
#[test]
|
||||
|
||||
@@ -58,11 +58,20 @@ static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
|
||||
struct DetectedCodecs {
|
||||
video_codecs: Vec<String>,
|
||||
audio_codecs: Vec<String>,
|
||||
/// Channels the *current audio output route* accepts, as reported by
|
||||
/// media3's `AudioCapabilities`. Distinct from the codec lists: a device
|
||||
/// decodes 5.1 happily and still has only two channels to play it out of.
|
||||
/// `None` when the platform had no answer.
|
||||
max_audio_channels: Option<u32>,
|
||||
}
|
||||
|
||||
impl DetectedCodecs {
|
||||
/// Create from comma-separated codec strings (from JNI)
|
||||
fn from_jni_strings(video_codecs: &str, audio_codecs: &str) -> Self {
|
||||
fn from_jni_strings(
|
||||
video_codecs: &str,
|
||||
audio_codecs: &str,
|
||||
max_audio_channels: Option<u32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
video_codecs: video_codecs
|
||||
.split(',')
|
||||
@@ -74,6 +83,7 @@ impl DetectedCodecs {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
max_audio_channels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +98,19 @@ impl DetectedCodecs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Public function to get detected codecs (for use in repository layer)
|
||||
pub fn get_detected_codecs() -> Option<(String, String)> {
|
||||
DETECTED_CODECS
|
||||
.get()
|
||||
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
|
||||
/// Public function to get detected codecs (for use in repository layer).
|
||||
///
|
||||
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
|
||||
/// channels the current audio output can actually voice, which bounds what the
|
||||
/// server may direct-play.
|
||||
pub fn get_detected_codecs() -> Option<(String, String, Option<u32>)> {
|
||||
DETECTED_CODECS.get().map(|codecs| {
|
||||
(
|
||||
codecs.video_codecs_string(),
|
||||
codecs.audio_codecs_string(),
|
||||
codecs.max_audio_channels,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Trait for handling media commands from Android MediaSession.
|
||||
@@ -1125,6 +1143,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
_class: JClass,
|
||||
video_codecs: JString,
|
||||
audio_codecs: JString,
|
||||
max_audio_channels: jint,
|
||||
) {
|
||||
let video_str: String = env
|
||||
.get_string(&video_codecs)
|
||||
@@ -1136,7 +1155,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str);
|
||||
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
||||
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
|
||||
|
||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
|
||||
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} video codecs: {}",
|
||||
@@ -1148,6 +1170,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
codecs.audio_codecs.len(),
|
||||
codecs.audio_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Audio route max channels: {:?}",
|
||||
codecs.max_audio_channels
|
||||
);
|
||||
|
||||
// Store in global state
|
||||
if DETECTED_CODECS.set(codecs).is_err() {
|
||||
@@ -1448,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
||||
/// Set the background-audio handoff base (seconds) on the playback service.
|
||||
///
|
||||
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
||||
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
|
||||
/// place the relative handoff timeline is converted to the episode's own — see
|
||||
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
|
||||
/// service isn't running yet, so it's safe to call unconditionally.
|
||||
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
@@ -1499,11 +1527,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
|
||||
env.call_method(
|
||||
&service_obj,
|
||||
"setPositionOffset",
|
||||
"setHandoffBase",
|
||||
"(D)V",
|
||||
&[JValue::Double(offset_seconds)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
||||
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -23,6 +23,23 @@ pub enum QueueContext {
|
||||
}
|
||||
|
||||
/// Represents a subtitle track
|
||||
///
|
||||
/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
||||
/// struct in the player that deliberately keeps snake_case on the wire, because
|
||||
/// the *same* serialization feeds two consumers that both spell `mime_type`:
|
||||
///
|
||||
/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
||||
/// with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
||||
/// whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
||||
/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
||||
/// from the frontend, and the generated binding (`SubtitleTrack` in
|
||||
/// `bindings.ts`) therefore also declares `mime_type`.
|
||||
///
|
||||
/// Renaming would not break the build and would not fail the IPC: Kotlin's
|
||||
/// `optString` would just fall back to its default MIME type for every track, so
|
||||
/// the failure would be silent. UT-146 asserts the serialized keys.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SubtitleTrack {
|
||||
/// Stream index in the media source
|
||||
@@ -33,7 +50,8 @@ pub struct SubtitleTrack {
|
||||
pub language: Option<String>,
|
||||
/// Display title
|
||||
pub label: Option<String>,
|
||||
/// MIME type (e.g., "text/vtt", "application/x-subrip")
|
||||
/// MIME type (e.g., "text/vtt", "application/x-subrip").
|
||||
/// Snake_case on purpose — see the note on the struct.
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
|
||||
+141
-13
@@ -32,7 +32,7 @@ pub mod webview_audio_backend;
|
||||
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
||||
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
|
||||
pub use queue::{QueueManager, RepeatMode};
|
||||
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
||||
pub use session::{MediaSessionManager, MediaSessionType};
|
||||
@@ -754,12 +754,50 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek to a position in seconds
|
||||
/// Seek to a position in seconds, **on the player's own timeline**.
|
||||
///
|
||||
/// During a background-audio handoff that timeline is relative to the handoff
|
||||
/// point, so this is not the call a lockscreen scrub or a UI seek wants — use
|
||||
/// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
|
||||
/// timeline and is what every caller outside the player itself means.
|
||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.seek(position)
|
||||
}
|
||||
|
||||
/// Seek to an **absolute** position on the item's own timeline.
|
||||
///
|
||||
/// This is the boundary every outside seek comes through — the UI, the
|
||||
/// lockscreen scrubber, a headset gesture — because all of them are looking
|
||||
/// at the whole episode, not at whatever fragment of it the player happens to
|
||||
/// be streaming.
|
||||
///
|
||||
/// Outside a background-audio handoff the two timelines are the same and this
|
||||
/// is an ordinary seek. Inside one they differ by the handoff base, and the
|
||||
/// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
|
||||
/// transcode with no length, so ExoPlayer either refuses or clamps — and a
|
||||
/// clamped seek lands at stream zero, which is the handoff point. That is the
|
||||
/// "jumps back to where I locked the screen" symptom. Honouring the seek means
|
||||
/// re-opening the URL at the new position, which is exactly what the
|
||||
/// truncation recovery already does, so it shares `resume_stream_at`.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
||||
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
|
||||
let rebuild = self.is_background_audio_active() && {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue
|
||||
.current()
|
||||
.map(Self::is_audio_only_video)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if rebuild {
|
||||
return self.resume_stream_at(position.max(0.0)).await;
|
||||
}
|
||||
|
||||
self.seek(position).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set volume (0.0 - 1.0)
|
||||
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
||||
self.backend.lock_safe().set_volume(volume)
|
||||
@@ -1384,8 +1422,10 @@ impl PlayerController {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = *self.background_audio_base.lock_safe();
|
||||
let absolute = (base + self.position()).max(0.0);
|
||||
// Already absolute: the Android position tick shifts by the handoff base
|
||||
// before anything sees the value, so adding it again here would
|
||||
// double-count it. (DR-159)
|
||||
let absolute = self.position().max(0.0);
|
||||
|
||||
match self.stream_resume.lock_safe().allow_attempt(absolute) {
|
||||
Some(attempt) => Some((absolute, attempt)),
|
||||
@@ -1425,8 +1465,8 @@ impl PlayerController {
|
||||
}
|
||||
current.duration
|
||||
};
|
||||
let base = *self.background_audio_base.lock_safe();
|
||||
let absolute = (base + self.position()).max(0.0);
|
||||
// Already absolute — see claim_stream_resume. (DR-159)
|
||||
let absolute = self.position().max(0.0);
|
||||
|
||||
// Only spend a resume attempt once the runtime says this really was cut
|
||||
// short — a genuine end must stay a genuine end.
|
||||
@@ -3229,7 +3269,13 @@ mod tests {
|
||||
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
@@ -3248,6 +3294,9 @@ mod tests {
|
||||
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
@@ -3601,10 +3650,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The handoff stream's timeline starts at the handoff position, so the
|
||||
/// player reports a *relative* position. The runtime it is compared against
|
||||
/// is absolute — the base has to be added back, or every handoff looks like a
|
||||
/// truncation.
|
||||
/// A seek arriving during a background-audio handoff is **absolute** — the
|
||||
/// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
|
||||
/// 25:00 of the episode, not 25:00 into the handoff stream.
|
||||
///
|
||||
/// The handoff stream cannot be seeked at all (a chunked, length-less
|
||||
/// transcode), so honouring it means re-opening the URL at the new position,
|
||||
/// exactly as the truncation recovery does. Passing the number through to
|
||||
/// ExoPlayer instead — which is what used to happen — asked a stream that
|
||||
/// cannot seek to jump past its own end, and a clamped seek lands at stream
|
||||
/// zero: the handoff point.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
||||
#[tokio::test]
|
||||
async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
|
||||
// Handed off 20 minutes in, so the stream's zero is 1200s.
|
||||
controller.enter_background_audio(1200.0);
|
||||
|
||||
// The viewer scrubs the lockscreen to 25:00 absolute.
|
||||
controller.seek_absolute(1490.0).await.unwrap();
|
||||
|
||||
let url = {
|
||||
let queue = controller.queue();
|
||||
let queue = queue.lock_safe();
|
||||
match &queue.current().unwrap().source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
other => panic!("expected a remote source, got {:?}", other),
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
url.contains(&format!(
|
||||
"StartTimeTicks={}",
|
||||
(1490.0 * 10_000_000.0) as i64
|
||||
)),
|
||||
"the stream must be re-opened at the absolute position; got {}",
|
||||
url
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*controller.background_audio_base.lock_safe(),
|
||||
1490.0,
|
||||
"the re-opened stream's zero is the position it was opened at, or \
|
||||
every later reading is off by the difference"
|
||||
);
|
||||
}
|
||||
|
||||
/// Outside a handoff there is no base and nothing to re-open: an absolute
|
||||
/// seek is just a seek, and must not be turned into a stream rebuild.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-159 | UT-155
|
||||
#[tokio::test]
|
||||
async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
|
||||
controller.seek_absolute(300.0).await.unwrap();
|
||||
|
||||
assert_eq!(controller.position(), 300.0);
|
||||
assert_eq!(
|
||||
*controller.background_audio_base.lock_safe(),
|
||||
0.0,
|
||||
"an ordinary seek must not invent a handoff base"
|
||||
);
|
||||
}
|
||||
|
||||
/// The truncation check compares the position against the item's runtime, so
|
||||
/// both must be on the same timeline.
|
||||
///
|
||||
/// They now are by construction: the Android position tick shifts by the
|
||||
/// handoff base before anything sees the value, so what the player reports is
|
||||
/// already a position on the episode. The base is therefore *not* added here —
|
||||
/// doing so would double-count it and make the last minute of a handoff look
|
||||
/// like a truncation. What the mock backend holds is what the real one would
|
||||
/// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
|
||||
#[tokio::test]
|
||||
async fn test_truncated_check_uses_the_absolute_position() {
|
||||
let controller = PlayerController::default();
|
||||
@@ -3613,9 +3740,10 @@ mod tests {
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
// Handed off at 24:00; the stream then played its last 56 seconds out.
|
||||
// Handed off at 24:00; the stream then played its last 56 seconds out, so
|
||||
// the player reports 24:56 of the episode.
|
||||
controller.set_background_audio_base(1440.0);
|
||||
controller.seek(56.0).unwrap();
|
||||
controller.seek(1496.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Device-profile policy: turning what a device *reports* about its audio
|
||||
//! output into the constraints we send Jellyfin.
|
||||
//!
|
||||
//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
|
||||
//! many channels the current audio route accepts); deciding what those facts
|
||||
//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
|
||||
//! of the boundary, where it is testable without a device.
|
||||
|
||||
/// Channel count assumed when the platform cannot tell us — every audio route
|
||||
/// can voice stereo, so it is the only safe floor.
|
||||
const FALLBACK_AUDIO_CHANNELS: u32 = 2;
|
||||
|
||||
/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
|
||||
/// nonsense reading from a driver should not become a nonsense profile.
|
||||
const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
|
||||
|
||||
/// Decide the `MaxAudioChannels` to advertise, given what the current audio
|
||||
/// route reported.
|
||||
///
|
||||
/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
|
||||
/// a sink that only has two channels. What the user hears then is device
|
||||
/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
|
||||
/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
|
||||
/// channel count makes the server downmix instead, which is always audible.
|
||||
///
|
||||
/// A missing or zero reading means "route not established yet", not "no audio":
|
||||
/// fall back to stereo rather than claiming a capability we have not seen.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-141 | UT-141
|
||||
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
|
||||
match reported {
|
||||
Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
|
||||
_ => FALLBACK_AUDIO_CHANNELS,
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel cap for this device, reading the platform's report where one
|
||||
/// exists.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-141 | UT-141
|
||||
pub fn max_audio_channels() -> u32 {
|
||||
#[cfg(target_os = "android")]
|
||||
let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
|
||||
|
||||
// Desktop plays video through the WebKitGTK HTML5 <video> element, which we
|
||||
// do not interrogate for a channel count; stereo is the safe assumption.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let reported: Option<u32> = None;
|
||||
|
||||
clamp_max_audio_channels(reported)
|
||||
}
|
||||
|
||||
/// Audio codecs the webview's `<video>` element can decode.
|
||||
///
|
||||
/// Deliberately narrower than what the platform reports: see
|
||||
/// [`video_audio_codecs`].
|
||||
const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
|
||||
|
||||
/// The codec claimed when a device reports nothing we can use. Every renderer
|
||||
/// decodes AAC, and claiming *something* is what makes the server transcode to
|
||||
/// it rather than give up.
|
||||
const FALLBACK_AUDIO_CODEC: &str = "aac";
|
||||
|
||||
/// Narrow a detected audio-codec list to what the renderer that will actually
|
||||
/// play the **video** can decode.
|
||||
///
|
||||
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
||||
/// but the webview `<video>` element may be what renders the video, and
|
||||
/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
|
||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||
/// the user gets picture with no sound.
|
||||
///
|
||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||
/// the alternative is silence for everyone the switch lands the other way, which
|
||||
/// is the bug this exists to prevent.
|
||||
///
|
||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
||||
///
|
||||
/// This applies to the *video* direct-play profile only. Audio-only playback
|
||||
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-148 | UT-142
|
||||
pub fn video_audio_codecs(detected: &str) -> String {
|
||||
let kept: Vec<&str> = detected
|
||||
.split(',')
|
||||
.filter_map(|codec| {
|
||||
let codec = codec.trim();
|
||||
// Match case-insensitively but emit our own spelling: the platform
|
||||
// list is assembled from MIME strings and its casing is not ours to
|
||||
// forward to the server.
|
||||
WEBVIEW_AUDIO_CODECS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|supported| supported.eq_ignore_ascii_case(codec))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if kept.is_empty() {
|
||||
FALLBACK_AUDIO_CODEC.to_string()
|
||||
} else {
|
||||
kept.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the webview `<video>` element can decode this audio codec.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
WEBVIEW_AUDIO_CODECS
|
||||
.iter()
|
||||
.any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
|
||||
}
|
||||
|
||||
/// Decide whether we must transcode *regardless of what the server negotiated*,
|
||||
/// given the source's audio streams as `(codec, is_default)` in source order.
|
||||
///
|
||||
/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
|
||||
/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
|
||||
/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
|
||||
/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
|
||||
/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
|
||||
/// delegate this decision; it knows what its own renderer can decode and must
|
||||
/// apply that itself.
|
||||
///
|
||||
/// The track that matters is the one the server will actually serve (see
|
||||
/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
|
||||
/// on a guess would burn server CPU for files that play.
|
||||
///
|
||||
/// 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),
|
||||
// No audio at all, or a codec the server did not name: leave it alone.
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec of the audio track the server will actually serve, given the
|
||||
/// source's audio streams as `(codec, is_default)` in source order: the default,
|
||||
/// or the first when none is marked.
|
||||
///
|
||||
/// `None` means "nothing to judge" — no audio streams, or the server named no
|
||||
/// codec for the one it would serve. Both callers of this rule treat that as
|
||||
/// leave-well-alone, never as a licence to assume compatibility.
|
||||
///
|
||||
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
|
||||
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
|
||||
streams
|
||||
.iter()
|
||||
.find(|(_, is_default)| *is_default)
|
||||
.or_else(|| streams.first())
|
||||
.and_then(|(codec, _)| *codec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_default_track_forces_a_transcode() {
|
||||
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||
assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
|
||||
assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decodable_track_is_left_to_direct_play() {
|
||||
// Never spend server CPU on a file that already plays.
|
||||
assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
|
||||
assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_track_decides_not_the_first() {
|
||||
// The webview plays the default track, so that is the one that has to be
|
||||
// decodable — a supported track further down does not save us.
|
||||
assert!(audio_forces_transcode(&[
|
||||
(Some("aac"), false),
|
||||
(Some("eac3"), true)
|
||||
]));
|
||||
assert!(!audio_forces_transcode(&[
|
||||
(Some("eac3"), false),
|
||||
(Some("aac"), true)
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_default_marked_the_first_track_decides() {
|
||||
// Jellyfin leaves IsDefault false on every stream for some files; the
|
||||
// server then serves the first, so judge that one.
|
||||
assert!(audio_forces_transcode(&[
|
||||
(Some("eac3"), false),
|
||||
(Some("aac"), false)
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_with_no_audio_is_not_transcoded() {
|
||||
// Nothing to rescue, and a transcode would not create audio.
|
||||
assert!(!audio_forces_transcode(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_codec_is_not_second_guessed() {
|
||||
// The server did not tell us the codec; assuming the worst would
|
||||
// transcode files that play perfectly.
|
||||
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||
}
|
||||
|
||||
/// The download path needs the codec itself, not just the verdict, so it can
|
||||
/// tell the server what to re-encode. It picks the same track the streaming
|
||||
/// verdict is formed from — one rule, one place.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
|
||||
assert_eq!(
|
||||
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
|
||||
Some("eac3")
|
||||
);
|
||||
assert_eq!(
|
||||
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
|
||||
Some("eac3")
|
||||
);
|
||||
assert_eq!(served_audio_codec(&[]), None);
|
||||
assert_eq!(served_audio_codec(&[(None, true)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||
// E-AC-3 into a webview that cannot decode it — silent video, on that
|
||||
// device only.
|
||||
let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
|
||||
assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codecs_the_webview_cannot_decode_are_dropped() {
|
||||
// AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
|
||||
// problem — it is just rarer content.
|
||||
assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
|
||||
assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_the_webview_fully_supports_is_untouched() {
|
||||
assert_eq!(
|
||||
video_audio_codecs("aac,mp3,opus,vorbis,flac"),
|
||||
"aac,mp3,opus,vorbis,flac"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_decodable_still_claims_aac() {
|
||||
// Claiming an empty list invites the server to give up rather than
|
||||
// transcode. AAC is universally decodable, so ask for it.
|
||||
assert_eq!(video_audio_codecs("eac3,dts"), "aac");
|
||||
assert_eq!(video_audio_codecs(""), "aac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_and_case_in_the_platform_list_are_tolerated() {
|
||||
// The list is assembled from MediaCodecList strings; do not let
|
||||
// whitespace decide whether the user gets sound.
|
||||
assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_route_falls_back_to_stereo() {
|
||||
// Codec detection has not run yet, or the platform has no answer. Never
|
||||
// claim surround we have not seen — every sink can do stereo.
|
||||
assert_eq!(clamp_max_audio_channels(None), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_reading_is_not_a_capability() {
|
||||
// A route that has not been established reports 0; taking that literally
|
||||
// would advertise a device with no audio at all.
|
||||
assert_eq!(clamp_max_audio_channels(Some(0)), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stereo_sink_is_reported_as_stereo() {
|
||||
// The phone speaker / Bluetooth headset case: the server must downmix
|
||||
// 5.1 rather than direct-play it.
|
||||
assert_eq!(clamp_max_audio_channels(Some(2)), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surround_route_keeps_its_channels() {
|
||||
// HDMI to an AVR: 5.1 and 7.1 direct play stay available.
|
||||
assert_eq!(clamp_max_audio_channels(Some(6)), 6);
|
||||
assert_eq!(clamp_max_audio_channels(Some(8)), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurd_reading_is_capped_rather_than_forwarded() {
|
||||
// Some drivers report the AudioTrack maximum rather than the route's.
|
||||
assert_eq!(clamp_max_audio_channels(Some(32)), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mono_route_is_taken_at_its_word() {
|
||||
assert_eq!(clamp_max_audio_channels(Some(1)), 1);
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,49 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
|
||||
/// caller can refresh the cache in the background.
|
||||
///
|
||||
/// A plain cache hit answers from data that may be arbitrarily old, which
|
||||
/// is right for the *response* and wrong for what it leaves behind: per-user
|
||||
/// state (watch positions, favourites) only reaches the local tables when a
|
||||
/// server result is cached, so a surface that always hits cache never learns
|
||||
/// what another device did. `get_items` had a bespoke version of this; this
|
||||
/// is the same idea, reusable.
|
||||
///
|
||||
/// The callback runs only on a cache hit — on a miss the server result is
|
||||
/// already being fetched and cached by the normal path.
|
||||
///
|
||||
/// TRACES: UR-002, UR-025 | DR-155
|
||||
async fn race_with_refresh<T, F1, F2, R>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
on_cache_hit: R,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
R: FnOnce(),
|
||||
{
|
||||
let cache_result = cache_future.await;
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
|
||||
on_cache_hit();
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||
///
|
||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
||||
@@ -489,6 +532,21 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single item, cache-first — and, on a cache hit, refreshed in the
|
||||
/// background so the stored copy keeps up with the server.
|
||||
///
|
||||
/// The background refresh is what carries per-user state home: caching an
|
||||
/// item runs `mirror_user_data`, which is the only path by which a watch
|
||||
/// position set on another device reaches the local `user_data` row the
|
||||
/// resume check reads. Without it a cache hit returned this device's own
|
||||
/// stale position forever and cross-device resume silently did nothing —
|
||||
/// `get_items` already refreshes this way, so browsing a season worked
|
||||
/// while opening the episode directly did not.
|
||||
///
|
||||
/// The refreshed value lands for the *next* read rather than this one: the
|
||||
/// point of the cache-first race is to answer immediately.
|
||||
///
|
||||
/// TRACES: UR-025, UR-002 | DR-155 | UT-152
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -497,9 +555,32 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
||||
|
||||
let online_for_refresh = Arc::clone(&self.online);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
let refresh_id = item_id_clone.clone();
|
||||
let on_cache_hit = move || {
|
||||
tokio::spawn(async move {
|
||||
match online_for_refresh.get_item(&refresh_id).await {
|
||||
Ok(fresh) => {
|
||||
// `save_to_cache` files the row under a parent; the item's
|
||||
// own parent keeps it where a later listing expects it.
|
||||
let parent = fresh
|
||||
.parent_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "item".to_string());
|
||||
if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
|
||||
debug!("[HybridRepo] Background item refresh failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let server_future = async move { online.get_item(&item_id_clone).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
self.race_with_refresh(cache_future, server_future, on_cache_hit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_latest_items(
|
||||
@@ -791,10 +872,11 @@ impl MediaRepository for HybridRepository {
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Always use online URL for downloads
|
||||
self.online
|
||||
.get_video_download_url(item_id, quality, media_source_id)
|
||||
.get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
@@ -812,6 +894,11 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.clear_watch_history(item_id).await
|
||||
}
|
||||
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.mark_played(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -1213,6 +1300,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1237,6 +1325,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1483,6 +1575,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1507,6 +1600,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod device_profile;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
@@ -196,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
|
||||
format: &str,
|
||||
) -> String;
|
||||
|
||||
/// Get video download URL (synchronous - just constructs URL)
|
||||
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
||||
/// Build the URL a video download is fetched from. Synchronous — it only
|
||||
/// constructs a URL, so it stays testable without a server. Reach it through
|
||||
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||
///
|
||||
/// `source_audio_codec` is the codec of the audio track the server would
|
||||
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
|
||||
/// `original` quality it decides whether the file can be copied byte-for-byte
|
||||
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
||||
/// played back with no server in reach, so it has to be decodable *here*.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171
|
||||
#[allow(dead_code)]
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
@@ -234,6 +245,14 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// TRACES: UR-064 | DR-106
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Mark an item played — the inverse of `clear_watch_history`. Needed by the
|
||||
/// sync-queue drain, which replays `mark_played` rows queued while the
|
||||
/// server was unreachable; reporting a stop at a made-up position was the
|
||||
/// previous stand-in and does not set the played flag reliably.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get person details
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
@@ -314,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
/// The audio codec the server would serve for `item_id` — the default track, or
|
||||
/// the first when none is marked, matching the track Jellyfin picks.
|
||||
///
|
||||
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
||||
/// caller must read that as "unknown", never as "fine": it is the input to a
|
||||
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
||||
/// exactly as it was.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
||||
let item = repo.get_item(item_id).await.ok()?;
|
||||
let audio: Vec<(Option<&str>, bool)> = item
|
||||
.media_streams
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|s| s.stream_type == "Audio")
|
||||
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||
.collect();
|
||||
|
||||
device_profile::served_audio_codec(&audio).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Resolve the download URL for a video, applying the audio-codec policy that
|
||||
/// keeps the saved file playable offline (DR-171).
|
||||
///
|
||||
/// Every video download goes through here rather than calling the builder
|
||||
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
||||
/// forgets to is exactly how the silent downloads shipped.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171
|
||||
pub async fn resolve_video_download_url(
|
||||
repo: &dyn MediaRepository,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
let codec = served_audio_codec(repo, item_id).await;
|
||||
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
||||
}
|
||||
|
||||
@@ -665,40 +665,63 @@ impl OfflineRepository {
|
||||
}
|
||||
|
||||
/// Mirror the server's per-user state for an item into the local
|
||||
/// `user_data` table, so favourites marked on any other client are visible
|
||||
/// here — including offline, where the local table is the only source.
|
||||
/// `user_data` table, so favourites marked — and positions watched — on any
|
||||
/// other client are visible here, including offline, where the local table
|
||||
/// is the only source.
|
||||
///
|
||||
/// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
|
||||
/// conflict rule: a toggle made while the server was unreachable is still
|
||||
/// conflict rule: a change made while the server was unreachable is still
|
||||
/// waiting to be pushed, and must not be clobbered by the stale value the
|
||||
/// server is still reporting. Rows carrying no favourite state are skipped
|
||||
/// entirely rather than written as `0`, which would fabricate an
|
||||
/// "unfavourited" record from an endpoint that simply omits `UserData`.
|
||||
/// server is still reporting. For a position that means it is never pulled
|
||||
/// *backwards* by a server that has not yet heard where we got to.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-114 | UT-102
|
||||
/// Each field is mirrored only when the server actually reported it —
|
||||
/// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
|
||||
/// absent, and a row with neither field is skipped outright rather than
|
||||
/// written as zeroes, which would fabricate an "unfavourited, unwatched"
|
||||
/// record from an endpoint that simply omits `UserData`.
|
||||
///
|
||||
/// The position half is what makes cross-device resume work: the resume
|
||||
/// check reads this table alone, so before it was mirrored an item watched
|
||||
/// elsewhere resumed from whatever *this* device last saw, or not at all.
|
||||
///
|
||||
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
|
||||
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
||||
let Some(is_favorite) = item.user_data.as_ref().and_then(|ud| ud.is_favorite) else {
|
||||
let user_data = item.user_data.as_ref();
|
||||
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
|
||||
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
|
||||
|
||||
// Nothing the server actually told us about — do not invent a row.
|
||||
if is_favorite.is_none() && position_ticks.is_none() {
|
||||
return Ok(());
|
||||
};
|
||||
}
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
|
||||
VALUES (?1, ?2, ?3, ?4, 0)
|
||||
"INSERT INTO user_data
|
||||
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_favorite = excluded.is_favorite,
|
||||
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
|
||||
playback_position_ticks = COALESCE(
|
||||
excluded.playback_position_ticks, user_data.playback_position_ticks),
|
||||
synced_at = excluded.synced_at
|
||||
WHERE user_data.pending_sync = 0",
|
||||
vec![
|
||||
QueryParam::String(self.user_id.clone()),
|
||||
QueryParam::String(item.id.clone()),
|
||||
QueryParam::Int(if is_favorite { 1 } else { 0 }),
|
||||
is_favorite
|
||||
.map(|f| QueryParam::Int(if f { 1 } else { 0 }))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
position_ticks
|
||||
.map(QueryParam::Int64)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(now.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
// A missing item row (FK) is not fatal here — the favourite mirror is
|
||||
// best-effort metadata, and failing the whole cache write over it would
|
||||
// break browsing.
|
||||
// A missing item row (FK) is not fatal here — the mirror is best-effort
|
||||
// metadata, and failing the whole cache write over it would break
|
||||
// browsing.
|
||||
if let Err(e) = self.db_service.execute(query).await {
|
||||
debug!(
|
||||
"[OfflineRepo] user_data mirror skipped for {}: {}",
|
||||
@@ -805,6 +828,32 @@ impl OfflineRepository {
|
||||
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
|
||||
/// is authoritative regardless of the process-wide catalog-browse flag.
|
||||
///
|
||||
/// Whether cached item `i` belongs to library `l`, decided by media kind.
|
||||
///
|
||||
/// The cache leaves `library_id`/`parent_id` NULL on every item
|
||||
/// ([[offline-libraries-never-cached]]), so there is no link to follow: a
|
||||
/// library's `collection_type` and an item's `item_type` are the only things
|
||||
/// that can associate them. This is Jellyfin taxonomy and therefore lives in
|
||||
/// Rust, never in the frontend.
|
||||
///
|
||||
/// It is a named constant because it is needed in two places that must agree
|
||||
/// — which library *appears* in the Downloaded list, and which items appear
|
||||
/// *inside* it. They disagreed: the listing query used this mapping while the
|
||||
/// browse query only checked that the requested library existed, so opening
|
||||
/// any library showed every downloaded top-level item on the server.
|
||||
///
|
||||
/// A library of some other (or unknown) type keeps everything, since there is
|
||||
/// no mapping to narrow it by and hiding its contents would be worse.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-167
|
||||
const LIBRARY_HOLDS_ITEM: &'static str = "(
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR l.collection_type IS NULL
|
||||
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
||||
)";
|
||||
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||
WITH downloaded_items AS (
|
||||
@@ -885,6 +934,7 @@ impl OfflineRepository {
|
||||
EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
WHERE l.id = ? AND l.server_id = i.server_id
|
||||
AND {membership}
|
||||
)
|
||||
-- Top-level only: hide leaves whose container is downloaded.
|
||||
AND NOT EXISTS (
|
||||
@@ -899,6 +949,7 @@ impl OfflineRepository {
|
||||
ORDER BY i.sort_name ASC, i.name ASC
|
||||
LIMIT {limit} OFFSET {start_index}",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
);
|
||||
|
||||
let query = Query::with_params(
|
||||
@@ -943,7 +994,7 @@ impl OfflineRepository {
|
||||
// We match a library by collection_type ↔ item_type instead: any
|
||||
// completed download of a given media kind qualifies that library.
|
||||
let query = Query::with_params(
|
||||
&format!(
|
||||
format!(
|
||||
"{cte}
|
||||
SELECT l.id, l.name, l.collection_type, l.image_tag
|
||||
FROM libraries l
|
||||
@@ -952,15 +1003,11 @@ impl OfflineRepository {
|
||||
SELECT 1 FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = l.server_id
|
||||
AND (
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
|
||||
)
|
||||
AND {membership}
|
||||
)
|
||||
ORDER BY l.sort_order ASC, l.name ASC",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
),
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
@@ -1426,6 +1473,14 @@ impl MediaRepository for OfflineRepository {
|
||||
FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ? AND i.library_id = ?
|
||||
-- Collapse leaves into the container that was added: a new
|
||||
-- 14-track album should read as one album, not 14 songs. Only
|
||||
-- drops a leaf when its own container is present in the same
|
||||
-- result, so a standalone track or movie still appears.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM downloaded_items parent
|
||||
WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
|
||||
)
|
||||
ORDER BY i.synced_at DESC
|
||||
LIMIT {}", limit_val
|
||||
),
|
||||
@@ -1928,6 +1983,7 @@ impl MediaRepository for OfflineRepository {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Cannot download while offline
|
||||
String::new()
|
||||
@@ -2050,6 +2106,12 @@ impl MediaRepository for OfflineRepository {
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
// Offline the local flag is written by `storage_mark_played` and the
|
||||
// server half is queued in `sync_queue`; this path has no server.
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let query = Query::with_params(
|
||||
"SELECT id, name, overview, primary_image_tag
|
||||
@@ -3538,6 +3600,32 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Like `insert_item`, but sets `library_id` — which `get_latest_items`
|
||||
/// filters on, so rows without it are invisible to that query.
|
||||
async fn insert_library_item(
|
||||
db: &Arc<RusqliteService>,
|
||||
id: &str,
|
||||
item_type: &str,
|
||||
library_id: &str,
|
||||
album_id: Option<&str>,
|
||||
) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
|
||||
VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
|
||||
vec![
|
||||
QueryParam::String(id.to_string()),
|
||||
QueryParam::String(library_id.to_string()),
|
||||
QueryParam::String(format!("Name {id}")),
|
||||
QueryParam::String(item_type.to_string()),
|
||||
album_id
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
|
||||
@@ -3572,6 +3660,36 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// A newly-synced album appears once in "recently added", not once per track.
|
||||
///
|
||||
/// The downloaded-items CTE deliberately matches both the leaves and their
|
||||
/// container, which is right for browsing but wrong here: it made a 3-track
|
||||
/// album occupy 4 slots in the row. Tracks whose album is itself in the
|
||||
/// result are now collapsed into it.
|
||||
#[tokio::test]
|
||||
async fn test_get_latest_items_collapses_tracks_into_their_album() {
|
||||
let db = create_test_db();
|
||||
insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
|
||||
for track in ["track-1", "track-2", "track-3"] {
|
||||
insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
|
||||
seed_completed_download(&db, track, 1000).await;
|
||||
}
|
||||
// A movie has no container, so it must still show up on its own.
|
||||
insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
|
||||
seed_completed_download(&db, "movie-1", 2000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
|
||||
let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
|
||||
|
||||
assert!(
|
||||
!ids.iter().any(|id| id.starts_with("track-")),
|
||||
"individual tracks must collapse into their album, got: {ids:?}"
|
||||
);
|
||||
assert!(ids.contains(&"album-1"), "the album itself is listed");
|
||||
assert!(ids.contains(&"movie-1"), "containerless items still listed");
|
||||
}
|
||||
|
||||
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
|
||||
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
|
||||
///
|
||||
@@ -3627,6 +3745,82 @@ mod tests {
|
||||
assert_eq!(track_ids, vec!["track-1", "track-2"]);
|
||||
}
|
||||
|
||||
/// Regression: each downloaded library shows **only its own media**.
|
||||
///
|
||||
/// Cached items carry no link back to their library (`library_id`/`parent_id`
|
||||
/// are NULL — [[offline-libraries-never-cached]]), and the library branch of
|
||||
/// the query only asserted that the requested library *exists*, never that
|
||||
/// the item belongs to it. So opening any downloaded library listed every
|
||||
/// downloaded top-level item on the server: films in the music library,
|
||||
/// albums under TV. The library's `collection_type` decides which item types
|
||||
/// belong to it, the same mapping `get_downloaded_libraries` already uses.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-167 | UT-162
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_library_does_not_mix_media_types() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "music-lib", "music").await;
|
||||
seed_library(&db, "movie-lib", "movies").await;
|
||||
seed_library(&db, "tv-lib", "tvshows").await;
|
||||
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||
insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
|
||||
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
seed_completed_download(&db, "movie-1", 2000).await;
|
||||
seed_completed_download(&db, "episode-1", 3000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
let music: Vec<String> = repo
|
||||
.get_downloaded_items("music-lib", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
music,
|
||||
vec!["album-1"],
|
||||
"the music library must not list films or series; got {:?}",
|
||||
music
|
||||
);
|
||||
|
||||
let movies: Vec<String> = repo
|
||||
.get_downloaded_items("movie-lib", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
movies,
|
||||
vec!["movie-1"],
|
||||
"the movie library must not list albums or series; got {:?}",
|
||||
movies
|
||||
);
|
||||
|
||||
let tv: Vec<String> = repo
|
||||
.get_downloaded_items("tv-lib", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
tv,
|
||||
vec!["series-1"],
|
||||
"the TV library must not list albums or films; got {:?}",
|
||||
tv
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a downloaded TV library lists the Series, not its Seasons or
|
||||
/// Episodes — the same "individual songs" bug seen for music, for TV. The
|
||||
/// season and episode are still reachable by drilling into the series.
|
||||
@@ -4368,4 +4562,142 @@ mod tests {
|
||||
"an unsynced local toggle must survive a cache write"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-152 — the server's watch position is mirrored locally, so an item
|
||||
/// watched on another device resumes here.
|
||||
///
|
||||
/// The resume check reads only the local `user_data` row, and the mirror
|
||||
/// previously carried `is_favorite` alone — so a position set on any other
|
||||
/// client never reached this device and cross-device resume silently did
|
||||
/// nothing. The `pending_sync` guard is the same conflict rule favourites
|
||||
/// use: a local position still waiting to be pushed must not be pulled
|
||||
/// backwards by the stale value the server is still reporting.
|
||||
///
|
||||
/// TRACES: UR-025, UR-069 | DR-155 | UT-152
|
||||
#[tokio::test]
|
||||
async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
|
||||
use crate::storage::db_service::DatabaseService;
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let position = |id: &'static str| {
|
||||
let db = db_service.clone();
|
||||
async move {
|
||||
db.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT playback_position_ticks, pending_sync FROM user_data \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String(id.to_string()),
|
||||
],
|
||||
),
|
||||
|row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// Watched 20 minutes into this episode on another device.
|
||||
let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
|
||||
watched.user_data = Some(UserData {
|
||||
playback_position_ticks: Some(12_000_000_000),
|
||||
..Default::default()
|
||||
});
|
||||
// No user data at all — must not fabricate a position of 0.
|
||||
let untouched = create_test_item("ep-2", "No User Data", None);
|
||||
|
||||
repo.save_to_cache("parent-1", &[watched.clone(), untouched])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
position("ep-1").await,
|
||||
Some((Some(12_000_000_000), Some(0))),
|
||||
"the server's position should be mirrored as synced"
|
||||
);
|
||||
assert_eq!(
|
||||
position("ep-2").await,
|
||||
None,
|
||||
"an item without UserData should not get an invented position"
|
||||
);
|
||||
|
||||
// Watched further here while the server was unreachable: pending_sync = 1.
|
||||
db_service
|
||||
.execute(Query::with_params(
|
||||
"UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::Int64(30_000_000_000),
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String("ep-1".to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The server still reports the older position; caching must not win.
|
||||
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
position("ep-1").await,
|
||||
Some((Some(30_000_000_000), Some(1))),
|
||||
"an unsynced local position must not be pulled backwards"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-152 — a server item carrying *only* a position (no favourite flag)
|
||||
/// still gets mirrored.
|
||||
///
|
||||
/// The mirror used to return early whenever `is_favorite` was absent, which
|
||||
/// is exactly the shape of an ordinary watched episode: Jellyfin reports
|
||||
/// `PlaybackPositionTicks` with no favourite state. That early return is why
|
||||
/// the position never landed.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-155 | UT-152
|
||||
#[tokio::test]
|
||||
async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
|
||||
use crate::storage::db_service::DatabaseService;
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let mut watched = create_test_item("ep-3", "Position Only", None);
|
||||
watched.user_data = Some(UserData {
|
||||
is_favorite: None,
|
||||
playback_position_ticks: Some(9_000_000_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
|
||||
|
||||
let stored = db_service
|
||||
.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT playback_position_ticks FROM user_data \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String("ep-3".to_string()),
|
||||
],
|
||||
),
|
||||
|row| row.get::<_, Option<i64>>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
stored,
|
||||
Some(Some(9_000_000_000)),
|
||||
"a position with no favourite flag must still be mirrored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
||||
|
||||
use async_trait::async_trait;
|
||||
#[cfg(target_os = "android")]
|
||||
use log::warn;
|
||||
use log::{debug, error, info};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::{types::*, MediaRepository};
|
||||
use crate::connectivity::ConnectivityReporter;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::settings::StreamingQuality;
|
||||
use crate::utils::lock::RwLockSafe;
|
||||
|
||||
/// The bandwidth ceiling every video stream this process opens is built against.
|
||||
///
|
||||
/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
|
||||
/// preference about *this device's connection*, not about a server session: it
|
||||
/// must survive a repository being rebuilt on re-login, and every URL builder and
|
||||
/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
|
||||
/// negotiation would authorise a direct play the URL builder then never gets to
|
||||
/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
|
||||
///
|
||||
/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
|
||||
/// restored from the database at startup.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
|
||||
|
||||
/// Apply a bandwidth ceiling to every subsequently-opened video stream.
|
||||
///
|
||||
/// Streams already playing keep the bitrate they were opened at — a cap is a
|
||||
/// property of the URL the server is transcoding for, so changing it mid-stream
|
||||
/// requires re-opening at the new quality (`player_set_stream_quality`).
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub fn set_streaming_quality(quality: StreamingQuality) {
|
||||
*STREAMING_QUALITY.write_safe() = quality;
|
||||
}
|
||||
|
||||
/// The ceiling currently applied to new video streams.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub fn streaming_quality() -> StreamingQuality {
|
||||
*STREAMING_QUALITY.read_safe()
|
||||
}
|
||||
|
||||
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
|
||||
///
|
||||
@@ -382,6 +415,13 @@ impl OnlineRepository {
|
||||
/// forces the server to transcode the whole file before playback can begin —
|
||||
/// which manifests as playback never starting. `StartTimeTicks` makes the
|
||||
/// server begin the transcode at the requested position.
|
||||
///
|
||||
/// The stream is built against the current [`streaming_quality`] ceiling:
|
||||
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
|
||||
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
|
||||
/// allowance, which is a transcode ceiling rather than a user-facing limit.
|
||||
///
|
||||
/// TRACES: UR-004, UR-074 | DR-140, DR-162 | UT-130, UT-156
|
||||
pub async fn get_video_stream_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
@@ -392,8 +432,12 @@ impl OnlineRepository {
|
||||
// Convert seconds to ticks (10,000,000 ticks per second)
|
||||
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
|
||||
|
||||
// Use provided audio stream index, or default to 0
|
||||
let audio_index = audio_stream_index.unwrap_or(0).to_string();
|
||||
let quality = streaming_quality();
|
||||
// `Original` is uncapped as a *user* setting, but a transcode still needs
|
||||
// a ceiling to encode against — keep the values this endpoint has always
|
||||
// used so nothing changes for the default.
|
||||
let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
|
||||
let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
|
||||
|
||||
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
|
||||
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
|
||||
@@ -402,16 +446,34 @@ impl OnlineRepository {
|
||||
("DeviceId", "jellytau-tauri".to_string()),
|
||||
("VideoCodec", "h264".to_string()),
|
||||
("AudioCodec", "aac".to_string()),
|
||||
("AudioStreamIndex", audio_index),
|
||||
("MaxStreamingBitrate", "20000000".to_string()),
|
||||
("VideoBitrate", "18000000".to_string()),
|
||||
("AudioBitrate", "384000".to_string()),
|
||||
("TranscodingMaxAudioChannels", "2".to_string()),
|
||||
("MaxStreamingBitrate", max_bitrate.to_string()),
|
||||
("VideoBitrate", video_bitrate.to_string()),
|
||||
("AudioBitrate", quality.audio_bitrate().to_string()),
|
||||
(
|
||||
"TranscodingMaxAudioChannels",
|
||||
super::device_profile::max_audio_channels().to_string(),
|
||||
),
|
||||
("SegmentContainer", "ts".to_string()),
|
||||
("TranscodingContainer", "ts".to_string()),
|
||||
("TranscodingProtocol", "hls".to_string()),
|
||||
];
|
||||
|
||||
// Scale the picture down to what the budget can carry. Omitted for the
|
||||
// uncapped steps so the source resolution is preserved.
|
||||
if let Some(height) = quality.max_height() {
|
||||
params.push(("MaxHeight", height.to_string()));
|
||||
}
|
||||
|
||||
// Only pin an audio track when the user actually picked one. Jellyfin's
|
||||
// `MediaStream.Index` is global across *all* streams in a media source, so
|
||||
// index 0 is the video stream on virtually every file — defaulting to 0
|
||||
// asks the server to transcode the video stream as the audio track, which
|
||||
// yields a picture with no sound. Omitting the param lets the server use
|
||||
// the source's `DefaultAudioStreamIndex`.
|
||||
if let Some(index) = audio_stream_index {
|
||||
params.push(("AudioStreamIndex", index.to_string()));
|
||||
}
|
||||
|
||||
if let Some(source_id) = media_source_id {
|
||||
params.push(("MediaSourceId", source_id.to_string()));
|
||||
}
|
||||
@@ -438,7 +500,7 @@ impl OnlineRepository {
|
||||
/// Get an **audio-only** stream URL for a *video* item, for the
|
||||
/// background-audio handoff (UR-040).
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032 | UT-059
|
||||
/// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
|
||||
///
|
||||
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
|
||||
/// the server extracts/transcodes only the item's audio track and streams
|
||||
@@ -463,21 +525,31 @@ impl OnlineRepository {
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
let audio_index = audio_stream_index.unwrap_or(0).to_string();
|
||||
|
||||
let mut params = vec![
|
||||
("UserId", self.user_id.clone()),
|
||||
("api_key", self.access_token.clone()),
|
||||
("DeviceId", "jellytau-tauri".to_string()),
|
||||
("AudioStreamIndex", audio_index),
|
||||
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
||||
("Container", "mp3".to_string()),
|
||||
("AudioCodec", "mp3".to_string()),
|
||||
("TranscodingContainer", "mp3".to_string()),
|
||||
("TranscodingProtocol", "http".to_string()),
|
||||
("MaxStreamingBitrate", "384000".to_string()),
|
||||
// Audio-only is already far under any video cap, but a user on the
|
||||
// bottom rungs of the ladder asked for *less traffic*, so take the
|
||||
// lower of the two rather than always 384 kbps.
|
||||
// TRACES: UR-074 | DR-162
|
||||
(
|
||||
"MaxStreamingBitrate",
|
||||
streaming_quality().audio_bitrate().min(384_000).to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
// Carry the track over only if one was actually selected — index 0 is the
|
||||
// video stream, not "the first audio track" (see `get_video_stream_url`).
|
||||
if let Some(index) = audio_stream_index {
|
||||
params.push(("AudioStreamIndex", index.to_string()));
|
||||
}
|
||||
|
||||
if let Some(source_id) = media_source_id {
|
||||
params.push(("MediaSourceId", source_id.to_string()));
|
||||
}
|
||||
@@ -658,6 +730,26 @@ fn build_get_items_endpoint(
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a "recently added" listing.
|
||||
///
|
||||
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
|
||||
/// `false`, which returns each newly-added *leaf* separately, so importing one
|
||||
/// 14-track album pushed 14 rows into "recently added" and buried everything
|
||||
/// else. With grouping on, the server collapses children into the container
|
||||
/// that was added — an album appears once, while movies (which have no such
|
||||
/// container) are unaffected.
|
||||
///
|
||||
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||
format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
user_id,
|
||||
parent_id,
|
||||
limit.unwrap_or(16)
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a favourites listing.
|
||||
///
|
||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
||||
@@ -896,11 +988,7 @@ impl MediaRepository for OnlineRepository {
|
||||
parent_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, parent_id, limit_str
|
||||
);
|
||||
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
||||
|
||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||
Ok(items
|
||||
@@ -1239,7 +1327,11 @@ impl MediaRepository for OnlineRepository {
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct PlaybackInfoRequest {
|
||||
user_id: String,
|
||||
audio_stream_index: i32,
|
||||
/// Omitted so the server resolves the source's default audio stream.
|
||||
/// Never send 0 here: the index is global across all streams, so 0 is
|
||||
/// the video stream and the negotiated source comes back soundless.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
audio_stream_index: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
subtitle_stream_index: Option<i32>,
|
||||
start_time_ticks: i64,
|
||||
@@ -1256,6 +1348,10 @@ impl MediaRepository for OnlineRepository {
|
||||
name: String,
|
||||
max_streaming_bitrate: i64,
|
||||
max_static_bitrate: i64,
|
||||
/// Channels the device's audio route can actually voice. Without it
|
||||
/// the server may direct-play a 5.1 track to a two-channel sink,
|
||||
/// which is silence or inaudible dialogue depending on the device.
|
||||
max_audio_channels: String,
|
||||
direct_play_profiles: Vec<DirectPlayProfile>,
|
||||
transcoding_profiles: Vec<TranscodingProfile>,
|
||||
subtitle_profiles: Vec<SubtitleProfile>,
|
||||
@@ -1283,6 +1379,7 @@ impl MediaRepository for OnlineRepository {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
video_codec: Option<String>,
|
||||
audio_codec: String,
|
||||
max_audio_channels: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1319,12 +1416,16 @@ impl MediaRepository for OnlineRepository {
|
||||
index: i32,
|
||||
#[serde(default)]
|
||||
codec: Option<String>,
|
||||
/// The track the server serves when the client pins none.
|
||||
#[serde(default)]
|
||||
is_default: bool,
|
||||
}
|
||||
|
||||
// Get detected codecs from Android MediaCodecList or use platform defaults
|
||||
#[cfg(target_os = "android")]
|
||||
let (video_codecs, audio_codecs) =
|
||||
crate::player::get_detected_codecs().unwrap_or_else(|| {
|
||||
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
|
||||
.map(|(video, audio, _channels)| (video, audio))
|
||||
.unwrap_or_else(|| {
|
||||
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
|
||||
("h264,hevc".to_string(), "aac,mp3".to_string())
|
||||
});
|
||||
@@ -1332,8 +1433,9 @@ impl MediaRepository for OnlineRepository {
|
||||
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
|
||||
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
|
||||
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
|
||||
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
|
||||
// profile is shared, so we keep the broadly-supported audio codecs.)
|
||||
// (Audio-only files still direct-play via MPV; these codecs are what
|
||||
// both renderers handle, and the audio profile keeps them in full while
|
||||
// the video profile is narrowed below.)
|
||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||
let (video_codecs, audio_codecs) =
|
||||
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
|
||||
@@ -1344,25 +1446,65 @@ impl MediaRepository for OnlineRepository {
|
||||
"aac,mp3,opus,vorbis,flac".to_string(),
|
||||
);
|
||||
|
||||
// Video plays in a webview <video> element on every platform, which
|
||||
// decodes a narrower audio set than the platform does — so the video
|
||||
// profile must claim less than the audio-only profile. Without this a
|
||||
// Dolby-licensed device advertises eac3, gets a direct play, and shows
|
||||
// picture with no sound.
|
||||
let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
|
||||
|
||||
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
|
||||
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
|
||||
info!(
|
||||
"[DeviceProfile] Audio codecs for video direct play: {}",
|
||||
video_audio_codecs
|
||||
);
|
||||
|
||||
// Bound every profile by what the audio route can actually voice, so a
|
||||
// multichannel track is downmixed by the server rather than direct-played
|
||||
// into a sink that has nowhere to put the extra channels.
|
||||
let max_audio_channels = super::device_profile::max_audio_channels().to_string();
|
||||
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
|
||||
|
||||
// The user's bandwidth ceiling has to be part of the *negotiation*, not
|
||||
// just the transcode URL: `max_static_bitrate` is what makes the server
|
||||
// refuse to direct-play a source fatter than the cap, and without it a
|
||||
// 30 Mbps remux is handed over untouched and every URL parameter
|
||||
// downstream is moot. `Original` keeps the historical "no ceiling"
|
||||
// sentinel so the default path negotiates exactly as before.
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
let quality = streaming_quality();
|
||||
let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
|
||||
if let Some(cap) = quality.max_bitrate() {
|
||||
info!(
|
||||
"[DeviceProfile] Streaming quality cap active: {} ({} bps)",
|
||||
quality.label(),
|
||||
cap
|
||||
);
|
||||
}
|
||||
|
||||
// Create device profile with detected hardware capabilities
|
||||
let device_profile = DeviceProfile {
|
||||
name: "JellyTau Native Player".to_string(),
|
||||
max_streaming_bitrate: 999_999_999,
|
||||
max_static_bitrate: 999_999_999,
|
||||
max_streaming_bitrate: negotiated_bitrate,
|
||||
max_static_bitrate: negotiated_bitrate,
|
||||
max_audio_channels: max_audio_channels.clone(),
|
||||
direct_play_profiles: vec![
|
||||
DirectPlayProfile {
|
||||
profile_type: "Video".to_string(),
|
||||
container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
|
||||
video_codec: Some(video_codecs.clone()),
|
||||
audio_codec: audio_codecs.clone(),
|
||||
// The webview decodes this stream, not ExoPlayer/MPV.
|
||||
audio_codec: video_audio_codecs.clone(),
|
||||
},
|
||||
DirectPlayProfile {
|
||||
profile_type: "Audio".to_string(),
|
||||
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
|
||||
video_codec: None,
|
||||
// Audio-only really is the native player's, so it keeps the
|
||||
// full platform list — narrowing it would transcode music
|
||||
// that plays perfectly well.
|
||||
audio_codec: audio_codecs.clone(),
|
||||
},
|
||||
],
|
||||
@@ -1374,6 +1516,7 @@ impl MediaRepository for OnlineRepository {
|
||||
container: "ts".to_string(),
|
||||
video_codec: Some("h264,hevc".to_string()),
|
||||
audio_codec: "aac,mp3".to_string(),
|
||||
max_audio_channels: max_audio_channels.clone(),
|
||||
},
|
||||
TranscodingProfile {
|
||||
profile_type: "Audio".to_string(),
|
||||
@@ -1382,6 +1525,7 @@ impl MediaRepository for OnlineRepository {
|
||||
container: "mp3".to_string(),
|
||||
video_codec: None,
|
||||
audio_codec: "mp3".to_string(),
|
||||
max_audio_channels: max_audio_channels.clone(),
|
||||
},
|
||||
],
|
||||
subtitle_profiles: vec![
|
||||
@@ -1399,12 +1543,13 @@ impl MediaRepository for OnlineRepository {
|
||||
// POST to PlaybackInfo with device profile containing detected codecs
|
||||
let request_body = PlaybackInfoRequest {
|
||||
user_id: self.user_id.clone(),
|
||||
audio_stream_index: 0, // Request first audio stream
|
||||
audio_stream_index: None, // Let the server pick the source default
|
||||
subtitle_stream_index: None,
|
||||
start_time_ticks: 0,
|
||||
is_playback: true,
|
||||
auto_open_live_stream: true,
|
||||
max_streaming_bitrate: 20_000_000, // 20 Mbps
|
||||
// The user's cap, or the historical 20 Mbps allowance when uncapped.
|
||||
max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
|
||||
device_profile: Some(device_profile), // Now sending profile with detected codecs
|
||||
};
|
||||
|
||||
@@ -1426,13 +1571,35 @@ impl MediaRepository for OnlineRepository {
|
||||
);
|
||||
}
|
||||
|
||||
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
|
||||
// but ignores its audio codec, so it offers an E-AC-3 track for direct
|
||||
// play even though DR-148 advertises only AAC — and the webview renders
|
||||
// the picture in silence. Judge the track we would actually be served
|
||||
// against what the webview can decode, and override the server's answer.
|
||||
let audio_streams: Vec<(Option<&str>, bool)> = source
|
||||
.media_streams
|
||||
.iter()
|
||||
.filter(|stream| stream.stream_type == "Audio")
|
||||
.map(|stream| (stream.codec.as_deref(), stream.is_default))
|
||||
.collect();
|
||||
let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
|
||||
|
||||
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
||||
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
||||
format!("{}{}", self.server_url, transcoding_url)
|
||||
} else if audio_forces_transcode {
|
||||
warn!(
|
||||
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
|
||||
audio_streams.first().and_then(|(codec, _)| *codec)
|
||||
);
|
||||
self.get_video_stream_url(item_id, Some(&source.id), None, None)
|
||||
.await?
|
||||
} else {
|
||||
// Fall back to direct stream URL
|
||||
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
||||
// serves the original file untouched, and pinning index 0 (the video
|
||||
// stream) only misleads servers that do honour it.
|
||||
format!(
|
||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&audioStreamIndex=0&userId={}",
|
||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
source.id,
|
||||
@@ -1447,8 +1614,9 @@ impl MediaRepository for OnlineRepository {
|
||||
media_source_id: source.id.clone(),
|
||||
play_session_id: response.play_session_id,
|
||||
stream_url,
|
||||
direct_play: source.supports_direct_play,
|
||||
needs_transcoding: !source.supports_direct_play && source.supports_transcoding,
|
||||
direct_play: source.supports_direct_play && !audio_forces_transcode,
|
||||
needs_transcoding: audio_forces_transcode
|
||||
|| (!source.supports_direct_play && source.supports_transcoding),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1544,7 +1712,10 @@ impl MediaRepository for OnlineRepository {
|
||||
user_id: self.user_id.clone(),
|
||||
auto_open_live_stream: true,
|
||||
is_playback: true,
|
||||
max_streaming_bitrate: 20_000_000,
|
||||
// Live TV is video like any other, so the user's cap applies here
|
||||
// too — a channel opened at the source bitrate would walk straight
|
||||
// past a limit set for the connection. TRACES: UR-074 | DR-162
|
||||
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
|
||||
};
|
||||
|
||||
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
|
||||
@@ -1699,11 +1870,13 @@ impl MediaRepository for OnlineRepository {
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-123
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
||||
// available (returns 404 on many server configs), which silently broke
|
||||
@@ -1716,32 +1889,77 @@ impl MediaRepository for OnlineRepository {
|
||||
// Map the frontend quality preset to concrete transcode params. For
|
||||
// "original" we request a direct static copy (no transcode) which is
|
||||
// byte-range resumable; other presets ask the server to transcode.
|
||||
//
|
||||
// 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
|
||||
// query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
|
||||
// free, but `videoBitrate` (lowercase r) is a *different token*: it
|
||||
// fails to bind, is silently dropped, and the requested cap vanishes
|
||||
// with no error. That is why every "480p"/"720p" download came back at
|
||||
// full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
|
||||
//
|
||||
// `allowVideoStreamCopy=false` forces a real re-encode. Without it the
|
||||
// server may stream-copy the source when it already satisfies the cap —
|
||||
// fine in itself, but it also means a mis-typed cap degrades silently.
|
||||
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
|
||||
// video copy is gated by `allowVideoStreamCopy`.
|
||||
match quality {
|
||||
"high" => {
|
||||
params.push("videoBitrate=8000000".to_string());
|
||||
params.push("videoBitRate=8000000".to_string());
|
||||
params.push("maxHeight=1080".to_string());
|
||||
params.push("audioBitrate=384000".to_string());
|
||||
params.push("audioBitRate=384000".to_string());
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
"medium" => {
|
||||
params.push("videoBitrate=4000000".to_string());
|
||||
params.push("videoBitRate=4000000".to_string());
|
||||
params.push("maxHeight=720".to_string());
|
||||
params.push("audioBitrate=256000".to_string());
|
||||
params.push("audioBitRate=256000".to_string());
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
"low" => {
|
||||
params.push("videoBitrate=1500000".to_string());
|
||||
params.push("videoBitRate=1500000".to_string());
|
||||
params.push("maxHeight=480".to_string());
|
||||
params.push("audioBitrate=128000".to_string());
|
||||
params.push("audioBitRate=128000".to_string());
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy.
|
||||
_ => {
|
||||
params.push("Static=true".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy —
|
||||
// unless the audio in that copy is undecodable where the file will
|
||||
// be played back. A download is watched with no server in reach, so
|
||||
// it has to satisfy the same constraint DR-149 applies to streams:
|
||||
// the webview `<video>` element renders video on both platforms and
|
||||
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
||||
// disk is what made a downloaded film play offline as picture with
|
||||
// no sound while the same film had sound when streamed.
|
||||
//
|
||||
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
||||
// h264 source's picture byte-for-byte, so "original" still means
|
||||
// original quality, and no bitrate or resolution cap is added. A
|
||||
// source the webview could not have rendered anyway (HEVC) is
|
||||
// re-encoded to h264 as a side effect, which is the only form of it
|
||||
// that would have played.
|
||||
//
|
||||
// The cost of the transcode is that the response is no longer
|
||||
// range-resumable, which is exactly why this is decided per item
|
||||
// rather than applied to every `original` download.
|
||||
//
|
||||
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
_ => match source_audio_codec {
|
||||
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("allowVideoStreamCopy=true".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("audioBitRate=384000".to_string());
|
||||
}
|
||||
// Decodable, or unknown: an unknown codec must not provoke a
|
||||
// transcode — that would burn server CPU on a guess for files
|
||||
// that play perfectly well.
|
||||
_ => params.push("Static=true".to_string()),
|
||||
},
|
||||
}
|
||||
|
||||
// Add media source ID if provided
|
||||
@@ -1858,6 +2076,48 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
|
||||
/// `clear_watch_history`.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Content-Length", "0")
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
self.report_outcome(&result).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
@@ -2085,6 +2345,7 @@ impl MediaRepository for OnlineRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn create_test_repository() -> OnlineRepository {
|
||||
@@ -2231,11 +2492,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialises every test whose expectations depend on the process-wide
|
||||
/// streaming ceiling, and restores the uncapped default afterwards — without
|
||||
/// it, a capped test running concurrently changes what an uncapped one sees.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
||||
|
||||
impl QualityFixture {
|
||||
fn set(quality: StreamingQuality) -> Self {
|
||||
let guard = QUALITY_LOCK.lock_safe();
|
||||
set_streaming_quality(quality);
|
||||
Self(guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QualityFixture {
|
||||
fn drop(&mut self) {
|
||||
set_streaming_quality(StreamingQuality::Original);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cap has to reach the transcode URL as all four of its parts: the total
|
||||
/// ceiling, the split between video and audio, and the resolution the budget
|
||||
/// can carry. Capping only `MaxStreamingBitrate` would leave the server
|
||||
/// encoding 1080p into 2 Mbps.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_video_stream_url_applies_bitrate_cap() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_video_stream_url("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
|
||||
// 2 Mbps total less the 192 kbps audio share — the two must not sum to
|
||||
// more than the cap the user asked for.
|
||||
assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
|
||||
assert!(url.contains("AudioBitrate=192000"), "url: {url}");
|
||||
assert!(url.contains("MaxHeight=720"), "url: {url}");
|
||||
}
|
||||
|
||||
/// The uncapped default must keep the exact transcode allowance this
|
||||
/// endpoint has always used, and must not start constraining resolution.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_video_stream_url("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
|
||||
assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
|
||||
assert!(url.contains("AudioBitrate=384000"), "url: {url}");
|
||||
assert!(
|
||||
!url.contains("MaxHeight"),
|
||||
"uncapped must not scale the picture down: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The background-audio handoff is already cheap, but someone who capped the
|
||||
/// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
|
||||
///
|
||||
/// TRACES: UR-040, UR-074 | DR-162 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
|
||||
{
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
|
||||
let repo = create_test_repository();
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
|
||||
}
|
||||
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_video_stream_url_returns_hls_with_position() {
|
||||
// Transcoded video resume/seek must produce an HLS master playlist with
|
||||
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
|
||||
// for HEVC sources). See get_video_stream_url docs.
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
@@ -2257,6 +2614,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_video_stream_url_omits_position_when_absent() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
@@ -2267,8 +2625,14 @@ mod tests {
|
||||
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
|
||||
assert!(!url.contains("StartTimeTicks"));
|
||||
assert!(!url.contains("MediaSourceId"));
|
||||
// Defaults to first audio stream
|
||||
assert!(url.contains("AudioStreamIndex=0"));
|
||||
// With no track chosen, the param must be OMITTED so the server picks the
|
||||
// source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
|
||||
// all streams of a source, so index 0 is the *video* stream on virtually
|
||||
// every file — sending it asks for a "audio track" that has no audio.
|
||||
assert!(
|
||||
!url.contains("AudioStreamIndex"),
|
||||
"must not pin an audio index when none was chosen: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2325,8 +2689,12 @@ mod tests {
|
||||
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
|
||||
assert!(!url.contains("StartTimeTicks"));
|
||||
assert!(!url.contains("MediaSourceId"));
|
||||
// Defaults to first audio stream.
|
||||
assert!(url.contains("AudioStreamIndex=0"));
|
||||
// Same as the video path: omit rather than pin index 0 (the video stream),
|
||||
// and let the server fall back to the source's default audio stream.
|
||||
assert!(
|
||||
!url.contains("AudioStreamIndex"),
|
||||
"must not pin an audio index when none was chosen: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2399,7 +2767,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
||||
let repo = create_test_repository();
|
||||
let url = repo.get_video_download_url("item123", "original", None);
|
||||
let url = repo.get_video_download_url("item123", "original", None, None);
|
||||
|
||||
// Must NOT use the /download endpoint (404 on real servers).
|
||||
assert!(
|
||||
@@ -2417,13 +2785,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_video_download_url_original_is_static_direct_copy() {
|
||||
let repo = create_test_repository();
|
||||
let url = repo.get_video_download_url("item123", "original", None);
|
||||
let url = repo.get_video_download_url("item123", "original", None, None);
|
||||
|
||||
// "original" must request a direct static copy (byte-range resumable),
|
||||
// with no transcode params.
|
||||
assert!(url.contains("Static=true"), "url: {url}");
|
||||
assert!(
|
||||
!url.contains("videoBitrate"),
|
||||
!url.contains("videoBitRate"),
|
||||
"original must not transcode: {url}"
|
||||
);
|
||||
assert!(
|
||||
@@ -2437,13 +2805,13 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
|
||||
let url = repo.get_video_download_url("item123", quality, None);
|
||||
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||
assert!(
|
||||
url.contains("/Videos/item123/stream.mp4"),
|
||||
"{quality} must use stream.mp4: {url}"
|
||||
);
|
||||
assert!(
|
||||
url.contains("videoBitrate="),
|
||||
url.contains("videoBitRate="),
|
||||
"{quality} must set bitrate: {url}"
|
||||
);
|
||||
assert!(
|
||||
@@ -2459,10 +2827,152 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
|
||||
/// R**. Jellyfin binds query keys case-insensitively, so this is not a
|
||||
/// casing preference: `videoBitrate` is a *different token* that fails to
|
||||
/// bind and is silently discarded, taking the user's quality cap with it.
|
||||
/// Nothing errors — the download just returns the full-size original, which
|
||||
/// is exactly how this bug went unnoticed.
|
||||
#[test]
|
||||
fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for quality in ["high", "medium", "low"] {
|
||||
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||
|
||||
assert!(
|
||||
url.contains("videoBitRate="),
|
||||
"{quality} must spell it videoBitRate (capital R): {url}"
|
||||
);
|
||||
assert!(
|
||||
url.contains("audioBitRate="),
|
||||
"{quality} must spell it audioBitRate (capital R): {url}"
|
||||
);
|
||||
|
||||
// The lowercase-r spellings never bind — they must not appear at
|
||||
// all, or the cap is silently dropped by the server.
|
||||
assert!(
|
||||
!url.contains("videoBitrate="),
|
||||
"{quality} emits the unbindable lowercase-r spelling: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("audioBitrate="),
|
||||
"{quality} emits the unbindable lowercase-r spelling: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A correctly-spelled cap is still only *conditionally* honored: the server
|
||||
/// may stream-copy the source when it already satisfies the cap. Video copy
|
||||
/// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
|
||||
/// only governs audio), so the transcode presets must disable it to
|
||||
/// guarantee a real re-encode at the requested bitrate.
|
||||
#[test]
|
||||
fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for quality in ["high", "medium", "low"] {
|
||||
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||
assert!(
|
||||
url.contains("allowVideoStreamCopy=false"),
|
||||
"{quality} must forbid video stream copy: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
// "original" is a deliberate direct copy — it must NOT disable copying.
|
||||
let original = repo.get_video_download_url("item123", "original", None, None);
|
||||
assert!(
|
||||
!original.contains("allowVideoStreamCopy=false"),
|
||||
"original must remain a direct copy: {original}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A downloaded file is played with no server in reach, so `original`
|
||||
/// quality cannot mean "copy whatever the source holds" when the source
|
||||
/// holds audio this device cannot decode.
|
||||
///
|
||||
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
||||
/// track included, and video plays through the webview `<video>` element on
|
||||
/// both platforms — which decodes none of them. Streaming already knows this
|
||||
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
||||
/// download path did not, so a downloaded film played offline as picture with
|
||||
/// no sound while the very same film had sound when streamed.
|
||||
///
|
||||
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
|
||||
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
||||
assert!(
|
||||
!url.contains("Static=true"),
|
||||
"{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
|
||||
);
|
||||
assert!(
|
||||
url.contains("audioCodec=aac"),
|
||||
"{codec} must be re-encoded to aac on the way down: {url}"
|
||||
);
|
||||
// "Original" still has to mean original picture: the video stream is
|
||||
// copied when it can be, so no bitrate or resolution cap appears.
|
||||
assert!(
|
||||
url.contains("allowVideoStreamCopy=true"),
|
||||
"the video stream must still be copied where possible: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("videoBitRate") && !url.contains("maxHeight"),
|
||||
"original must not degrade the picture to fix the audio: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The converse, and the reason the policy is per-item rather than blanket:
|
||||
/// audio that plays here keeps the byte-exact, range-resumable copy that the
|
||||
/// download worker's resume depends on.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
|
||||
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
||||
assert!(
|
||||
url.contains("Static=true"),
|
||||
"{codec} plays here — the download must stay a direct copy: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("audioCodec="),
|
||||
"{codec} needs no transcode: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown codec: the policy only ever *adds* a transcode, so an item we
|
||||
// could not look up behaves exactly as it did before.
|
||||
let unknown = repo.get_video_download_url("item123", "original", None, None);
|
||||
assert!(unknown.contains("Static=true"), "url: {unknown}");
|
||||
}
|
||||
|
||||
/// The explicit quality presets already transcode audio to AAC, so the
|
||||
/// policy has nothing to add — and must not start overriding a chosen cap.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_presets_ignore_the_audio_policy() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for quality in ["high", "medium", "low"] {
|
||||
let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
|
||||
let without = repo.get_video_download_url("item123", quality, None, None);
|
||||
assert_eq!(with, without, "{quality} must not vary with source audio");
|
||||
assert!(with.contains("audioCodec=aac"), "url: {with}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_passes_media_source_id() {
|
||||
let repo = create_test_repository();
|
||||
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
|
||||
let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
|
||||
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
|
||||
}
|
||||
|
||||
@@ -2581,6 +3091,25 @@ mod tests {
|
||||
assert!(!off.contains("Filters=IsFavorite"));
|
||||
}
|
||||
|
||||
/// A newly-added album must arrive as one entry, not one per track.
|
||||
///
|
||||
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
|
||||
/// every new Audio track individually — so ripping a 14-track album filled
|
||||
/// the whole "recently added" row with that one album. `GroupItems=true`
|
||||
/// makes the server collapse children into their parent container.
|
||||
#[test]
|
||||
fn test_latest_items_endpoint_groups_children_into_containers() {
|
||||
let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
|
||||
|
||||
assert!(
|
||||
endpoint.contains("GroupItems=true"),
|
||||
"latest items must be grouped so an album counts once, got: {}",
|
||||
endpoint
|
||||
);
|
||||
assert!(endpoint.contains("ParentId=lib-1"));
|
||||
assert!(endpoint.contains("Limit=16"));
|
||||
}
|
||||
|
||||
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
|
||||
///
|
||||
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
||||
|
||||
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
/// working through.
|
||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||
/// we do not cache locally.
|
||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
||||
/// this rung the whole feature would be online-only.
|
||||
/// 3. **The episode after the furthest-watched one**, falling back to the first
|
||||
/// unwatched episode when nothing has been watched or the series is finished.
|
||||
/// This is the offline path: `OfflineRepository::get_next_up_episodes`
|
||||
/// returns an empty vec, so without this rung the whole feature would be
|
||||
/// online-only. It deliberately does *not* return the first unwatched
|
||||
/// episode outright — an unwatched episode behind the viewer's furthest
|
||||
/// point was skipped on purpose, and sending them back to it is the bug
|
||||
/// DR-101 was reopened for.
|
||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||
/// rather than on nothing.
|
||||
///
|
||||
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
|
||||
return Some(matched.unwrap_or(found).clone());
|
||||
}
|
||||
|
||||
// 3. First unwatched in series order.
|
||||
// 3. The episode after the furthest-watched one. Not simply the first
|
||||
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||
// where they stopped is the *last* thing they watched.
|
||||
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
||||
if let Some(found) = episodes.get(furthest + 1) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||
// the first unwatched episode in series order.
|
||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
@@ -352,6 +368,54 @@ mod tests {
|
||||
assert_eq!(current.id, "s2e2");
|
||||
}
|
||||
|
||||
/// A viewer deep in season 3 who never watched the pilot must not be sent
|
||||
/// back to it: the gap was a skip, not the place they stopped.
|
||||
#[test]
|
||||
fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
|
||||
let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
|
||||
for ep in eps.iter_mut() {
|
||||
// Everything through S3E3 watched, except the never-watched pilot.
|
||||
let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
|
||||
if watched_through && ep.id != "s1e1" {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s3e4");
|
||||
}
|
||||
|
||||
/// The furthest-watched episode being a finale must still roll into the
|
||||
/// next season rather than stopping the series.
|
||||
#[test]
|
||||
fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut() {
|
||||
if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
/// Specials sort last, so watching one must not mark the series finished
|
||||
/// while numbered episodes remain.
|
||||
#[test]
|
||||
fn a_watched_special_does_not_end_the_series() {
|
||||
let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
|
||||
sort_series_order(&mut eps);
|
||||
for ep in eps.iter_mut() {
|
||||
if ep.id == "s1e1" || ep.id == "s0e1" {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
|
||||
@@ -148,6 +148,138 @@ impl AudioSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// A ceiling on how much bandwidth a *video* stream may consume.
|
||||
///
|
||||
/// A quality step is a bundle of concrete transcode parameters — total stream
|
||||
/// ceiling, the audio share of it, and the resolution that ceiling can carry —
|
||||
/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
|
||||
/// they live here and the frontend only ever names a variant; the labels the
|
||||
/// picker shows are served over IPC by `player_get_streaming_qualities`.
|
||||
///
|
||||
/// The ladder is deliberately expressed in bandwidth rather than resolution: it
|
||||
/// exists to fit a connection, and the resolution cap is chosen *from* the
|
||||
/// bitrate so the encoder does not spend a small budget on pixels it cannot
|
||||
/// afford. See docs/specs/streaming-bitrate-cap.md.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StreamingQuality {
|
||||
/// No client-imposed cap — the server may direct-play the source as-is.
|
||||
#[default]
|
||||
Original,
|
||||
Mbps20,
|
||||
Mbps10,
|
||||
Mbps8,
|
||||
Mbps4,
|
||||
Mbps2,
|
||||
Mbps1,
|
||||
Kbps720,
|
||||
}
|
||||
|
||||
impl StreamingQuality {
|
||||
/// The ladder, highest first, for enumerating across the IPC boundary.
|
||||
pub const ALL: [StreamingQuality; 8] = [
|
||||
StreamingQuality::Original,
|
||||
StreamingQuality::Mbps20,
|
||||
StreamingQuality::Mbps10,
|
||||
StreamingQuality::Mbps8,
|
||||
StreamingQuality::Mbps4,
|
||||
StreamingQuality::Mbps2,
|
||||
StreamingQuality::Mbps1,
|
||||
StreamingQuality::Kbps720,
|
||||
];
|
||||
|
||||
/// Total bits per second the stream may use (video + audio), or `None` for
|
||||
/// the uncapped `Original`.
|
||||
///
|
||||
/// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate`
|
||||
/// and into the device profile. Sending it there — not just on the transcode
|
||||
/// URL — is what makes 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.
|
||||
pub fn max_bitrate(&self) -> Option<u64> {
|
||||
match self {
|
||||
StreamingQuality::Original => None,
|
||||
StreamingQuality::Mbps20 => Some(20_000_000),
|
||||
StreamingQuality::Mbps10 => Some(10_000_000),
|
||||
StreamingQuality::Mbps8 => Some(8_000_000),
|
||||
StreamingQuality::Mbps4 => Some(4_000_000),
|
||||
StreamingQuality::Mbps2 => Some(2_000_000),
|
||||
StreamingQuality::Mbps1 => Some(1_000_000),
|
||||
StreamingQuality::Kbps720 => Some(720_000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per second allotted to the audio track.
|
||||
///
|
||||
/// The value shrinks with the ladder because at the bottom rungs a fixed
|
||||
/// 384 kbps would be a third of the entire budget.
|
||||
pub fn audio_bitrate(&self) -> u64 {
|
||||
match self {
|
||||
StreamingQuality::Original
|
||||
| StreamingQuality::Mbps20
|
||||
| StreamingQuality::Mbps10
|
||||
| StreamingQuality::Mbps8 => 384_000,
|
||||
StreamingQuality::Mbps4 => 256_000,
|
||||
StreamingQuality::Mbps2 => 192_000,
|
||||
StreamingQuality::Mbps1 => 128_000,
|
||||
StreamingQuality::Kbps720 => 96_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per second allotted to the video track: the total minus the audio
|
||||
/// share, so the two together honour [`max_bitrate`](Self::max_bitrate)
|
||||
/// rather than overshooting it by the size of the audio track.
|
||||
pub fn video_bitrate(&self) -> Option<u64> {
|
||||
self.max_bitrate()
|
||||
.map(|total| total.saturating_sub(self.audio_bitrate()))
|
||||
}
|
||||
|
||||
/// Resolution ceiling that suits the bitrate, or `None` to leave the source
|
||||
/// resolution alone. Scaling down is what keeps a small budget looking like
|
||||
/// clean video instead of blocky 1080p.
|
||||
pub fn max_height(&self) -> Option<u32> {
|
||||
match self {
|
||||
// 20 Mbps carries 4K, so it caps bandwidth without capping pixels.
|
||||
StreamingQuality::Original | StreamingQuality::Mbps20 => None,
|
||||
StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
|
||||
StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
|
||||
StreamingQuality::Mbps1 => Some(480),
|
||||
StreamingQuality::Kbps720 => Some(360),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for the picker. Lives in Rust with the numbers it describes,
|
||||
/// so the two cannot drift apart.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
StreamingQuality::Original => "Original",
|
||||
StreamingQuality::Mbps20 => "20 Mbps",
|
||||
StreamingQuality::Mbps10 => "10 Mbps",
|
||||
StreamingQuality::Mbps8 => "8 Mbps",
|
||||
StreamingQuality::Mbps4 => "4 Mbps",
|
||||
StreamingQuality::Mbps2 => "2 Mbps",
|
||||
StreamingQuality::Mbps1 => "1 Mbps",
|
||||
StreamingQuality::Kbps720 => "720 kbps",
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary line for the picker: what the cap means in practice.
|
||||
pub fn detail(&self) -> &'static str {
|
||||
match self {
|
||||
StreamingQuality::Original => "No limit — highest quality",
|
||||
StreamingQuality::Mbps20 => "Up to 4K",
|
||||
StreamingQuality::Mbps10 => "1080p, high quality",
|
||||
StreamingQuality::Mbps8 => "1080p",
|
||||
StreamingQuality::Mbps4 => "720p",
|
||||
StreamingQuality::Mbps2 => "720p, reduced",
|
||||
StreamingQuality::Mbps1 => "480p",
|
||||
StreamingQuality::Kbps720 => "360p — slowest connections",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video playback settings
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -159,6 +291,14 @@ pub struct VideoSettings {
|
||||
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||
#[serde(default)]
|
||||
pub auto_play_max_episodes: u32,
|
||||
/// Bandwidth ceiling applied to every video stream.
|
||||
///
|
||||
/// `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
/// loads as the previous behaviour (uncapped).
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[serde(default)]
|
||||
pub streaming_quality: StreamingQuality,
|
||||
}
|
||||
|
||||
impl Default for VideoSettings {
|
||||
@@ -167,6 +307,7 @@ impl Default for VideoSettings {
|
||||
auto_play_next_episode: true,
|
||||
auto_play_countdown_seconds: 10,
|
||||
auto_play_max_episodes: 0,
|
||||
streaming_quality: StreamingQuality::Original,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,12 +568,14 @@ mod tests {
|
||||
auto_play_next_episode: false,
|
||||
auto_play_countdown_seconds: 15,
|
||||
auto_play_max_episodes: 5,
|
||||
streaming_quality: StreamingQuality::Mbps4,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
||||
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
||||
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
|
||||
assert!(json.contains("\"streamingQuality\":\"mbps4\""));
|
||||
|
||||
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
||||
assert!(!parsed.auto_play_next_episode);
|
||||
@@ -448,5 +591,90 @@ mod tests {
|
||||
assert!(parsed.auto_play_next_episode);
|
||||
assert_eq!(parsed.auto_play_countdown_seconds, 10);
|
||||
assert_eq!(parsed.auto_play_max_episodes, 0);
|
||||
// Settings persisted before the cap existed must load as uncapped —
|
||||
// inventing a limit for an upgrading user would silently degrade their
|
||||
// picture with no setting having been changed.
|
||||
assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
|
||||
}
|
||||
|
||||
/// The whole point of a step is the number of bits it promises not to
|
||||
/// exceed, so video + audio must fit inside the total — a video bitrate set
|
||||
/// to the full cap would overshoot it by the size of the audio track.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-157
|
||||
#[test]
|
||||
fn test_streaming_quality_budget_is_internally_consistent() {
|
||||
for quality in StreamingQuality::ALL {
|
||||
let Some(total) = quality.max_bitrate() else {
|
||||
assert_eq!(
|
||||
quality,
|
||||
StreamingQuality::Original,
|
||||
"only Original may be uncapped"
|
||||
);
|
||||
assert!(quality.video_bitrate().is_none());
|
||||
assert!(quality.max_height().is_none());
|
||||
continue;
|
||||
};
|
||||
|
||||
let video = quality.video_bitrate().expect("a capped step caps video");
|
||||
assert_eq!(
|
||||
video + quality.audio_bitrate(),
|
||||
total,
|
||||
"{:?}: video + audio must equal the cap",
|
||||
quality
|
||||
);
|
||||
assert!(
|
||||
video > 0,
|
||||
"{:?}: audio must not consume the budget",
|
||||
quality
|
||||
);
|
||||
assert!(!quality.label().is_empty());
|
||||
assert!(!quality.detail().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder is presented to the user as descending, and the resolution cap
|
||||
/// must fall with it — a lower bitrate paired with a higher resolution would
|
||||
/// spend the smaller budget on more pixels, which is backwards.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-157
|
||||
#[test]
|
||||
fn test_streaming_quality_ladder_descends() {
|
||||
let steps = StreamingQuality::ALL;
|
||||
for pair in steps.windows(2) {
|
||||
let (higher, lower) = (pair[0], pair[1]);
|
||||
let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
|
||||
let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
|
||||
assert!(
|
||||
higher_bitrate > lower_bitrate,
|
||||
"{:?} must sit above {:?}",
|
||||
higher,
|
||||
lower
|
||||
);
|
||||
assert!(
|
||||
higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
|
||||
"{:?} must not cap resolution below {:?}",
|
||||
higher,
|
||||
lower
|
||||
);
|
||||
assert!(higher.audio_bitrate() >= lower.audio_bitrate());
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted form is the serde token, and it must survive a round trip —
|
||||
/// a rename here silently resets everyone's saved cap to uncapped.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-157
|
||||
#[test]
|
||||
fn test_streaming_quality_round_trips_through_json() {
|
||||
for quality in StreamingQuality::ALL {
|
||||
let json = serde_json::to_string(&quality).expect("serialises");
|
||||
let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
|
||||
assert_eq!(parsed, quality);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
|
||||
"\"mbps10\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"transparent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.4",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -18,7 +18,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+24
@@ -51,6 +51,30 @@ html, body {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Native-video compositing (Android).
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-150
|
||||
*
|
||||
* When ExoPlayer renders into a SurfaceView *behind* the WebView, every opaque
|
||||
* layer between the viewport and that surface hides the video. The WebView
|
||||
* itself is made transparent by `"transparent": true` in
|
||||
* tauri.android.conf.json; these rules clear the app's own painted backgrounds.
|
||||
*
|
||||
* Scoped to `[data-native-video="active"]` — set on <html> by
|
||||
* $lib/stores/nativeVideo.ts only while a native video session is on screen —
|
||||
* because every other screen genuinely needs its opaque background. The app
|
||||
* shell (+layout.svelte) also paints --color-background across the viewport, so
|
||||
* it is cleared here too; the shell is the layer directly over the surface.
|
||||
*
|
||||
* `background: transparent` (not a colour) is required: an alpha-0 colour still
|
||||
* composites in some WebView versions.
|
||||
*/
|
||||
html[data-native-video="active"],
|
||||
html[data-native-video="active"] body,
|
||||
html[data-native-video="active"] [data-app-shell] {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-white antialiased;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
||||
+266
-6
@@ -143,6 +143,14 @@ async playerGetStatus() : Promise<PlayerStatus> {
|
||||
async playerGetQueue() : Promise<QueueStatus> {
|
||||
return await TAURI_INVOKE("player_get_queue");
|
||||
},
|
||||
/**
|
||||
* Report this platform's playback capabilities to the frontend.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||
*/
|
||||
async playerGetCapabilities() : Promise<PlaybackCapabilities> {
|
||||
return await TAURI_INVOKE("player_get_capabilities");
|
||||
},
|
||||
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
|
||||
return await TAURI_INVOKE("player_add_to_queue", { request });
|
||||
},
|
||||
@@ -189,6 +197,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
|
||||
async playerGetVideoSettings() : Promise<VideoSettings> {
|
||||
return await TAURI_INVOKE("player_get_video_settings");
|
||||
},
|
||||
/**
|
||||
* The bandwidth ceilings the quality picker may offer, each with the label and
|
||||
* one-line detail to show for it, highest first.
|
||||
*
|
||||
* The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||
* frontend reads them here rather than encoding them — the same arrangement as
|
||||
* [`player_get_eq_presets`].
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> {
|
||||
return await TAURI_INVOKE("player_get_streaming_qualities");
|
||||
},
|
||||
/**
|
||||
* Change the bandwidth ceiling of the video that is playing *right now*.
|
||||
*
|
||||
* A cap is a property of the stream the server is producing, so unlike a volume
|
||||
* change it cannot be applied to a stream already in flight — the stream has to
|
||||
* be re-opened at the new quality and resumed at the current position. That is
|
||||
* the same reload the transcoded-seek and audio-track paths use, and the same
|
||||
* 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.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
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 });
|
||||
},
|
||||
/**
|
||||
* Set sleep timer mode
|
||||
*/
|
||||
@@ -738,6 +780,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
|
||||
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
|
||||
},
|
||||
/**
|
||||
* Set the watched flag locally for an item **and everything inside it**.
|
||||
*
|
||||
* This backs the watched toggle, and is deliberately separate from
|
||||
* [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
* increments `play_count` — because the toggle has two directions and applies
|
||||
* to containers.
|
||||
*
|
||||
* The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
* online the server fixes up the children on the next read; with no server to
|
||||
* ask, marking a season watched would otherwise tick the season and leave every
|
||||
* episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
* columns the rest of the offline layer uses, so an id that is not cached
|
||||
* selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
*
|
||||
* Un-marking clears the resume position too, matching the server, so an item
|
||||
* un-marked offline does not come back offering to resume from a position it is
|
||||
* no longer meant to have.
|
||||
*
|
||||
* `pending_sync = 1` hands the rows to the sync drain.
|
||||
*
|
||||
* TRACES: UR-073 | DR-158
|
||||
*/
|
||||
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
|
||||
},
|
||||
/**
|
||||
* Get playback progress for an item
|
||||
*/
|
||||
@@ -801,13 +870,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
|
||||
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
|
||||
},
|
||||
/**
|
||||
* Pause a download
|
||||
* Pause a download.
|
||||
*
|
||||
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||
* streaming task knew nothing about the row and kept running, then overwrote it
|
||||
* with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||
* undid itself — the reported "pause does not work". Signalling the worker is
|
||||
* what actually stops the bytes; it leaves the `.part` file in place so
|
||||
* [`resume_download`] can continue from it.
|
||||
*
|
||||
* A queued (not yet started) download has no worker to signal, and the status
|
||||
* write alone is enough — the pump skips anything that is not `pending`.
|
||||
*
|
||||
* TRACES: UR-055 | DR-168
|
||||
*/
|
||||
async pauseDownload(downloadId: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("pause_download", { downloadId });
|
||||
},
|
||||
/**
|
||||
* Resume a paused download
|
||||
* Resume a paused download.
|
||||
*
|
||||
* Flipping the row back to `pending` is likewise not enough on its own: the
|
||||
* pump is not a poller, it runs when something calls it, so a resumed download
|
||||
* sat untouched until some unrelated event happened to pump the queue. That is
|
||||
* the other half of "resume does not work".
|
||||
*
|
||||
* TRACES: UR-055 | DR-168
|
||||
*/
|
||||
async resumeDownload(downloadId: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("resume_download", { downloadId });
|
||||
@@ -873,6 +961,22 @@ async markDownloadCompleted(downloadId: number, bytesDownloaded: number, filePat
|
||||
async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("mark_download_failed", { downloadId, errorMessage });
|
||||
},
|
||||
/**
|
||||
* A playable URL for a downloaded file on disk.
|
||||
*
|
||||
* Local media is served over a loopback HTTP server rather than handed to the
|
||||
* webview as a `file://`/asset URL, because the asset protocol cannot stream a
|
||||
* large file — it answers a range-less request with the whole thing, which
|
||||
* Chromium abandons. See `media_server` for why real HTTP is used.
|
||||
*
|
||||
* The returned URL carries the server's per-session token, so it is only valid
|
||||
* for this run of the app and must not be persisted.
|
||||
*
|
||||
* TRACES: UR-071 | DR-137
|
||||
*/
|
||||
async mediaLocalUrl(path: string) : Promise<string> {
|
||||
return await TAURI_INVOKE("media_local_url", { path });
|
||||
},
|
||||
/**
|
||||
* Start downloading a file immediately
|
||||
* This command actually downloads the file using the worker
|
||||
@@ -1130,6 +1234,14 @@ async syncMarkFailed(id: number, error: string) : Promise<null> {
|
||||
async syncGetPendingCount(userId: string) : Promise<number> {
|
||||
return await TAURI_INVOKE("sync_get_pending_count", { userId });
|
||||
},
|
||||
/**
|
||||
* Push the queue now, on the user's say-so, instead of waiting for a reconnect.
|
||||
*
|
||||
* TRACES: UR-025 | DR-132
|
||||
*/
|
||||
async syncProcessPending() : Promise<DrainReport> {
|
||||
return await TAURI_INVOKE("sync_process_pending");
|
||||
},
|
||||
/**
|
||||
* Delete completed sync operations older than specified days
|
||||
*/
|
||||
@@ -1440,6 +1552,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
|
||||
},
|
||||
/**
|
||||
* Report playback stopped
|
||||
*
|
||||
* A stop-report that cannot reach the server is queued rather than dropped:
|
||||
* this is the position the resume point is built from, and losing it is
|
||||
* exactly the "it forgot where I was" the sync queue exists to prevent. The
|
||||
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
||||
* failing the command because the *queue* write failed would tell the caller
|
||||
* the report was lost when the local position was already saved.
|
||||
*
|
||||
* TRACES: UR-025 | DR-154 | UT-151
|
||||
*/
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||
@@ -1879,6 +2000,26 @@ export type DownloadVideoRequest = { itemId: string; userId: string; filePath: s
|
||||
* Enhanced response with pre-computed stats
|
||||
*/
|
||||
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
|
||||
/**
|
||||
* What a drain did, for logging and for the frontend's "Sync now" button.
|
||||
*/
|
||||
export type DrainReport = {
|
||||
/**
|
||||
* Rows that reached the server and are now `completed`.
|
||||
*/
|
||||
pushed: number;
|
||||
/**
|
||||
* Rows that failed and will be retried on the next reconnect.
|
||||
*/
|
||||
deferred: number;
|
||||
/**
|
||||
* Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
|
||||
*/
|
||||
abandoned: number;
|
||||
/**
|
||||
* Rows still waiting afterwards (what the badge counts).
|
||||
*/
|
||||
remaining: number }
|
||||
/**
|
||||
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
|
||||
* layout above (a domain concept), not a mere label — the curve numbers live
|
||||
@@ -2187,7 +2328,29 @@ itemType?: string | null;
|
||||
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
||||
* look up the next episode when a background-audio track ends.
|
||||
*/
|
||||
seriesId?: string | null }
|
||||
seriesId?: string | null;
|
||||
/**
|
||||
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||
*
|
||||
* Only the native backends use these: on Android they become the
|
||||
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||
* builds its own `<track>` children instead and ignores this list.
|
||||
*
|
||||
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||
* *text track groups* — i.e. the position of the sideloaded configuration,
|
||||
* not the Jellyfin stream index (which is kept on each entry for the UI's
|
||||
* benefit). So `n` must be a position in this very array, and the array
|
||||
* must not be reordered or filtered between building it and sending it.
|
||||
* `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
|
||||
* list that is sent here, for exactly this reason.
|
||||
*
|
||||
* Defaulted so the background-audio handoff and the autoplay/next-episode
|
||||
* callers, which have no subtitles to offer, need not send the field.
|
||||
*
|
||||
* TRACES: UR-020 | IR-016, JA-008 | UT-145
|
||||
*/
|
||||
subtitles?: SubtitleTrack[] }
|
||||
/**
|
||||
* Queue context for remote transfer - what type of queue is this?
|
||||
*/
|
||||
@@ -2227,6 +2390,30 @@ export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffl
|
||||
* over playback from a remote session so we don't restart from 0.
|
||||
*/
|
||||
startPosition?: number | null }
|
||||
/**
|
||||
* What playback facilities this platform's backend actually provides.
|
||||
*
|
||||
* The frontend is presentation-only and must not re-derive backend facts from
|
||||
* `navigator.userAgent` — that sniffing was a second copy of the same platform
|
||||
* decision Rust already makes with `cfg!`, and it drifted. These flags are the
|
||||
* single source of truth; the frontend consumes them.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||
*/
|
||||
export type PlaybackCapabilities = {
|
||||
/**
|
||||
* True when audio is rendered by a webview `<audio>` element rather than a
|
||||
* native backend. Native audio exists on Linux (mpv) and Android
|
||||
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
||||
*/
|
||||
usesWebviewAudio: boolean;
|
||||
/**
|
||||
* True when video can be rendered by a native surface composited *behind*
|
||||
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
|
||||
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||
* compositing), so it stays on the HTML5 element.
|
||||
*/
|
||||
supportsNativeVideo: boolean }
|
||||
/**
|
||||
* Playback information
|
||||
*/
|
||||
@@ -2723,8 +2910,63 @@ export type StreamKind = "audio" | "video" | "subtitle" |
|
||||
* Any stream kind we do not model explicitly (e.g. embedded image, data).
|
||||
*/
|
||||
"other"
|
||||
/**
|
||||
* Response for a mid-playback streaming-quality change.
|
||||
*
|
||||
* Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
|
||||
* has to reload anything, so no strategy branch lives in the UI.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
export type StreamQualityResponse =
|
||||
/**
|
||||
* The native backend was reloaded here; nothing left for the frontend.
|
||||
*/
|
||||
{ strategy: "native"; position: number } |
|
||||
/**
|
||||
* HTML5 must reload its element with this URL.
|
||||
*/
|
||||
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||
/**
|
||||
* A ceiling on how much bandwidth a *video* stream may consume.
|
||||
*
|
||||
* A quality step is a bundle of concrete transcode parameters — total stream
|
||||
* ceiling, the audio share of it, and the resolution that ceiling can carry —
|
||||
* not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
|
||||
* they live here and the frontend only ever names a variant; the labels the
|
||||
* picker shows are served over IPC by `player_get_streaming_qualities`.
|
||||
*
|
||||
* The ladder is deliberately expressed in bandwidth rather than resolution: it
|
||||
* exists to fit a connection, and the resolution cap is chosen *from* the
|
||||
* bitrate so the encoder does not spend a small budget on pixels it cannot
|
||||
* afford. See docs/specs/streaming-bitrate-cap.md.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
export type StreamingQuality =
|
||||
/**
|
||||
* No client-imposed cap — the server may direct-play the source as-is.
|
||||
*/
|
||||
"original" | "mbps20" | "mbps10" | "mbps8" | "mbps4" | "mbps2" | "mbps1" | "kbps720"
|
||||
/**
|
||||
* Represents a subtitle track
|
||||
*
|
||||
* 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
||||
* struct in the player that deliberately keeps snake_case on the wire, because
|
||||
* the *same* serialization feeds two consumers that both spell `mime_type`:
|
||||
*
|
||||
* * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
||||
* with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
||||
* whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
||||
* * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
||||
* from the frontend, and the generated binding (`SubtitleTrack` in
|
||||
* `bindings.ts`) therefore also declares `mime_type`.
|
||||
*
|
||||
* Renaming would not break the build and would not fail the IPC: Kotlin's
|
||||
* `optString` would just fall back to its default MIME type for every track, so
|
||||
* the failure would be silent. UT-146 asserts the serialized keys.
|
||||
*
|
||||
* TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||
*/
|
||||
export type SubtitleTrack = {
|
||||
/**
|
||||
@@ -2744,13 +2986,22 @@ language: string | null;
|
||||
*/
|
||||
label: string | null;
|
||||
/**
|
||||
* MIME type (e.g., "text/vtt", "application/x-subrip")
|
||||
* MIME type (e.g., "text/vtt", "application/x-subrip").
|
||||
* Snake_case on purpose — see the note on the struct.
|
||||
*/
|
||||
mime_type: string }
|
||||
/**
|
||||
* Sync queue item returned to frontend
|
||||
*/
|
||||
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null }
|
||||
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null;
|
||||
/**
|
||||
* Cached title of the item the operation is about, when the catalog knows
|
||||
* it. Resolved here rather than by a per-row frontend fetch — the queue
|
||||
* list is otherwise a wall of opaque ids.
|
||||
*
|
||||
* TRACES: UR-025 | DR-132
|
||||
*/
|
||||
itemName: string | null }
|
||||
/**
|
||||
* Statistics about the thumbnail cache
|
||||
*/
|
||||
@@ -2818,7 +3069,16 @@ autoPlayCountdownSeconds: number;
|
||||
/**
|
||||
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||
*/
|
||||
autoPlayMaxEpisodes?: number }
|
||||
autoPlayMaxEpisodes?: number;
|
||||
/**
|
||||
* Bandwidth ceiling applied to every video stream.
|
||||
*
|
||||
* `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
* loads as the previous behaviour (uncapped).
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
streamingQuality?: StreamingQuality }
|
||||
/**
|
||||
* Volume normalization levels matching Spotify's presets
|
||||
*/
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<!--
|
||||
Shared application header. Lifted out of the library layout so the account
|
||||
menu (and desktop nav) are available on every authenticated, non-immersive
|
||||
screen, not only under /library. Routes that need in-header search (the
|
||||
library layout) pass it in via the `search` snippet; other routes omit it.
|
||||
screen, not only under /library.
|
||||
|
||||
The search box is owned here rather than passed in by a layout, so the same
|
||||
bar renders on the library routes and on /search — searching from the header
|
||||
no longer hands you to a screen with a different input.
|
||||
|
||||
TRACES: UR-054 | DR-076
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
|
||||
|
||||
let { search }: { search?: Snippet } = $props();
|
||||
import HeaderSearch from "$lib/components/search/HeaderSearch.svelte";
|
||||
import { showHeaderSearch } from "$lib/utils/layoutShell";
|
||||
|
||||
const pathname = $derived($page.url.pathname);
|
||||
const withSearch = $derived(showHeaderSearch({ pathname }));
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
||||
@@ -51,10 +54,10 @@
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Optional in-header search (library layout supplies it). -->
|
||||
{#if search}
|
||||
<div class="flex-1 max-w-md hidden md:block space-y-2">
|
||||
{@render search()}
|
||||
<!-- In-header search: the single md+ search input (library + /search). -->
|
||||
{#if withSearch}
|
||||
<div class="flex-1 max-w-md hidden md:block">
|
||||
<HeaderSearch />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
/** Exposed so a parent can focus/position the caret (see HeaderSearch). */
|
||||
inputEl?: HTMLInputElement | null;
|
||||
}
|
||||
|
||||
let { value = $bindable(""), placeholder = "Search...", onSearch }: Props = $props();
|
||||
let {
|
||||
value = $bindable(""),
|
||||
placeholder = "Search...",
|
||||
onSearch,
|
||||
inputEl = $bindable(null),
|
||||
}: Props = $props();
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -39,6 +46,7 @@
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
{value}
|
||||
{placeholder}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
<!-- TRACES: UR-048 | DR-061, DR-062 -->
|
||||
<!--
|
||||
The one and only episode surface (ux-flows §5B.1) — so it carries everything
|
||||
an episode can do, not just Play. A bare Episode page used to exist alongside
|
||||
it with a *different* set of affordances (download, breadcrumbs, cast), which
|
||||
meant opening an episode from Continue Watching silently lost them.
|
||||
|
||||
TRACES: UR-048, UR-058 | DR-061, DR-062, DR-142
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CastSection from "./CastSection.svelte";
|
||||
import GenreTags from "./GenreTags.svelte";
|
||||
import RelatedItemsSection from "./RelatedItemsSection.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import { seasonAnchorId } from "./seriesNavigation";
|
||||
import {
|
||||
isCurrentEpisode as isSameEpisode,
|
||||
adjacentEpisodes as computeAdjacent,
|
||||
@@ -14,12 +27,17 @@
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
series: MediaItem;
|
||||
allEpisodes: MediaItem[];
|
||||
/**
|
||||
* The parent series. `null` only for an episode that carries no `seriesId`
|
||||
* (a deep link into a stale cache) — the view still renders, minus the
|
||||
* affordances that need series context.
|
||||
*/
|
||||
series?: MediaItem | null;
|
||||
allEpisodes?: MediaItem[];
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||
let { episode, series = null, allEpisodes = [], onBack }: Props = $props();
|
||||
|
||||
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
|
||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||
@@ -28,6 +46,10 @@
|
||||
|
||||
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
|
||||
|
||||
// A strip of exactly one card is the current episode talking to itself — the
|
||||
// spec wants the *next* episodes, so with no siblings there is nothing to show.
|
||||
const hasEpisodeStrip = $derived(adjacentEpisodes().length > 1);
|
||||
|
||||
// Compute best backdrop source (no fetch, pure derivation)
|
||||
const backdropSource = $derived.by(() => {
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
@@ -36,12 +58,28 @@
|
||||
if (episode.imageId) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
||||
}
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
if (series?.backdropImageTags?.[0]) {
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Cast and genres are the episode's own when the server sent them, else the
|
||||
// series' — a list-level episode fetch often carries neither, and an empty
|
||||
// Cast row on an episode of a show with a known cast reads as broken.
|
||||
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
|
||||
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
|
||||
|
||||
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
|
||||
// off the series rather than the episode.
|
||||
const seriesName = $derived(series?.name ?? episode.seriesName ?? null);
|
||||
const seriesHref = $derived(series ? `/library/${series.id}` : null);
|
||||
const seasonHref = $derived(
|
||||
series && episode.parentIndexNumber != null
|
||||
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
|
||||
: null
|
||||
);
|
||||
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms) return "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
@@ -66,6 +104,7 @@
|
||||
}
|
||||
|
||||
function handleEpisodeClick(ep: MediaItem) {
|
||||
if (!series) return;
|
||||
goto(`/library/${series.id}?episode=${ep.id}`);
|
||||
}
|
||||
|
||||
@@ -112,8 +151,19 @@
|
||||
<!-- Content -->
|
||||
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
|
||||
<div class="space-y-4">
|
||||
<!-- Series name -->
|
||||
<p class="text-gray-300 text-lg">{series.name}</p>
|
||||
<!-- Series name — a link, so the episode page is a navigable hub
|
||||
rather than a dead end (UR-058). -->
|
||||
{#if seriesName}
|
||||
<p class="text-lg">
|
||||
{#if seriesHref}
|
||||
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
|
||||
{seriesName}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-gray-300">{seriesName}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Episode title -->
|
||||
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
||||
@@ -122,9 +172,20 @@
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center gap-4 text-sm text-gray-200">
|
||||
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
|
||||
{episodeLabel}
|
||||
</span>
|
||||
<!-- The badge links to the season's place in the series list —
|
||||
seasons have no page of their own (DR-103). -->
|
||||
{#if seasonHref}
|
||||
<a
|
||||
href={seasonHref}
|
||||
class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold hover:brightness-110 transition-all"
|
||||
>
|
||||
{episodeLabel}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
|
||||
{episodeLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if duration}
|
||||
<span>{duration}</span>
|
||||
{/if}
|
||||
@@ -168,7 +229,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Play button + favourite. TRACES: UR-068 | DR-119 -->
|
||||
<!-- Play / Download / Favourite — the full hero action row of
|
||||
ux-flows §5B.2. TRACES: UR-058, UR-068 | DR-119, DR-142 -->
|
||||
<div class="pt-2 flex items-center gap-3">
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
@@ -179,6 +241,22 @@
|
||||
</svg>
|
||||
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
||||
</button>
|
||||
<VideoDownloadButton
|
||||
itemId={episode.id}
|
||||
itemName={episode.name}
|
||||
isMovie={false}
|
||||
seriesName={seriesName ?? undefined}
|
||||
seasonName={episode.seasonName ?? undefined}
|
||||
seasonNumber={episode.parentIndexNumber ?? undefined}
|
||||
episodeNumber={episode.indexNumber ?? undefined}
|
||||
size="lg"
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={episode.id}
|
||||
watched={episode.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
size="lg"
|
||||
/>
|
||||
<FavoriteButton
|
||||
itemId={episode.id}
|
||||
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
|
||||
@@ -189,7 +267,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adjacent episodes -->
|
||||
<!-- Adjacent episodes. Nothing may be inserted between the hero and this
|
||||
strip — continuation content comes before discovery content
|
||||
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
|
||||
{#if hasEpisodeStrip}
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
||||
|
||||
@@ -273,4 +354,27 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
|
||||
hero → strip → cast → similar). TRACES: UR-048 | DR-062, DR-142 -->
|
||||
{#if genres.length}
|
||||
<GenreTags {genres} maxShow={6} itemKind="episode" />
|
||||
{/if}
|
||||
|
||||
{#if people.length}
|
||||
<CastSection {people} />
|
||||
{/if}
|
||||
|
||||
<!-- "More Like This" on an episode means similar shows, so it keys off the
|
||||
series. Skipped for a series-less episode, which has nothing to match on. -->
|
||||
{#if series && (series.genres?.length || series.people?.length)}
|
||||
<RelatedItemsSection
|
||||
currentItemId={series.id}
|
||||
itemKind="series"
|
||||
genres={series.genres ?? undefined}
|
||||
people={series.people ?? undefined}
|
||||
limit={12}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// The Episode Focus View is the *only* episode surface (ux-flows §5B.1), so it
|
||||
// has to carry everything the bare Episode page used to: download, breadcrumbs
|
||||
// back to the series/season, cast and similar shows. It shipped with only Play
|
||||
// and Favourite, which is why "open an episode from Continue Watching" lost the
|
||||
// download affordance.
|
||||
//
|
||||
// TRACES: UR-048, UR-058 | DR-062, DR-142 | UT-131, UT-132, UT-133, UT-134, UT-135
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
|
||||
favoriteOverridesStore: shim(new Map<string, boolean>()),
|
||||
getSimilarItems: vi.fn(async () => ({ items: [] as MediaItem[] })),
|
||||
search: vi.fn(async () => ({ items: [] as MediaItem[] })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/downloads", () => ({
|
||||
downloads: {
|
||||
subscribe: h.downloadsStore.subscribe,
|
||||
downloadVideo: vi.fn(),
|
||||
pinItem: vi.fn(),
|
||||
unpinItem: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/favorites", async () => {
|
||||
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>(
|
||||
"$lib/stores/favorites"
|
||||
);
|
||||
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: () => ({ getSimilarItems: h.getSimilarItems, search: h.search }),
|
||||
getUserId: () => "user-1",
|
||||
},
|
||||
user: { subscribe: (fn: (v: unknown) => void) => (fn({ id: "user-1" }), () => {}) },
|
||||
}));
|
||||
|
||||
// CachedImage does async repo/image work irrelevant to these tests.
|
||||
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
|
||||
default: (await import("./__mocks__/StubImage.svelte")).default,
|
||||
}));
|
||||
|
||||
import EpisodeFocusView from "./EpisodeFocusView.svelte";
|
||||
|
||||
const SERIES: MediaItem = {
|
||||
id: "series-1",
|
||||
name: "The Show",
|
||||
kind: "series",
|
||||
genres: ["Drama"],
|
||||
people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }],
|
||||
} as unknown as MediaItem;
|
||||
|
||||
function episode(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "ep-4",
|
||||
name: "The Fourth One",
|
||||
kind: "episode",
|
||||
seriesId: "series-1",
|
||||
seriesName: "The Show",
|
||||
parentIndexNumber: 2,
|
||||
indexNumber: 4,
|
||||
durationMs: 2_880_000,
|
||||
overview: "Something happens.",
|
||||
genres: ["Drama"],
|
||||
people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }],
|
||||
...overrides,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
function sibling(id: string, number: number): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `Episode ${number}`,
|
||||
kind: "episode",
|
||||
seriesId: "series-1",
|
||||
parentIndexNumber: 2,
|
||||
indexNumber: number,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
const allEpisodes = [sibling("ep-3", 3), episode(), sibling("ep-5", 5)];
|
||||
|
||||
describe("EpisodeFocusView — full episode functionality (DR-142)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.downloadsStore.set({ downloads: {} });
|
||||
h.favoriteOverridesStore.set(new Map());
|
||||
});
|
||||
|
||||
it("offers a download control in the hero", () => {
|
||||
render(EpisodeFocusView, {
|
||||
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("links the series name back to the series page", () => {
|
||||
render(EpisodeFocusView, {
|
||||
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||
});
|
||||
|
||||
const link = screen.getByRole("link", { name: "The Show" });
|
||||
expect(link.getAttribute("href")).toBe("/library/series-1");
|
||||
});
|
||||
|
||||
it("links the season badge to the season's place in the series list", () => {
|
||||
render(EpisodeFocusView, {
|
||||
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||
});
|
||||
|
||||
const link = screen.getByRole("link", { name: "S2E4" });
|
||||
expect(link.getAttribute("href")).toBe("/library/series-1#season-2");
|
||||
});
|
||||
|
||||
it("renders cast below the episode strip, never above it (DR-062)", () => {
|
||||
const { container } = render(EpisodeFocusView, {
|
||||
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||
});
|
||||
|
||||
const headings = [...container.querySelectorAll("h2")].map((h2) => h2.textContent?.trim());
|
||||
const strip = headings.indexOf("More Episodes");
|
||||
const cast = headings.findIndex((t) => t?.startsWith("Cast"));
|
||||
|
||||
expect(strip).toBeGreaterThanOrEqual(0);
|
||||
expect(cast).toBeGreaterThan(strip);
|
||||
});
|
||||
|
||||
it("hides the episode strip when the episode has no siblings", () => {
|
||||
render(EpisodeFocusView, {
|
||||
props: { episode: episode(), series: SERIES, allEpisodes: [] },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("More Episodes")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders without a series for an episode that carries no seriesId", () => {
|
||||
render(EpisodeFocusView, {
|
||||
props: {
|
||||
episode: episode({ seriesId: null, seriesName: null }),
|
||||
series: null,
|
||||
allEpisodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Still a complete surface: title, play and download all present.
|
||||
expect(screen.getByText("The Fourth One")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "The Show" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -17,9 +18,17 @@
|
||||
*/
|
||||
current?: boolean;
|
||||
onclick?: () => void;
|
||||
/** Fired when the watched toggle changes, so the series page can reload. */
|
||||
onWatchedChanged?: () => void;
|
||||
}
|
||||
|
||||
let { episode, focused = false, current = false, onclick }: Props = $props();
|
||||
let {
|
||||
episode,
|
||||
focused = false,
|
||||
current = false,
|
||||
onclick,
|
||||
onWatchedChanged,
|
||||
}: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
@@ -177,6 +186,16 @@
|
||||
{duration}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Watched toggle - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<WatchedToggleButton
|
||||
itemId={episode.id}
|
||||
watched={episode.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
size="sm"
|
||||
onChanged={onWatchedChanged}
|
||||
/>
|
||||
</div>
|
||||
<!-- Download button - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<VideoDownloadButton
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
@@ -52,12 +53,16 @@
|
||||
let selectedGenre = $state<Genre | null>(null);
|
||||
let genreItems = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
async function reloadGenreBrowse() {
|
||||
await loadGenres();
|
||||
if (selectedGenre) {
|
||||
await loadGenreItems(selectedGenre);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(reloadGenreBrowse);
|
||||
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
|
||||
useOfflineFilterReload(reloadGenreBrowse);
|
||||
|
||||
onMount(async () => {
|
||||
await loadGenres();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||
import type { MediaItem, Library, ItemType, SearchResult } from "$lib/api/types";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
@@ -95,6 +96,11 @@
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
// Re-query when the offline downloaded-only gate changes — going offline, or
|
||||
// toggling "Show all server media". Without this the listing kept whatever it
|
||||
// was first loaded with and the toggle only greyed cards. TRACES: UR-052 | DR-143
|
||||
useOfflineFilterReload(() => loadItems());
|
||||
|
||||
onMount(async () => {
|
||||
await loadItems();
|
||||
markLoaded();
|
||||
|
||||
@@ -45,9 +45,16 @@
|
||||
* TRACES: UR-068 | DR-119
|
||||
*/
|
||||
showFavorite?: boolean;
|
||||
/**
|
||||
* Force the artwork box to a fixed aspect ratio instead of deriving one from
|
||||
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
|
||||
* strip, where square music art next to 16:9 video art would otherwise give
|
||||
* the cards different heights). Artwork still fills the box via object-cover.
|
||||
*/
|
||||
aspect?: "square" | "video" | "poster";
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
|
||||
|
||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||
@@ -179,7 +186,14 @@
|
||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||
);
|
||||
|
||||
const FIXED_ASPECT = {
|
||||
square: "aspect-square",
|
||||
video: "aspect-video",
|
||||
poster: "aspect-[2/3]",
|
||||
} as const;
|
||||
|
||||
const aspectRatio = $derived(() => {
|
||||
if (aspect) return FIXED_ASPECT[aspect];
|
||||
if ("kind" in item) {
|
||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { seasonAnchorId } from "./seriesNavigation";
|
||||
|
||||
@@ -65,18 +66,26 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Season info -->
|
||||
<!-- Season info.
|
||||
|
||||
The header stacks on narrow screens and only shares a row from `sm` up.
|
||||
Three action buttons and a season title cannot both fit across a phone,
|
||||
and side-by-side they ended up overlapping. -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<!-- The whole title block toggles the season open/closed. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="{anchor}-episodes"
|
||||
class="flex-1 min-w-0 text-left group/season"
|
||||
class="min-w-0 sm:flex-1 text-left group/season"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
|
||||
a flex item will not shrink below its content width without it, so
|
||||
a long season name grew the row instead of ellipsising and ran
|
||||
under the buttons. -->
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
||||
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
||||
@@ -88,7 +97,7 @@
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="truncate">{seasonName}</span>
|
||||
<span class="truncate min-w-0">{seasonName}</span>
|
||||
{#if holdsCurrentEpisode}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
@@ -120,8 +129,9 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Per-season actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<!-- Per-season actions. `self-start` keeps them level with the title on
|
||||
wide rows; on a stacked phone layout they sit under it. -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2 self-start">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
@@ -130,6 +140,13 @@
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={season.id}
|
||||
watched={watchedCount === episodeCount && episodeCount > 0}
|
||||
scope="season"
|
||||
size="sm"
|
||||
onChanged={onHistoryCleared}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={season.id}
|
||||
itemName={seasonName}
|
||||
@@ -151,6 +168,7 @@
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
current={episode.id === currentEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
onWatchedChanged={onHistoryCleared}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
Mark an episode, season or series watched — or unwatched again.
|
||||
|
||||
The backend already had both halves (`mark_played` / `clear_watch_history`,
|
||||
both recursive over a container on the server) and the sync queue already
|
||||
replayed the first; nothing in the UI had ever called them, so the only way to
|
||||
mark something watched was to sit through it. This is that control.
|
||||
|
||||
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
|
||||
this series", confirms, and needs the server — this is an everyday toggle: no
|
||||
confirmation, and it works offline by queueing, in both directions.
|
||||
|
||||
TRACES: UR-073 | DR-158
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
|
||||
interface Props {
|
||||
/** Episode, season or series id. */
|
||||
itemId: string;
|
||||
/** Current watched state, as the caller knows it. */
|
||||
watched: boolean;
|
||||
/** What is being marked, for the tooltip wording. */
|
||||
scope: "episode" | "season" | "series";
|
||||
size?: "sm" | "lg";
|
||||
/** Show a text label beside the icon rather than icon-only. */
|
||||
showLabel?: boolean;
|
||||
/** Called after a successful toggle so the caller can reload. */
|
||||
onChanged?: (watched: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
itemId,
|
||||
watched,
|
||||
scope,
|
||||
size = "lg",
|
||||
showLabel = false,
|
||||
onChanged,
|
||||
}: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
// Optimistic state: the caller's `watched` prop only catches up once it has
|
||||
// reloaded from the repository, which on a season means a round trip. Without
|
||||
// this the button visibly ignores the first tap.
|
||||
let optimistic = $state<boolean | null>(null);
|
||||
const isWatched = $derived(optimistic ?? watched);
|
||||
|
||||
// A new item in the same slot (scrolling a virtualised list, switching series)
|
||||
// must drop the previous item's optimistic state or it shows the wrong tick.
|
||||
$effect(() => {
|
||||
itemId;
|
||||
optimistic = null;
|
||||
});
|
||||
|
||||
const subject = $derived(
|
||||
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
|
||||
);
|
||||
const label = $derived(isWatched ? "Watched" : "Mark watched");
|
||||
const title = $derived(
|
||||
isWatched
|
||||
? `Mark this ${subject} unwatched`
|
||||
: scope === "episode"
|
||||
? "Mark this episode watched"
|
||||
: `Mark every episode in this ${subject} watched`
|
||||
);
|
||||
|
||||
async function handleClick() {
|
||||
if (busy) return;
|
||||
|
||||
const next = !isWatched;
|
||||
busy = true;
|
||||
optimistic = next;
|
||||
try {
|
||||
if (next) {
|
||||
await syncService.queueMarkPlayed(itemId);
|
||||
} else {
|
||||
await syncService.queueMarkUnplayed(itemId);
|
||||
}
|
||||
onChanged?.(next);
|
||||
} catch (e) {
|
||||
// Put the button back where it was — the change did not happen.
|
||||
optimistic = null;
|
||||
console.error("Failed to change watched state:", e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClick}
|
||||
disabled={busy}
|
||||
{title}
|
||||
aria-label={title}
|
||||
aria-pressed={isWatched}
|
||||
class="rounded-lg font-medium flex items-center gap-2 transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
{isWatched
|
||||
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
|
||||
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
|
||||
>
|
||||
{#if busy}
|
||||
<div
|
||||
class="border-2 border-current border-t-transparent rounded-full animate-spin
|
||||
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
||||
></div>
|
||||
{:else if isWatched}
|
||||
<!-- Filled check: this one is done. -->
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
|
||||
6.4-6.4L18.4 8.8l-7.8 7.8z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Outline check: available, not yet done. -->
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if showLabel}
|
||||
<span>{busy ? "Saving…" : label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -1,9 +1,11 @@
|
||||
// TRACES: UR-062 | DR-102, DR-103, DR-142 | UT-136
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import {
|
||||
seasonAnchorId,
|
||||
seasonRedirectTarget,
|
||||
episodeFocusHref,
|
||||
episodeRedirectTarget,
|
||||
seriesPlayHref,
|
||||
seriesPlayLabel,
|
||||
groupEpisodesBySeason,
|
||||
@@ -103,6 +105,17 @@ describe("episodeFocusHref", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("episodeRedirectTarget", () => {
|
||||
it("sends a bare episode page to the episode inside its series", () => {
|
||||
expect(episodeRedirectTarget(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
|
||||
});
|
||||
|
||||
it("does not redirect an episode that has no series to fall back on", () => {
|
||||
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
|
||||
expect(episodeRedirectTarget(orphan)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupEpisodesBySeason", () => {
|
||||
it("groups episodes under their season headers, in season order", () => {
|
||||
const seasons = [seasonHeader(2), seasonHeader(1)];
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// lives in Rust (`repository_get_series_current_episode`); this module only
|
||||
// renders and routes around the answer.
|
||||
//
|
||||
// TRACES: UR-062 | DR-102, DR-103
|
||||
// TRACES: UR-062 | DR-102, DR-103, DR-142
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
export interface SeasonData {
|
||||
@@ -57,6 +57,21 @@ export function episodeFocusHref(episode: MediaItem): string {
|
||||
return `/library/${episode.seriesId}?episode=${episode.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a bare `/library/<episodeId>` should actually land — the same rule
|
||||
* seasons follow (DR-103). An episode is never a page of its own, so a deep
|
||||
* link, a stale bookmark, or any caller that missed `episodeFocusHref` is
|
||||
* redirected into the series' Episode Focus View.
|
||||
*
|
||||
* Returns `null` for an episode with no `seriesId` (a deep link into a stale
|
||||
* cache): there is nothing to redirect *to*, so the caller renders the Focus
|
||||
* View series-less rather than stranding the user (ux-flows §5B.1).
|
||||
*/
|
||||
export function episodeRedirectTarget(episode: MediaItem): string | null {
|
||||
if (!episode.seriesId) return null;
|
||||
return episodeFocusHref(episode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the series hero button goes.
|
||||
*
|
||||
|
||||
@@ -23,6 +23,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That is the default again (DR-172,
|
||||
// after native video shipped as audio with no picture), so this mock now agrees
|
||||
// with the default rather than opposing it — kept explicit so the tests state
|
||||
// which path they guard instead of inheriting whatever the default happens to be.
|
||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||
return {
|
||||
...actual,
|
||||
experimentalNativeVideo: {
|
||||
subscribe: (run: (v: boolean) => void) => {
|
||||
run(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
@@ -61,6 +82,10 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
|
||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||
// The player loads the streaming-quality picker on mount; without these the
|
||||
// mock throws and every test in the file fails before it starts.
|
||||
playerGetStreamingQualities: vi.fn(async () => []),
|
||||
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { onMount, onDestroy, tick, untrack } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { JRayActor } from "$lib/api/bindings";
|
||||
import type { JRayActor, StreamingQuality } from "$lib/api/bindings";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Hls from "hls.js";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -14,13 +14,37 @@
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { videoFitClass } from "./videoFit";
|
||||
import {
|
||||
resolveSubtitleTracks,
|
||||
reconcileSelectedSubtitle,
|
||||
videoCrossOriginMode,
|
||||
nativeSubtitleTracks,
|
||||
nativeSubtitleArrayIndex,
|
||||
type RenderableSubtitleTrack,
|
||||
} from "./subtitleTracks";
|
||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||
import { playbackPosition, playerState } from "$lib/stores/player";
|
||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||
import { playerController } from "$lib/player";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
import {
|
||||
createAdapter,
|
||||
Html5PlayerAdapter,
|
||||
type PlayerAdapter,
|
||||
type Html5ElementBridge,
|
||||
} from "$lib/player/adapters";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
||||
import {
|
||||
enableNativeVideoCompositing,
|
||||
disableNativeVideoCompositing,
|
||||
} from "$lib/utils/videoSurface";
|
||||
import {
|
||||
isPipSupported,
|
||||
enterPip,
|
||||
setAutoEnterEnabled,
|
||||
setHtml5VideoState,
|
||||
} from "$lib/utils/pictureInPicture";
|
||||
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
|
||||
import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
@@ -91,8 +115,41 @@
|
||||
endedFired = true;
|
||||
onEnded?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep native's picture-in-picture state in step with the `<video>` element.
|
||||
*
|
||||
* PiP is driven by the Activity, and it only ever knew about the native
|
||||
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which at the
|
||||
* time defaulted to off. So in the then-shipping configuration nothing
|
||||
* satisfied its "is a video playing?" check and the PiP button did nothing at
|
||||
* all. Reporting the element gives it a surface it can shrink into, and still
|
||||
* has to: the flag defaults to on now (DR-161) but a user who turns it off is
|
||||
* back on the element. (UR-041, DR-160, DR-161)
|
||||
*/
|
||||
function reportPipVideoState() {
|
||||
if (!useHtml5Element || !videoElement) {
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
return;
|
||||
}
|
||||
setHtml5VideoState(
|
||||
true,
|
||||
videoElement.videoWidth,
|
||||
videoElement.videoHeight,
|
||||
isPlaying
|
||||
);
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
/**
|
||||
* True while the Activity is in picture-in-picture.
|
||||
*
|
||||
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
|
||||
* itself down to the video — controls, header and gradients would otherwise be
|
||||
* rendered into a window a couple of inches wide. (UR-041, DR-160)
|
||||
*/
|
||||
let isInPip = $state(false);
|
||||
let pipListenerCleanup: (() => void) | null = null;
|
||||
let showSleepTimerModal = $state(false);
|
||||
let isBuffering = $state(false);
|
||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -158,7 +215,10 @@
|
||||
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
|
||||
// registers the adapter with the facade so control intents — from UI OR from a
|
||||
// backend control event (lockscreen/remote/sleep) — reach this element.
|
||||
let playerAdapter: Html5PlayerAdapter | null = null;
|
||||
// Widened from Html5PlayerAdapter: the native path registers a
|
||||
// NativePlayerAdapter here. Element-coupled work is guarded by
|
||||
// `useHtml5Element`, not by narrowing this type.
|
||||
let playerAdapter: PlayerAdapter | null = null;
|
||||
|
||||
function tearDownHls() {
|
||||
if (hls) {
|
||||
@@ -186,6 +246,14 @@
|
||||
let showSubtitleMenu = $state(false);
|
||||
let selectedSubtitleIndex = $state<number | null>(null);
|
||||
|
||||
// Streaming bandwidth ceiling. The ladder and the current value both come from
|
||||
// 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);
|
||||
|
||||
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
||||
let videoDuration = $state(0);
|
||||
|
||||
@@ -275,6 +343,61 @@
|
||||
return tracks;
|
||||
});
|
||||
|
||||
// ===== Subtitle <track> sources for the HTML5 element (Linux/WebKitGTK) =====
|
||||
// Resolved asynchronously into state and only then rendered. The URLs come
|
||||
// from an async command, so they must never be bound to `src` directly — the
|
||||
// original markup did exactly that and put "[object Promise]" on every track,
|
||||
// which is why the whole block ended up commented out (and why selecting a
|
||||
// subtitle did nothing: with no <track> children the element has no
|
||||
// textTracks for the adapter to switch on).
|
||||
// TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||
let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
|
||||
|
||||
// The subtitle list actually handed to the native backend at load time
|
||||
// (Android/ExoPlayer). Kept because `player_set_subtitle_track` takes a
|
||||
// *position in this list*, not a Jellyfin stream index — see
|
||||
// nativeSubtitleArrayIndex. It is written once, from onMount, before the
|
||||
// play request; it is not derived, because the request is what fixed the
|
||||
// backend's idea of the track order.
|
||||
// TRACES: UR-020 | IR-016 | UT-147
|
||||
let sentSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
|
||||
|
||||
// Cross-origin <track> fetches use the media element's CORS setting; see
|
||||
// videoCrossOriginMode for why this is opt-in and same-origin-only.
|
||||
const videoCrossOrigin = $derived(
|
||||
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const streams = media?.mediaStreams ?? null;
|
||||
const itemId = media?.id;
|
||||
const sourceId = mediaSourceId;
|
||||
// Native (ExoPlayer) mode renders subtitles itself; the element has none.
|
||||
if (!useHtml5Element || !itemId || !sourceId) {
|
||||
renderedSubtitleTracks = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const tracks = await resolveSubtitleTracks(streams, (index) => getSubtitleUrl(index));
|
||||
if (cancelled) return;
|
||||
renderedSubtitleTracks = tracks;
|
||||
// Keep the menu's checkmark and the element's text tracks in agreement:
|
||||
// a selection that no longer resolves collapses to "Off".
|
||||
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
|
||||
selectedSubtitleIndex = selected;
|
||||
// The <track> children were just (re)created, so re-apply the selection to
|
||||
// the new TextTrack objects — otherwise a surviving selection shows nothing.
|
||||
await tick();
|
||||
if (!cancelled) applySubtitleToElement(selected);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
|
||||
let lastStreamUrlProp = $state("");
|
||||
|
||||
@@ -527,6 +650,27 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
onMount(() => {
|
||||
Promise.all([
|
||||
commands.playerGetStreamingQualities(),
|
||||
commands.playerGetVideoSettings(),
|
||||
])
|
||||
.then(([qualities, settings]) => {
|
||||
streamingQualities = qualities;
|
||||
// Optional on the wire (serde default) — absent means uncapped.
|
||||
selectedQuality = settings.streamingQuality ?? "original";
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[VideoPlayer] Failed to load streaming qualities:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Set up progress reporting interval
|
||||
onMount(async () => {
|
||||
// Background-audio lifecycle listeners MUST be registered synchronously —
|
||||
@@ -544,28 +688,23 @@
|
||||
console.log("[VideoPlayer] Initializing player for:", media.name);
|
||||
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
||||
|
||||
// Build subtitle tracks for native player
|
||||
const subtitleTracks = [];
|
||||
if (media.mediaStreams && mediaSourceId) {
|
||||
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
|
||||
for (const sub of subtitles) {
|
||||
try {
|
||||
const url = await getSubtitleUrl(sub.index);
|
||||
if (url) {
|
||||
subtitleTracks.push({
|
||||
index: sub.index,
|
||||
url: url,
|
||||
language: sub.language || null,
|
||||
label: sub.displayTitle || sub.language || `Track ${sub.index}`,
|
||||
mime_type: "text/vtt" // Jellyfin converts to WebVTT
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[VideoPlayer] Failed to build subtitle URL for track ${sub.index}:`, err);
|
||||
}
|
||||
}
|
||||
console.log(`[VideoPlayer] Built ${subtitleTracks.length} subtitle tracks for native player`);
|
||||
}
|
||||
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
||||
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
||||
// MediaItem.SubtitleConfigurations, which have to exist before
|
||||
// prepare() — there is no way to add one to a loaded item afterwards.
|
||||
//
|
||||
// Awaiting here is safe despite the native-mode pitfall: that rule is
|
||||
// about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
|
||||
// which throw lifecycle_outside_component and used to be misread as an
|
||||
// init failure. Nothing is registered here, and the background-audio
|
||||
// subscriptions above already ran synchronously. resolveSubtitleTracks
|
||||
// fans the requests out in parallel, so this costs one round trip, not
|
||||
// one per subtitle stream as the old serial loop did.
|
||||
// TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||
sentSubtitleTracks = mediaSourceId
|
||||
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
||||
: [];
|
||||
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
||||
|
||||
// Call Rust backend to start playback
|
||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||
@@ -576,6 +715,10 @@
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding: needsTranscoding,
|
||||
// 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.
|
||||
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||
});
|
||||
|
||||
// Rust tells us which backend it's using
|
||||
@@ -583,15 +726,16 @@
|
||||
backendChosen = true;
|
||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||
|
||||
// INTERIM (until the video-player API refactor lands): always render
|
||||
// through the webview HTML5 element, including Android. The native
|
||||
// ExoPlayer SurfaceView sits behind an opaque webview and has never
|
||||
// actually been visible (an init bug kept the app on the HTML5 path
|
||||
// since the POC), so true native mode plays audio behind a frozen
|
||||
// picture. Stop the native backend and let the webview own playback,
|
||||
// matching Linux behavior and avoiding dual audio.
|
||||
if (!useHtml5Element) {
|
||||
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
|
||||
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
||||
// the user opted into the experimental native path; otherwise fall back
|
||||
// to the webview element, which is what shipped by default.
|
||||
//
|
||||
// The flag is a suppressor, never a promoter — see createAdapter(). When
|
||||
// it is off we must also stop the native backend that player_play_item
|
||||
// just started, or ExoPlayer and the <video> element both decode the
|
||||
// same stream and the audio doubles.
|
||||
if (!useHtml5Element && !$experimentalNativeVideo) {
|
||||
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||
useHtml5Element = true;
|
||||
try {
|
||||
await commands.playerStop();
|
||||
@@ -599,6 +743,14 @@
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
||||
}
|
||||
} else if (!useHtml5Element) {
|
||||
// Native path: clear the opaque layers between the viewport and the
|
||||
// ExoPlayer SurfaceView (webview widget background + page background).
|
||||
// Paired with disableNativeVideoCompositing() in the teardown path —
|
||||
// leaving this on renders the rest of the app over a transparent
|
||||
// window.
|
||||
console.log("[VideoPlayer] Using native ExoPlayer video surface");
|
||||
enableNativeVideoCompositing();
|
||||
}
|
||||
|
||||
// If using HTML5 element for non-transcoded content, stop the backend player
|
||||
@@ -617,16 +769,61 @@
|
||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||
}
|
||||
|
||||
// Register the HTML5 player adapter with the facade so control intents
|
||||
// (UI or backend lockscreen/remote/sleep events) route to this element.
|
||||
if (useHtml5Element) {
|
||||
// Register the adapter with the facade so control intents (UI, or a
|
||||
// backend lockscreen/remote/sleep event) route to whatever is actually
|
||||
// rendering. Both paths need one: the native adapter forwards control
|
||||
// intents to ExoPlayer over IPC.
|
||||
{
|
||||
const host = createRustReportHost(media.id, {
|
||||
onEnded: () => notifyEnded(),
|
||||
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
||||
});
|
||||
playerAdapter = new Html5PlayerAdapter(host, adapterBridge);
|
||||
playerAdapter = createAdapter({
|
||||
backendKind: useHtml5Element ? "html5" : "native",
|
||||
host,
|
||||
bridge: adapterBridge,
|
||||
// useHtml5Element is already the resolved decision above, so the
|
||||
// flag has had its say; pass it through for the invariant check.
|
||||
experimentalNativeVideo: $experimentalNativeVideo,
|
||||
});
|
||||
// No-op for the native adapter, which owns no DOM element.
|
||||
playerAdapter.attach(videoElement);
|
||||
playerController.setActiveAdapter(playerAdapter);
|
||||
|
||||
// The native (ExoPlayer) path has no <video> element, so `canplay`
|
||||
// never fires and the handleCanPlay initial-seek below never runs —
|
||||
// resume-at-position played from the beginning on Android. Hand the
|
||||
// resume point to the adapter, which issues the backend seek.
|
||||
//
|
||||
// HTML5 keeps its existing element-driven seek: seeking before the
|
||||
// element has metadata is clamped back to 0, which is precisely what
|
||||
// handleCanPlay waits for.
|
||||
// TRACES: UR-005 | DR-004, DR-028
|
||||
if (!useHtml5Element) {
|
||||
hasPerformedInitialSeek = true; // native path owns the initial seek
|
||||
lastAppliedInitialPosition = initialPosition;
|
||||
await playerAdapter.load(currentStreamUrl, {
|
||||
mediaId: media.id,
|
||||
mediaSourceId: mediaSourceId ?? null,
|
||||
needsTranscoding,
|
||||
initialPosition: initialPosition ?? 0,
|
||||
isLive,
|
||||
audioTrackIndex: null,
|
||||
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
|
||||
// ExoPlayer already received these as SubtitleConfigurations via
|
||||
// player_play_item; mapped to the adapter shape for the contract.
|
||||
subtitleTracks: sentSubtitleTracks.map((t) => ({
|
||||
index: t.streamIndex,
|
||||
url: t.url,
|
||||
language: t.srclang,
|
||||
label: t.label,
|
||||
mimeType: "text/vtt",
|
||||
})),
|
||||
});
|
||||
if (initialPosition && initialPosition > 0 && !isLive) {
|
||||
currentTime = initialPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!useHtml5Element) {
|
||||
@@ -681,6 +878,24 @@
|
||||
// Load series audio preference (for TV shows)
|
||||
await loadSeriesAudioPreference();
|
||||
|
||||
// PiP: keep native's view of the `<video>` current, and react to the window
|
||||
// shrinking. The listeners are torn down in onDestroy. (DR-160)
|
||||
reportPipVideoState();
|
||||
const onPipEntered = () => (isInPip = true);
|
||||
const onPipExited = () => (isInPip = false);
|
||||
const onPipPlay = () => void videoElement?.play().catch(() => {});
|
||||
const onPipPause = () => videoElement?.pause();
|
||||
window.addEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.addEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.addEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.addEventListener("jellytau-pip-pause", onPipPause);
|
||||
pipListenerCleanup = () => {
|
||||
window.removeEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.removeEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.removeEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.removeEventListener("jellytau-pip-pause", onPipPause);
|
||||
};
|
||||
|
||||
// Report progress every 10 seconds while playing. Live streams have no
|
||||
// meaningful position to report, so skip progress reporting entirely.
|
||||
if (!isLive) {
|
||||
@@ -718,6 +933,24 @@
|
||||
});
|
||||
|
||||
onDestroy(async () => {
|
||||
// FIRST, and synchronously: restore the opaque webview/page backgrounds.
|
||||
//
|
||||
// This callback is async, so anything after an `await` may run a frame or
|
||||
// more later. Leaving the window transparent for even that long shows the
|
||||
// launcher/wallpaper through the app as the player unwinds. Unconditional
|
||||
// and idempotent — a no-op when compositing was never enabled.
|
||||
disableNativeVideoCompositing();
|
||||
|
||||
// Same reasoning for the system bars: they belong to the Activity, not to
|
||||
// this component, so a player torn down while immersive would leave every
|
||||
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
|
||||
exitImmersive();
|
||||
|
||||
// The `<video>` is going away, so PiP must stop being offered over it.
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
pipListenerCleanup?.();
|
||||
pipListenerCleanup = null;
|
||||
|
||||
// Stop RAF loop
|
||||
stopTimeUpdates();
|
||||
|
||||
@@ -831,6 +1064,9 @@
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
console.log("[VideoPlayer] loadedmetadata event");
|
||||
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||
reportPipVideoState();
|
||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||
@@ -1102,6 +1338,8 @@
|
||||
function handlePlay() {
|
||||
isPlaying = true;
|
||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||
// PiP's play/pause action reflects this. (DR-160)
|
||||
reportPipVideoState();
|
||||
// Mirror the DOM state into the Rust PlayerController so it is the single
|
||||
// source of truth for HTML5 video (the <video> lives in the webview, which
|
||||
// Rust cannot observe directly). See html5Adapter.ts.
|
||||
@@ -1131,6 +1369,7 @@
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||
// Report progress when paused
|
||||
@@ -1391,12 +1630,24 @@
|
||||
let pendingForegroundSeek: number | null = null;
|
||||
let pendingForegroundPlay = false;
|
||||
|
||||
// On Android the Activity owns the system bars, and requestFullscreen() cannot
|
||||
// reach them — the WebView already spans the window under an edge-to-edge
|
||||
// Activity, so on its own it left the status and navigation bars painted over
|
||||
// the video. The native bridge is what actually makes fullscreen full screen;
|
||||
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
||||
});
|
||||
enterImmersive();
|
||||
isFullscreen = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
exitImmersive();
|
||||
isFullscreen = false;
|
||||
}
|
||||
}
|
||||
@@ -1456,7 +1707,9 @@
|
||||
toggleFullscreen();
|
||||
} else if (e.key === "Escape") {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen();
|
||||
// Through the toggle, not document.exitFullscreen() directly: leaving
|
||||
// fullscreen also has to restore the system bars and clear the flag.
|
||||
toggleFullscreen();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
@@ -1666,45 +1919,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleQualityMenu() {
|
||||
showQualityMenu = !showQualityMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open the current stream at a different bandwidth ceiling.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async function selectQuality(quality: StreamingQuality) {
|
||||
showQualityMenu = false;
|
||||
if (quality === selectedQuality || changingQuality) return;
|
||||
|
||||
const previous = selectedQuality;
|
||||
selectedQuality = quality;
|
||||
changingQuality = true;
|
||||
try {
|
||||
stopTimeUpdates();
|
||||
await playerController.setStreamQuality(
|
||||
quality,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex
|
||||
);
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
}
|
||||
console.log("[VideoPlayer] Streaming quality changed:", quality);
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to change streaming quality:", err);
|
||||
selectedQuality = previous;
|
||||
} finally {
|
||||
changingQuality = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSubtitleMenu() {
|
||||
showSubtitleMenu = !showSubtitleMenu;
|
||||
}
|
||||
|
||||
async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) {
|
||||
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
||||
/**
|
||||
* Show exactly one (or no) text track on the HTML5 element. `null` disables
|
||||
* every track, which is what the menu's "Off" entry means.
|
||||
*
|
||||
* TRACES: UR-020 | DR-023
|
||||
*/
|
||||
function applySubtitleToElement(streamIndex: number | null) {
|
||||
if (!useHtml5Element || !videoElement || !videoElement.textTracks) return;
|
||||
|
||||
// Disable all text tracks first, so "Off" genuinely turns subtitles off.
|
||||
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
||||
videoElement.textTracks[i].mode = "disabled";
|
||||
}
|
||||
if (streamIndex === null) return;
|
||||
|
||||
// Find the corresponding track element by stream index.
|
||||
videoElement.querySelectorAll("track").forEach((track) => {
|
||||
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||
if (trackStreamIndex === streamIndex && track.track) {
|
||||
track.track.mode = "showing";
|
||||
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the menu's choice. `streamIndex` is always the Jellyfin media-stream
|
||||
* index (or `null` for "Off") — the UI speaks stream indices throughout.
|
||||
*
|
||||
* The native backend does not: `player_set_subtitle_track(n)` reaches
|
||||
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes ExoPlayer's text track
|
||||
* groups, i.e. the position of the sideloaded subtitle configuration. That
|
||||
* position is derived from `sentSubtitleTracks` — the exact array sent with
|
||||
* the play request — and not from the menu's row number, which counts every
|
||||
* subtitle *stream* including ones whose URL never resolved and so were never
|
||||
* sideloaded.
|
||||
*
|
||||
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
||||
*/
|
||||
async function selectSubtitle(streamIndex: number | null) {
|
||||
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
|
||||
selectedSubtitleIndex = streamIndex;
|
||||
showSubtitleMenu = false;
|
||||
|
||||
// For HTML5 video element, update the text tracks
|
||||
if (useHtml5Element && videoElement && videoElement.textTracks) {
|
||||
// Disable all text tracks first
|
||||
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
||||
videoElement.textTracks[i].mode = "disabled";
|
||||
}
|
||||
|
||||
// Enable the selected track if not null
|
||||
if (streamIndex !== null) {
|
||||
// Find the corresponding track element by stream index
|
||||
const tracks = videoElement.querySelectorAll("track");
|
||||
tracks.forEach((track) => {
|
||||
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||
if (trackStreamIndex === streamIndex) {
|
||||
const textTrack = track.track;
|
||||
if (textTrack) {
|
||||
textTrack.mode = "showing";
|
||||
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (!useHtml5Element) {
|
||||
if (useHtml5Element) {
|
||||
applySubtitleToElement(streamIndex);
|
||||
} else {
|
||||
// For native backend (Android), send command to change subtitle track
|
||||
try {
|
||||
// Use array index for ExoPlayer (0-based position in subtitle tracks array)
|
||||
// If streamIndex is null (disable), pass null; otherwise use arrayIndex
|
||||
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
|
||||
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||
await commands.playerSetSubtitleTrack(indexToUse);
|
||||
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse);
|
||||
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||
} catch (error) {
|
||||
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
||||
}
|
||||
@@ -1743,6 +2055,7 @@
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
style="filter: brightness({brightness})"
|
||||
@@ -1761,18 +2074,22 @@
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!-- Temporarily disabled to debug playback issues
|
||||
{#each subtitleTracks() as track}
|
||||
<!--
|
||||
Subtitles for the HTML5 path. `src` is a resolved string (see
|
||||
renderedSubtitleTracks); `data-stream-index` is what
|
||||
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
|
||||
attribute: a default track auto-shows, which would contradict the
|
||||
menu opening on "Off".
|
||||
-->
|
||||
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={getSubtitleUrl(track.index)}
|
||||
srclang={track.language || "unknown"}
|
||||
label={track.displayTitle || track.language || `Track ${track.index}`}
|
||||
data-stream-index={track.index}
|
||||
default={track.isDefault}
|
||||
src={track.url}
|
||||
srclang={track.srclang}
|
||||
label={track.label}
|
||||
data-stream-index={track.streamIndex}
|
||||
/>
|
||||
{/each}
|
||||
-->
|
||||
</video>
|
||||
{:else}
|
||||
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
||||
@@ -1930,8 +2247,8 @@
|
||||
style:padding-bottom="calc(1rem + var(--safe-bottom))"
|
||||
style:padding-left="calc(1rem + var(--safe-left))"
|
||||
style:padding-right="calc(1rem + var(--safe-right))"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
class:opacity-0={!showControls || isInPip}
|
||||
class:pointer-events-none={!showControls || isInPip}
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="mb-2">
|
||||
@@ -2039,6 +2356,48 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
|
||||
{#if streamingQualities.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleQualityMenu}
|
||||
class="text-white hover:text-gray-300 disabled:opacity-50"
|
||||
disabled={changingQuality}
|
||||
aria-label="Select streaming quality"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showQualityMenu}
|
||||
<div 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>
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => selectQuality(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 ? 'bg-white/20' : ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Subtitle Selection -->
|
||||
{#if subtitleTracks().length > 0}
|
||||
<div class="relative">
|
||||
@@ -2072,9 +2431,9 @@
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Subtitle tracks -->
|
||||
{#each subtitleTracks() as track, i}
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index, i)}
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
|
||||
@@ -87,6 +87,7 @@ vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||
isPipSupported: () => false,
|
||||
enterPip: vi.fn(),
|
||||
setAutoEnterEnabled: vi.fn(),
|
||||
setHtml5VideoState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
|
||||
@@ -26,6 +26,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That is the default again (DR-172,
|
||||
// after native video shipped as audio with no picture), so this mock now agrees
|
||||
// with the default rather than opposing it — kept explicit so the tests state
|
||||
// which path they guard instead of inheriting whatever the default happens to be.
|
||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||
return {
|
||||
...actual,
|
||||
experimentalNativeVideo: {
|
||||
subscribe: (run: (v: boolean) => void) => {
|
||||
run(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
@@ -63,6 +84,10 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
playerCancelSleepTimer: vi.fn(async () => ({})),
|
||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||
// The player loads the streaming-quality picker on mount; without these the
|
||||
// mock throws and every test in the file fails before it starts.
|
||||
playerGetStreamingQualities: vi.fn(async () => []),
|
||||
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
subtitleStreamsOf,
|
||||
subtitleTrackLabel,
|
||||
resolveSubtitleTracks,
|
||||
reconcileSelectedSubtitle,
|
||||
videoCrossOriginMode,
|
||||
nativeSubtitleTracks,
|
||||
nativeSubtitleArrayIndex,
|
||||
type SubtitleStreamLike,
|
||||
} from "./subtitleTracks";
|
||||
|
||||
/**
|
||||
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
|
||||
*
|
||||
* TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||
*
|
||||
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
|
||||
* block was commented out "to debug playback issues"), so
|
||||
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
|
||||
* the subtitle menu was inert on Linux. The reason it had to be disabled is
|
||||
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
|
||||
* *Promise* returned by an async function to the attribute, so every track's src
|
||||
* stringified to "[object Promise]", an unloadable resource hanging off the
|
||||
* media element.
|
||||
*
|
||||
* So the fix has two halves and both are tested here: URLs must be resolved into
|
||||
* plain strings *before* they reach the markup, and the markup must actually
|
||||
* render the tracks (with the `data-stream-index` the adapter matches on).
|
||||
*/
|
||||
|
||||
const SUBS: SubtitleStreamLike[] = [
|
||||
{ index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
|
||||
{ index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
|
||||
];
|
||||
|
||||
const STREAMS: SubtitleStreamLike[] = [
|
||||
{ index: 0, kind: "video", language: null, displayTitle: "1080p" },
|
||||
{ index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
|
||||
...SUBS,
|
||||
];
|
||||
|
||||
const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;
|
||||
|
||||
describe("subtitleStreamsOf", () => {
|
||||
it("keeps only subtitle streams, in stream order", () => {
|
||||
expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("tolerates missing media streams", () => {
|
||||
expect(subtitleStreamsOf(null)).toEqual([]);
|
||||
expect(subtitleStreamsOf(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtitleTrackLabel", () => {
|
||||
it("prefers the display title, then language, then the index", () => {
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
|
||||
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTracks", () => {
|
||||
it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
|
||||
const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
||||
|
||||
expect(tracks).toHaveLength(2);
|
||||
for (const track of tracks) {
|
||||
expect(typeof track.url).toBe("string");
|
||||
// The exact regression: a Promise bound to src stringifies to this.
|
||||
expect(String(track.url)).not.toContain("[object Promise]");
|
||||
expect(track.url).toContain("subtitles.vtt");
|
||||
}
|
||||
// The adapter matches <track> elements by data-stream-index, so the stream
|
||||
// index has to survive resolution.
|
||||
expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
|
||||
expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
|
||||
expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
|
||||
expect(tracks[0].isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
|
||||
if (i === 2) throw new Error("no repository");
|
||||
return url(i);
|
||||
});
|
||||
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("drops empty and non-string URLs", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
|
||||
i === 2 ? " " : (undefined as unknown as string),
|
||||
);
|
||||
expect(tracks).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing when there are no subtitle streams", async () => {
|
||||
expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
|
||||
expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileSelectedSubtitle", () => {
|
||||
it("starts off (null) and keeps 'off' selectable", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a selection that is still renderable", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
|
||||
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("never auto-selects the server's default track", async () => {
|
||||
// The menu opens on "Off" and a <track default> would auto-show, so the UI
|
||||
// would claim subtitles are off while they are burned over the picture.
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(tracks[0].isDefault).toBe(true);
|
||||
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoCrossOriginMode", () => {
|
||||
it("opts into CORS for a server stream that has subtitles", () => {
|
||||
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
|
||||
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("leaves a local/offline source alone so playback cannot regress", () => {
|
||||
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
|
||||
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays out of the way when there is nothing to load", () => {
|
||||
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
|
||||
expect(videoCrossOriginMode("", 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
|
||||
// Same answer before and after the async URL resolution completes.
|
||||
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||
expect(before).toBe(after);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Subtitles on the Android / ExoPlayer native path.
|
||||
*
|
||||
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||
*
|
||||
* The bug this guards: VideoPlayer built a fully-resolved subtitle array in
|
||||
* onMount and then never sent it — `commands.playerPlayItem({...})` passed only
|
||||
* streamUrl/title/id/videoCodec/needsTranscoding — so every MediaItem reached
|
||||
* ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged
|
||||
* "Invalid subtitle track index".
|
||||
*
|
||||
* And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track
|
||||
* groups*, i.e. the position of the sideloaded configuration — not the Jellyfin
|
||||
* stream index. The menu used to pass its own row position, which is a position
|
||||
* in the *unresolved* stream list; the moment one subtitle URL failed to
|
||||
* resolve, the two lists diverged and every track below the gap selected the
|
||||
* wrong subtitle.
|
||||
*/
|
||||
describe("nativeSubtitleTracks", () => {
|
||||
it("maps to the wire shape Rust deserializes and Kotlin parses", async () => {
|
||||
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
const payload = nativeSubtitleTracks(resolved);
|
||||
|
||||
expect(payload).toHaveLength(2);
|
||||
// Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps
|
||||
// snake_case for exactly that reason, and so does the generated binding.
|
||||
for (const track of payload) {
|
||||
expect(Object.keys(track).sort()).toEqual(
|
||||
["index", "label", "language", "mime_type", "url"].sort(),
|
||||
);
|
||||
expect(track).not.toHaveProperty("mimeType");
|
||||
expect(track.mime_type).toBe("text/vtt");
|
||||
}
|
||||
// Jellyfin serves every subtitle stream as WebVTT here, and the stream index
|
||||
// rides along so the UI can keep talking in stream indices.
|
||||
expect(payload.map((t) => t.index)).toEqual([2, 3]);
|
||||
expect(payload[0].url).toContain("subtitles.vtt");
|
||||
expect(payload[0].language).toBe("eng");
|
||||
expect(payload[0].label).toBe("English (SRT)");
|
||||
});
|
||||
|
||||
it("preserves stream order, because that order is the selection index", async () => {
|
||||
const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
||||
expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual(
|
||||
resolved.map((t) => t.streamIndex),
|
||||
);
|
||||
});
|
||||
|
||||
it("has nothing to send when no subtitle URL resolved", async () => {
|
||||
expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]);
|
||||
expect(nativeSubtitleTracks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries a null language/label through rather than inventing one", () => {
|
||||
const payload = nativeSubtitleTracks([
|
||||
{ streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false },
|
||||
]);
|
||||
expect(payload[0].language).toBeNull();
|
||||
expect(payload[0].label).toBe("Track 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nativeSubtitleArrayIndex", () => {
|
||||
it("returns the position in the list that was actually sent, not the stream index", async () => {
|
||||
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0);
|
||||
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1);
|
||||
});
|
||||
|
||||
it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => {
|
||||
// Stream 2 has no URL, so it is not among the sideloaded configurations.
|
||||
// The menu's own row for stream 3 is position 1, but ExoPlayer only has one
|
||||
// text track group — position 0. Sending 1 would select nothing.
|
||||
const resolved = await resolveSubtitleTracks(SUBS, async (i) => {
|
||||
if (i === 2) throw new Error("no repository");
|
||||
return url(i);
|
||||
});
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0);
|
||||
});
|
||||
|
||||
it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => {
|
||||
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a track that was never sent to null rather than to a wrong position", async () => {
|
||||
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull();
|
||||
expect(nativeSubtitleArrayIndex([], 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, "VideoPlayer.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
it("renders <track> elements instead of leaving them commented out", () => {
|
||||
expect(source).not.toContain("Temporarily disabled to debug playback issues");
|
||||
expect(source).toMatch(/<track\b/);
|
||||
expect(source).toContain('kind="subtitles"');
|
||||
});
|
||||
|
||||
/** The rendered element, not a `<track>` mentioned in prose. */
|
||||
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
|
||||
|
||||
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
|
||||
expect(trackElement).toContain("data-stream-index");
|
||||
});
|
||||
|
||||
it("never binds the async getSubtitleUrl() Promise to src", () => {
|
||||
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
|
||||
});
|
||||
|
||||
it("does not mark any track default (a default track auto-shows)", () => {
|
||||
expect(trackElement).not.toMatch(/\bdefault=/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The half of the Android fix that lives in the component: the resolved list has
|
||||
* to actually be handed to `playerPlayItem`, and the index sent to the backend
|
||||
* has to be computed from that same list.
|
||||
*
|
||||
* TRACES: UR-020 | IR-016 | UT-147
|
||||
*/
|
||||
describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => {
|
||||
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
||||
|
||||
/** The playerPlayItem({...}) argument object. */
|
||||
const playItemCall = (() => {
|
||||
const start = source.indexOf("commands.playerPlayItem(");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return source.slice(start, source.indexOf("});", start) + 3);
|
||||
})();
|
||||
|
||||
it("sends the subtitle tracks it resolved", () => {
|
||||
expect(playItemCall).toMatch(/\bsubtitles:/);
|
||||
});
|
||||
|
||||
it("selects by position in the sent list, not by the menu's row number", () => {
|
||||
expect(source).toContain("nativeSubtitleArrayIndex");
|
||||
// The old code forwarded the `{#each}` index straight to the backend.
|
||||
expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
|
||||
//
|
||||
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
|
||||
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
|
||||
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
|
||||
// attribute and every `<track>` pointed at "[object Promise]". The whole block
|
||||
// was commented out rather than fixed, which left `<video>` with no text tracks
|
||||
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
|
||||
// `textTracks` list and the subtitle menu silently did nothing.
|
||||
//
|
||||
// The rule this module enforces: URLs are resolved to plain strings *here*, off
|
||||
// the render path, and only tracks that actually resolved are handed to the
|
||||
// markup.
|
||||
//
|
||||
// The Android / ExoPlayer native path shares this module (see
|
||||
// nativeSubtitleTracks / nativeSubtitleArrayIndex at the bottom): it needs the
|
||||
// exact same "resolve the URLs first, keep only what resolved" list, just handed
|
||||
// to Rust instead of to `<track>` elements.
|
||||
//
|
||||
// TRACES: UR-020 | DR-023, IR-016 | UT-143, UT-144, UT-147
|
||||
|
||||
import type { SubtitleTrack } from "$lib/api/bindings";
|
||||
|
||||
/**
|
||||
* The subset of `MediaStream` (from the generated bindings) this module needs.
|
||||
* Kept structural so tests do not have to build full binding objects.
|
||||
*/
|
||||
export interface SubtitleStreamLike {
|
||||
index: number;
|
||||
kind?: string | null;
|
||||
language?: string | null;
|
||||
displayTitle?: string | null;
|
||||
isDefault?: boolean;
|
||||
isForced?: boolean;
|
||||
}
|
||||
|
||||
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
|
||||
export interface RenderableSubtitleTrack {
|
||||
/** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
|
||||
streamIndex: number;
|
||||
/** Fully resolved WebVTT URL. Always a string, never a Promise. */
|
||||
url: string;
|
||||
srclang: string;
|
||||
label: string;
|
||||
/** Server's "default" flag — shown in the menu, never auto-enabled. */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** Subtitle streams of a media item, in stream order. */
|
||||
export function subtitleStreamsOf(
|
||||
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||
): SubtitleStreamLike[] {
|
||||
if (!streams) return [];
|
||||
return streams.filter((s) => s.kind === "subtitle");
|
||||
}
|
||||
|
||||
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
|
||||
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
|
||||
return stream.displayTitle || stream.language || `Track ${stream.index}`;
|
||||
}
|
||||
|
||||
/** A src we are willing to put on a `<track>`: a non-blank plain string. */
|
||||
function isRenderableUrl(url: unknown): url is string {
|
||||
return typeof url === "string" && url.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every subtitle stream's URL and return only the tracks that can be
|
||||
* rendered. `resolveUrl` failures are swallowed per track: one unavailable
|
||||
* subtitle must not cost the user the others, and a dead `src` on a media
|
||||
* element is exactly what made this block get disabled in the first place.
|
||||
*/
|
||||
export async function resolveSubtitleTracks(
|
||||
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||
resolveUrl: (streamIndex: number) => Promise<string>,
|
||||
): Promise<RenderableSubtitleTrack[]> {
|
||||
const subtitles = subtitleStreamsOf(streams);
|
||||
if (subtitles.length === 0) return [];
|
||||
|
||||
const resolved = await Promise.all(
|
||||
subtitles.map(async (stream) => {
|
||||
try {
|
||||
const url = await resolveUrl(stream.index);
|
||||
if (!isRenderableUrl(url)) return null;
|
||||
return {
|
||||
streamIndex: stream.index,
|
||||
url,
|
||||
srclang: stream.language || "und",
|
||||
label: subtitleTrackLabel(stream),
|
||||
isDefault: stream.isDefault === true,
|
||||
} satisfies RenderableSubtitleTrack;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection to keep once the rendered track list changes.
|
||||
*
|
||||
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
|
||||
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
|
||||
* markup deliberately omits the `default` attribute, which would auto-show the
|
||||
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
|
||||
* what is on screen, and it would change behaviour for every user who has never
|
||||
* asked for subtitles.
|
||||
*
|
||||
* A selection that is no longer renderable (new item, or a URL that failed to
|
||||
* resolve) collapses to off, so the menu's checkmark can never point at a track
|
||||
* that does not exist on the element.
|
||||
*/
|
||||
export function reconcileSelectedSubtitle(
|
||||
tracks: readonly RenderableSubtitleTrack[],
|
||||
selected: number | null,
|
||||
): number | null {
|
||||
if (selected === null) return null;
|
||||
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
|
||||
}
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `crossorigin` value for the `<video>` element, or undefined for none.
|
||||
*
|
||||
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
|
||||
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
|
||||
* element opts in. The webview page's origin is `tauri://localhost`, so every
|
||||
* subtitle served by Jellyfin is cross-origin.
|
||||
*
|
||||
* Opting in is only safe when the media itself comes from an http(s) server —
|
||||
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
|
||||
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
|
||||
* the attribute off: subtitles staying dark there is the status quo, whereas
|
||||
* forcing CORS onto the video fetch could break playback outright.
|
||||
*
|
||||
* Deliberately keyed on the *count of subtitle streams* rather than on the
|
||||
* resolved tracks: both inputs are known at first render, so the attribute is
|
||||
* decided before the element starts loading and never flips underneath an
|
||||
* in-flight media fetch.
|
||||
*/
|
||||
export function videoCrossOriginMode(
|
||||
streamUrl: string,
|
||||
subtitleStreamCount: number,
|
||||
): "anonymous" | undefined {
|
||||
if (subtitleStreamCount <= 0) return undefined;
|
||||
return originOf(streamUrl) ? "anonymous" : undefined;
|
||||
}
|
||||
|
||||
// ===== Native (Android / ExoPlayer) path ====================================
|
||||
//
|
||||
// The HTML5 element gets `<track>` children; the native backend instead gets the
|
||||
// list *up front*, as part of the play request, because ExoPlayer sideloads
|
||||
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
|
||||
// `prepare()`. There is no "add a subtitle later" — a track absent from the
|
||||
// MediaItem simply does not exist as far as the player is concerned.
|
||||
|
||||
/**
|
||||
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
||||
*
|
||||
* The element type is the *generated* `SubtitleTrack` binding on purpose, so
|
||||
* `bun run check` fails if the Rust struct's field names ever move. In
|
||||
* particular `mime_type` is snake_case and must stay that way: the very same
|
||||
* bytes are re-serialized across JNI in `player/android/mod.rs`, and
|
||||
* `JellyTauPlayer.load()` reads `optString("mime_type")`. Renaming it to
|
||||
* `mimeType` would not error anywhere — Kotlin would just silently fall back to
|
||||
* its default MIME type for every track.
|
||||
*
|
||||
* Jellyfin is asked for every subtitle stream as WebVTT (see
|
||||
* `getSubtitleUrl(..., "vtt")`), so the MIME type is fixed rather than derived
|
||||
* from the source subtitle codec.
|
||||
*
|
||||
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||
*/
|
||||
export function nativeSubtitleTracks(
|
||||
tracks: readonly RenderableSubtitleTrack[],
|
||||
): SubtitleTrack[] {
|
||||
return tracks.map((track) => ({
|
||||
index: track.streamIndex,
|
||||
url: track.url,
|
||||
// `srclang` carries "und" for a stream with no language, which is the right
|
||||
// value for a `<track>` but is not a language the native side should claim.
|
||||
language: track.srclang === "und" ? null : track.srclang,
|
||||
label: track.label,
|
||||
mime_type: "text/vtt",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The argument for `player_set_subtitle_track` on the native backend.
|
||||
*
|
||||
* 🔴 This is **not** the Jellyfin stream index.
|
||||
* `JellyTauPlayer.setSubtitleTrack(n)` filters ExoPlayer's track groups down to
|
||||
* `C.TRACK_TYPE_TEXT` and indexes that list with `n`, so `n` is the *position of
|
||||
* the sideloaded subtitle configuration* — which is the position in the array
|
||||
* that `nativeSubtitleTracks()` produced and `playerPlayItem` sent.
|
||||
*
|
||||
* The menu's own row number is not that position: the menu lists every subtitle
|
||||
* *stream*, while only the streams whose URL resolved are sent. One failed URL
|
||||
* and everything below it selects the wrong subtitle. So the index is looked up
|
||||
* in the sent list instead of being passed down from the `{#each}`.
|
||||
*
|
||||
* `null` (the menu's "Off") stays `null`, which the backend turns into -1 and
|
||||
* Kotlin turns into "disable text tracks". A stream that was never sent also
|
||||
* maps to `null`: disabling subtitles is a truthful outcome, whereas guessing a
|
||||
* position would show the user a different language than the one they clicked.
|
||||
*
|
||||
* TRACES: UR-020 | IR-016 | UT-147
|
||||
*/
|
||||
export function nativeSubtitleArrayIndex(
|
||||
tracks: readonly RenderableSubtitleTrack[],
|
||||
streamIndex: number | null,
|
||||
): number | null {
|
||||
if (streamIndex === null) return null;
|
||||
const position = tracks.findIndex((t) => t.streamIndex === streamIndex);
|
||||
return position === -1 ? null : position;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<!--
|
||||
The header search box (md+ only; below md the bottom-nav Search tab and the
|
||||
/search page's own input serve that role).
|
||||
|
||||
One box, one results surface. The bar renders on the library routes *and on
|
||||
/search itself*, so searching from the header no longer swaps you onto a
|
||||
screen whose input is somewhere else: the box you typed in stays where it is
|
||||
and keeps driving the results. Off /search it navigates there (the only
|
||||
surface that renders results); on /search it republishes the query into the
|
||||
URL, which the page consumes.
|
||||
|
||||
TRACES: UR-049, UR-054 | DR-063, DR-064, DR-147
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { library } from "$lib/stores/library";
|
||||
import Search from "$lib/components/Search.svelte";
|
||||
import {
|
||||
isSearchRoute,
|
||||
parseSearchScope,
|
||||
resolveSearchScope,
|
||||
searchRouteUrl,
|
||||
type SearchScope,
|
||||
} from "$lib/utils/searchScope";
|
||||
|
||||
// Seeded from the URL, then owned by the user. A navigation to /search
|
||||
// remounts this component (library and root render their own AppHeader), so
|
||||
// reading `?q=` here is what carries a half-typed query across that hop.
|
||||
let value = $state($page.url.searchParams.get("q") ?? "");
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let scope = $state<SearchScope>(
|
||||
isSearchRoute($page.url.pathname)
|
||||
? parseSearchScope($page.url.searchParams.get("scope"))
|
||||
: resolveSearchScope($page.url.pathname)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const url = $page.url;
|
||||
if (isSearchRoute(url.pathname)) {
|
||||
// On /search the scope chips own the scope and publish it in the URL, so
|
||||
// the bar follows rather than overriding it on the next keystroke.
|
||||
scope = parseSearchScope(url.searchParams.get("scope"));
|
||||
} else if (!value.trim()) {
|
||||
// Elsewhere the route seeds the scope, but only while no search is
|
||||
// active: navigating must not snap a widened search back to the section
|
||||
// the user happens to be in.
|
||||
scope = resolveSearchScope(url.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
// Landing on /search with a seeded query means the user was mid-type in the
|
||||
// previous route's header. That box is gone; put the caret back in this one
|
||||
// so their next keystroke lands in the search field and not nowhere.
|
||||
onMount(async () => {
|
||||
if (!value) return;
|
||||
await tick();
|
||||
inputEl?.focus();
|
||||
inputEl?.setSelectionRange(value.length, value.length);
|
||||
});
|
||||
|
||||
async function handleSearch(query: string) {
|
||||
if (isSearchRoute($page.url.pathname)) {
|
||||
// Already on the results surface — republish in place. replaceState keeps
|
||||
// a whole session of typing to a single history entry.
|
||||
await goto(searchRouteUrl(query, scope), {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!query.trim()) {
|
||||
library.clearSearch();
|
||||
return;
|
||||
}
|
||||
await goto(searchRouteUrl(query, scope));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Search
|
||||
bind:value
|
||||
bind:inputEl
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The header search bar is the single md+ search input.
|
||||
*
|
||||
* Off /search it navigates there (the only surface that renders results); on
|
||||
* /search it stays put and republishes the query into the URL, so the user goes
|
||||
* on typing in the same box instead of being handed to a second input owned by
|
||||
* the page.
|
||||
*
|
||||
* TRACES: UR-049, UR-054 | DR-147
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
||||
|
||||
const { pageStore, goto, clearSearch } = vi.hoisted(() => {
|
||||
const { writable } = require("svelte/store");
|
||||
return {
|
||||
pageStore: writable({ url: new URL("http://localhost/library/music") }),
|
||||
goto: vi.fn(),
|
||||
clearSearch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$app/stores", () => ({ page: pageStore, navigating: { subscribe: () => () => {} } }));
|
||||
vi.mock("$app/navigation", () => ({ goto, afterNavigate: vi.fn(), beforeNavigate: vi.fn() }));
|
||||
vi.mock("$lib/stores/library", () => ({
|
||||
library: { subscribe: () => () => {}, search: vi.fn(), clearSearch },
|
||||
}));
|
||||
|
||||
import HeaderSearch from "./HeaderSearch.svelte";
|
||||
|
||||
const afterDebounce = () => new Promise((resolve) => setTimeout(resolve, 450));
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByPlaceholderText("Search your library...") as HTMLInputElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageStore.set({ url: new URL("http://localhost/library/music") });
|
||||
});
|
||||
|
||||
describe("from a library route", () => {
|
||||
it("routes to /search with the query and the route's scope", async () => {
|
||||
render(HeaderSearch);
|
||||
|
||||
await fireEvent.input(input(), { target: { value: "jazz" } });
|
||||
await afterDebounce();
|
||||
|
||||
expect(goto).toHaveBeenCalledWith("/search?q=jazz&scope=music");
|
||||
});
|
||||
|
||||
it("clears the results rather than navigating on an empty query", async () => {
|
||||
render(HeaderSearch);
|
||||
|
||||
await fireEvent.input(input(), { target: { value: "jazz" } });
|
||||
await afterDebounce();
|
||||
goto.mockClear();
|
||||
|
||||
await fireEvent.input(input(), { target: { value: "" } });
|
||||
await afterDebounce();
|
||||
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
expect(clearSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("on /search", () => {
|
||||
beforeEach(() => {
|
||||
pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=music") });
|
||||
});
|
||||
|
||||
it("carries the query over from the URL and puts the caret back in the box", async () => {
|
||||
render(HeaderSearch);
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(input()));
|
||||
expect(input().value).toBe("jazz");
|
||||
expect(input().selectionStart).toBe(4);
|
||||
});
|
||||
|
||||
it("republishes in place instead of pushing a second results screen", async () => {
|
||||
render(HeaderSearch);
|
||||
|
||||
await fireEvent.input(input(), { target: { value: "jazzy" } });
|
||||
await afterDebounce();
|
||||
|
||||
expect(goto).toHaveBeenCalledWith("/search?q=jazzy&scope=music", {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
});
|
||||
// The box the user is typing in keeps its text — nothing re-seeds it.
|
||||
expect(input().value).toBe("jazzy");
|
||||
});
|
||||
|
||||
it("searches with the scope the page's chips published, not the route default", async () => {
|
||||
// A chip pick lands in the URL; the bar must adopt it or the next keystroke
|
||||
// would silently widen the search back to All.
|
||||
pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=tv") });
|
||||
render(HeaderSearch);
|
||||
|
||||
await fireEvent.input(input(), { target: { value: "jazzy" } });
|
||||
await afterDebounce();
|
||||
|
||||
expect(goto).toHaveBeenCalledWith(
|
||||
"/search?q=jazzy&scope=tv",
|
||||
expect.objectContaining({ replaceState: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<!--
|
||||
What the "N waiting to sync" badge actually stands for.
|
||||
|
||||
These are outgoing changes — watch positions, watched flags — made while the
|
||||
server was unreachable and still waiting to reach Jellyfin. They are *not*
|
||||
downloads, which is where the badge used to send people looking: the Downloads
|
||||
page lists the `downloads` table and structurally cannot show these.
|
||||
|
||||
Rows push themselves on reconnect (DR-131); "Sync now" only asks for that to
|
||||
happen immediately.
|
||||
|
||||
TRACES: UR-025 | DR-132
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { commands, type SyncQueueItem } from "$lib/api/bindings";
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
import { pendingSyncCount } from "$lib/stores/appState";
|
||||
import { isConnected } from "$lib/stores/connectivity";
|
||||
import {
|
||||
describeOperation,
|
||||
describeSubject,
|
||||
isStuck,
|
||||
summarize,
|
||||
sortForDisplay,
|
||||
} from "$lib/services/pendingSync.logic";
|
||||
|
||||
let items = $state<SyncQueueItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let syncing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let lastResult = $state<string | null>(null);
|
||||
|
||||
const summary = $derived(summarize(items));
|
||||
|
||||
export async function refresh() {
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
items = sortForDisplay(await syncService.getPending());
|
||||
pendingSyncCount.set(items.length);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function syncNow() {
|
||||
try {
|
||||
syncing = true;
|
||||
error = null;
|
||||
const report = await commands.syncProcessPending();
|
||||
lastResult =
|
||||
report.pushed > 0
|
||||
? `Sent ${report.pushed} update${report.pushed === 1 ? "" : "s"}.`
|
||||
: "Nothing could be sent — the server is still unreachable.";
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatQueuedAt(createdAt: string | null): string {
|
||||
if (!createdAt) return "";
|
||||
const parsed = Date.parse(createdAt);
|
||||
return Number.isNaN(parsed) ? "" : new Date(parsed).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
Changes made while the server was unreachable — watch positions and watched
|
||||
flags — waiting to reach Jellyfin. They send themselves when the server comes
|
||||
back. This is not the download queue; downloaded media lives under Downloads.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-sm text-gray-400">Loading queued updates…</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="py-6 text-center text-sm text-gray-500">Everything is synced.</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onclick={syncNow}
|
||||
disabled={syncing || !$isConnected}
|
||||
class="rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={$isConnected ? "Send these now" : "Needs a reachable server"}
|
||||
>
|
||||
{syncing ? "Sending…" : "Sync now"}
|
||||
</button>
|
||||
{#if summary.stuck > 0}
|
||||
<span class="text-xs text-amber-400">
|
||||
{summary.stuck} failed and will be retried
|
||||
</span>
|
||||
{/if}
|
||||
{#if lastResult}
|
||||
<span class="text-xs text-gray-400">{lastResult}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
{#each items as item (item.id)}
|
||||
<li
|
||||
class="rounded-lg border-l-4 bg-[var(--color-surface)] p-3 {isStuck(item)
|
||||
? 'border-amber-500/60'
|
||||
: 'border-gray-600/60'}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-white">
|
||||
{describeOperation(item.operation)}
|
||||
</p>
|
||||
<p class="truncate text-xs text-gray-400">{describeSubject(item)}</p>
|
||||
{#if item.errorMessage}
|
||||
<p class="mt-1 text-xs text-amber-400">
|
||||
{item.errorMessage}{item.retryCount > 0 ? ` (attempt ${item.retryCount})` : ""}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="shrink-0 text-[11px] text-gray-500">
|
||||
{formatQueuedAt(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
Modal wrapper for the pending-sync queue, opened from the offline banner's
|
||||
badge so the count is answerable where the user reads it.
|
||||
|
||||
TRACES: UR-025 | DR-132
|
||||
-->
|
||||
<script lang="ts">
|
||||
import PendingSyncList from "./PendingSyncList.svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose }: Props = $props();
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 sm:items-center sm:p-4"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pending-sync-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[80vh] w-full flex-col rounded-t-2xl bg-[var(--color-surface)] shadow-2xl sm:max-h-[70vh] sm:max-w-lg sm:rounded-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="none"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||
<h2 id="pending-sync-title" class="text-lg font-semibold text-white">
|
||||
Waiting to sync
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="-m-2 p-2 text-gray-400 transition-colors hover:text-white"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<PendingSyncList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,55 @@
|
||||
// TRACES: UR-052 | DR-143 | UT-140
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
version: null as ReturnType<typeof import("svelte/store").writable<number>> | null,
|
||||
destroyFns: [] as Array<() => void>,
|
||||
}));
|
||||
|
||||
vi.mock("svelte", () => ({
|
||||
onDestroy: (fn: () => void) => h.destroyFns.push(fn),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/offlineCatalog", () => ({
|
||||
get catalogFilterVersion() {
|
||||
return h.version;
|
||||
},
|
||||
}));
|
||||
|
||||
import { useOfflineFilterReload } from "./useOfflineFilterReload";
|
||||
|
||||
describe("useOfflineFilterReload (DR-143)", () => {
|
||||
beforeEach(() => {
|
||||
h.version = writable(0);
|
||||
h.destroyFns.length = 0;
|
||||
});
|
||||
|
||||
it("does not reload for the value the page already loaded under", () => {
|
||||
const reload = vi.fn();
|
||||
useOfflineFilterReload(reload);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads once each time the gate settles into a new state", () => {
|
||||
const reload = vi.fn();
|
||||
useOfflineFilterReload(reload);
|
||||
|
||||
h.version!.set(1);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
h.version!.set(2);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops reloading a page that has been destroyed", () => {
|
||||
const reload = vi.fn();
|
||||
useOfflineFilterReload(reload);
|
||||
|
||||
h.destroyFns.forEach((fn) => fn());
|
||||
h.version!.set(1);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Re-query a listing when the offline "downloaded only" gate changes.
|
||||
*
|
||||
* The gate is a process-wide flag in Rust, consulted only while a query runs,
|
||||
* so flipping it has no effect on rows already on screen. Library pages loaded
|
||||
* once on mount and reloaded only on the offline → online transition
|
||||
* (`useServerReachabilityReload`), which left two gaps the user sees as a broken
|
||||
* filter:
|
||||
*
|
||||
* - going *offline* never reloaded, so the full server catalog stayed on
|
||||
* screen under a now-closed gate;
|
||||
* - toggling "Show all server media" never reloaded, so it only greyed the
|
||||
* cards already listed instead of adding or removing any.
|
||||
*
|
||||
* `catalogFilterVersion` bumps once the backend has accepted the new gate, so
|
||||
* the reload this triggers always queries under the intended filter.
|
||||
*
|
||||
* Call during component initialisation, like `useServerReachabilityReload`:
|
||||
*
|
||||
* ```svelte
|
||||
* <script>
|
||||
* useOfflineFilterReload(() => loadItems());
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* TRACES: UR-052 | DR-143
|
||||
*/
|
||||
import { onDestroy } from "svelte";
|
||||
import { catalogFilterVersion } from "$lib/services/offlineCatalog";
|
||||
|
||||
export function useOfflineFilterReload(reloadFn: () => void | Promise<void>): void {
|
||||
// The value the page is already showing. Seeded from the first subscription
|
||||
// callback (stores emit synchronously on subscribe) so mounting never
|
||||
// triggers a redundant second load of what onMount just fetched.
|
||||
let applied: number | null = null;
|
||||
|
||||
const unsubscribe = catalogFilterVersion.subscribe((version) => {
|
||||
if (applied === null || version === applied) {
|
||||
applied = version;
|
||||
return;
|
||||
}
|
||||
applied = version;
|
||||
void reloadFn();
|
||||
});
|
||||
|
||||
onDestroy(unsubscribe);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Adapter-selection regression guards.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-150 | UT-149
|
||||
*
|
||||
* The selection rule has two inputs and one hard safety property:
|
||||
*
|
||||
* - Rust says which backend the platform has (`backendKind`).
|
||||
* - The user opts in with `experimentalNativeVideo`.
|
||||
* - **The flag off must force HTML5 even when Rust says native.** That is the
|
||||
* regression guard: a broken spike must not be able to ship as the default.
|
||||
*
|
||||
* These are pure functions, so the whole matrix is testable without a device.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAdapter } from "./index";
|
||||
import { Html5PlayerAdapter } from "./html5Adapter";
|
||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
const host: AdapterHost = {
|
||||
reportState: () => {},
|
||||
reportPosition: () => {},
|
||||
reportEnded: () => {},
|
||||
} as unknown as AdapterHost;
|
||||
|
||||
const bridge = {
|
||||
getElement: () => null,
|
||||
} as any;
|
||||
|
||||
describe("createAdapter", () => {
|
||||
it("returns the native adapter when Rust says native and the flag is on", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "native",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: true,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
|
||||
expect(adapter.kind).toBe("native");
|
||||
});
|
||||
|
||||
// The regression guard: the flag is a suppressor, so off must beat Rust.
|
||||
it("forces HTML5 when the flag is off even though Rust says native", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "native",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: false,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
expect(adapter.kind).toBe("html5");
|
||||
});
|
||||
|
||||
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "html5",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: false,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
// The flag must never *promote* a platform Rust said has no native backend
|
||||
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
|
||||
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "html5",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: true,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
it("defaults to HTML5 when the flag is omitted entirely", () => {
|
||||
const adapter = createAdapter({ backendKind: "native", host, bridge });
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
it("requires a bridge for the HTML5 adapter", () => {
|
||||
expect(() =>
|
||||
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false })
|
||||
).toThrow(/bridge/i);
|
||||
});
|
||||
|
||||
// The native adapter owns no DOM element, so it must not demand a bridge.
|
||||
it("does not require a bridge for the native adapter", () => {
|
||||
expect(() =>
|
||||
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,22 @@
|
||||
* Player adapter factory + public exports.
|
||||
*
|
||||
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
|
||||
* It is the single place that encodes the INTERIM Android override: the Rust
|
||||
* backend may report a native ExoPlayer backend, but native Android video
|
||||
* rendering is blocked upstream (tauri#10152 — transparent webview / SurfaceView
|
||||
* compositing), so we render Android video through the HTML5 adapter for now.
|
||||
* When that upstream limitation is resolved, flip this to honor `backendKind`.
|
||||
* Rust decides *which backend this platform has* (`useHtml5Element` from
|
||||
* `player_play_item`); this factory consumes that decision rather than
|
||||
* re-deriving it.
|
||||
*
|
||||
* TRACES: UR-003 | DR-004
|
||||
* The `experimentalNativeVideo` flag is a **suppressor, never a promoter**: it
|
||||
* can force the HTML5 path when Rust says native (so an in-progress spike cannot
|
||||
* ship as a regression), but it can never select native on a platform whose Rust
|
||||
* backend reported HTML5 — Linux has no way to composite a surface behind a
|
||||
* WebKitGTK webview, so promoting there would produce a black screen.
|
||||
*
|
||||
* The previous unconditional HTML5 override cited tauri#10152 as an upstream
|
||||
* blocker. That was stale: #10152 is a dormant *feature request*, the capability
|
||||
* shipped in tauri 27d01834, and the black-screen bug (tauri#8381, #9408) was a
|
||||
* broken `setBackgroundColor` JNI signature fixed in wry 0.39.4 — we ship 0.53.x.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149
|
||||
*/
|
||||
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||
@@ -29,27 +38,36 @@ export interface CreateAdapterArgs {
|
||||
host: AdapterHost;
|
||||
/** Required for the HTML5 adapter; ignored by the native adapter. */
|
||||
bridge?: Html5ElementBridge;
|
||||
/**
|
||||
* User opt-in for the native video path. Defaults to **off**, so omitting it
|
||||
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
|
||||
* the spike.
|
||||
*/
|
||||
experimentalNativeVideo?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the adapter for this platform/stream.
|
||||
*
|
||||
* INTERIM: always returns the HTML5 adapter, because the native surface is not
|
||||
* visible through the webview on current Tauri (see module docs). The bridge is
|
||||
* therefore required.
|
||||
* Native is chosen only when Rust reports a native backend AND the user has
|
||||
* opted in. Every other combination is HTML5.
|
||||
*/
|
||||
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
|
||||
// INTERIM OVERRIDE: force HTML5 rendering even when the backend reports native.
|
||||
const effectiveKind: BackendKind = "html5";
|
||||
export function createAdapter({
|
||||
backendKind,
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo = false,
|
||||
}: CreateAdapterArgs): PlayerAdapter {
|
||||
const effectiveKind: BackendKind =
|
||||
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
|
||||
|
||||
if (effectiveKind === "html5") {
|
||||
if (!bridge) {
|
||||
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
|
||||
}
|
||||
return new Html5PlayerAdapter(host, bridge);
|
||||
if (effectiveKind === "native") {
|
||||
// The native surface is owned by the backend — no DOM element, no bridge.
|
||||
return new NativePlayerAdapter(host);
|
||||
}
|
||||
|
||||
// Reached only once the interim override is lifted (native Android unblocked).
|
||||
void backendKind;
|
||||
return new NativePlayerAdapter(host);
|
||||
if (!bridge) {
|
||||
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
|
||||
}
|
||||
return new Html5PlayerAdapter(host, bridge);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
|
||||
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
|
||||
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
|
||||
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
|
||||
const playerSeek = vi.fn((..._a: any[]): any => ({}));
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: {
|
||||
@@ -20,6 +21,7 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
|
||||
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
|
||||
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
|
||||
playerSeek: (...a: any[]) => playerSeek(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -72,6 +74,39 @@ describe("NativePlayerAdapter", () => {
|
||||
expect(adapter.getPosition()).toBe(90);
|
||||
});
|
||||
|
||||
// Regression: resume-at-position was broken on Android. player_play_item
|
||||
// carries no start position, and ExoPlayer always begins at 0, so recording
|
||||
// the number frontend-side left the backend playing from the beginning. The
|
||||
// adapter must actually *issue* the seek.
|
||||
it("load() issues the resume seek to the backend, not just records it", async () => {
|
||||
await adapter.load("url", {
|
||||
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
||||
initialPosition: 90, isLive: false, audioTrackIndex: null,
|
||||
knownDuration: 0, subtitleTracks: [],
|
||||
});
|
||||
expect(playerSeek).toHaveBeenCalledWith(90);
|
||||
});
|
||||
|
||||
it("load() does not seek when starting from the beginning", async () => {
|
||||
await adapter.load("url", {
|
||||
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
||||
initialPosition: 0, isLive: false, audioTrackIndex: null,
|
||||
knownDuration: 0, subtitleTracks: [],
|
||||
});
|
||||
expect(playerSeek).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A live stream has no meaningful resume point; seeking one is at best a
|
||||
// no-op and at worst knocks the HLS window off its live edge.
|
||||
it("load() never seeks a live stream", async () => {
|
||||
await adapter.load("url", {
|
||||
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
||||
initialPosition: 90, isLive: true, audioTrackIndex: null,
|
||||
knownDuration: 0, subtitleTracks: [],
|
||||
});
|
||||
expect(playerSeek).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setVolume clamps and delegates; setMuted toggles mute", () => {
|
||||
adapter.setVolume(2);
|
||||
expect(playerSetVolume).toHaveBeenCalledWith(1);
|
||||
|
||||
@@ -43,10 +43,21 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
||||
|
||||
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||
// player_play_item already initiated native playback before this adapter is
|
||||
// created; nothing further to do. Seed a resume position if requested (the
|
||||
// native backend performs the actual seek internally).
|
||||
if (options.initialPosition > 0) {
|
||||
// created, so there is no stream to load here — but it carries no start
|
||||
// position, and ExoPlayer always begins at 0. The resume seek must be
|
||||
// issued explicitly or "resume at position" silently plays from the top.
|
||||
//
|
||||
// Recording the position without seeking (what this used to do) is what
|
||||
// broke Android resume: the frontend believed it had resumed while
|
||||
// ExoPlayer played from the beginning.
|
||||
//
|
||||
// Live streams have no resume point — seeking one knocks the HLS window off
|
||||
// its live edge, so they are excluded.
|
||||
//
|
||||
// TRACES: UR-005 | DR-004, DR-028
|
||||
if (options.initialPosition > 0 && !options.isLive) {
|
||||
this.position = options.initialPosition;
|
||||
await commands.playerSeek(options.initialPosition);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PlayTracksContext,
|
||||
PlayAlbumTrackRequest,
|
||||
PlayItemRequest,
|
||||
StreamingQuality,
|
||||
} from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { PlayerAdapter } from "./adapters/types";
|
||||
@@ -182,6 +183,36 @@ async function switchAudioTrack(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async function setStreamQuality(
|
||||
quality: StreamingQuality,
|
||||
currentPosition: number | null,
|
||||
mediaSourceId: string | null,
|
||||
audioTrackIndex: number | null
|
||||
): Promise<void> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return;
|
||||
const response = (await commands.playerSetStreamQuality(
|
||||
requireHandle(),
|
||||
quality,
|
||||
adapter.kind === "html5",
|
||||
currentPosition,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function next() {
|
||||
await commands.playerNext();
|
||||
}
|
||||
@@ -300,6 +331,7 @@ export const playerController = {
|
||||
setSubtitleTrack,
|
||||
seekVideo,
|
||||
switchAudioTrack,
|
||||
setStreamQuality,
|
||||
playTracks,
|
||||
playAlbumTrack,
|
||||
playItem,
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveVideoSource } from "./localSource";
|
||||
import { downloadedFilePath, resolveVideoSource } from "./localSource";
|
||||
|
||||
describe("downloadedFilePath", () => {
|
||||
// The download worker rewrites `downloads.file_path` to the absolute path it
|
||||
// actually wrote once the transfer completes, so a completed row is already
|
||||
// rooted. Joining it onto the storage root again produced
|
||||
// `/data/user/0/app//data/user/0/app/videos/x.mp4`, which the asset protocol
|
||||
// cannot open — offline video died with MEDIA_ERR_SRC_NOT_SUPPORTED while
|
||||
// audio, which resolves the same column through Rust, played fine.
|
||||
it("leaves a completed download's absolute path alone", () => {
|
||||
const root = "/data/user/0/com.dtourolle.jellytau";
|
||||
const stored = `${root}/videos/Taming of the Shrew.mp4`;
|
||||
|
||||
expect(downloadedFilePath(root, stored)).toBe(stored);
|
||||
});
|
||||
|
||||
it("roots a path that is still relative to the storage directory", () => {
|
||||
// Rows only hold a relative path before the worker completes them, but a
|
||||
// half-migrated database can still carry one.
|
||||
expect(downloadedFilePath("/var/data/jellytau", "videos/film.mp4")).toBe(
|
||||
"/var/data/jellytau/videos/film.mp4"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an absolute Windows path alone", () => {
|
||||
const stored = "C:\\Users\\u\\AppData\\jellytau\\videos\\film.mp4";
|
||||
|
||||
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)}`;
|
||||
|
||||
@@ -36,6 +36,28 @@ export interface VideoSourceDecision {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-disk path of a row in the `downloads` store.
|
||||
*
|
||||
* `downloads.file_path` is stored relative to the storage root while a download
|
||||
* is queued, but the worker rewrites it to the absolute path it actually wrote
|
||||
* once the transfer completes — so a *completed* row is already rooted. Joining
|
||||
* it onto the storage root a second time produced
|
||||
* `/data/user/0/app//data/user/0/app/videos/x.mp4`; the asset protocol could not
|
||||
* open that, so offline video failed with `MEDIA_ERR_SRC_NOT_SUPPORTED` while
|
||||
* audio, which resolves the same column through Rust, played fine.
|
||||
*
|
||||
* TRACES: UR-071 | DR-133 | UT-124
|
||||
*/
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// The offline "downloaded only" gate is a *backend* flag, so flipping it only
|
||||
// changes what the user sees if the listing is re-queried afterwards. Nothing
|
||||
// re-queried: the toggle greyed cards instantly (a pure frontend derivation in
|
||||
// MediaCard) while the item list stayed exactly as it was loaded, which is why
|
||||
// the filter read as "shows everything until I toggle, then greys some of it".
|
||||
//
|
||||
// `catalogFilterVersion` is the refetch signal, and it must bump only once the
|
||||
// backend has actually accepted the new flag — a reload racing the push would
|
||||
// re-query under the old gate and land back where it started.
|
||||
//
|
||||
// TRACES: UR-052 | DR-078, DR-143 | UT-137, UT-138, UT-139
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
/**
|
||||
* Drop subscribers from previously imported copies of the module.
|
||||
* `vi.resetModules()` gives each test a fresh module instance, but this
|
||||
* store outlives them all — without this the stale instances keep
|
||||
* consuming `mockImplementationOnce` and pushing their own state.
|
||||
*/
|
||||
reset(v: T) {
|
||||
subs.clear();
|
||||
value = v;
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
isConnectedStore: shim(true),
|
||||
// Resolution is deferred so a test can observe the window between "command
|
||||
// issued" and "command accepted".
|
||||
pending: [] as Array<() => void>,
|
||||
setShowServerCatalog: vi.fn(
|
||||
() => new Promise<void>((resolve) => h.pending.push(() => resolve()))
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/connectivity", () => ({
|
||||
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: {
|
||||
setShowServerCatalog: h.setShowServerCatalog,
|
||||
syncFullCatalog: vi.fn(),
|
||||
resumeQueuedDownloads: vi.fn(),
|
||||
catalogSyncStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { getRepository: () => ({ getHandle: () => "handle-1" }) },
|
||||
}));
|
||||
|
||||
/** Let every issued setShowServerCatalog settle. */
|
||||
async function settle() {
|
||||
h.pending.splice(0).forEach((resolve) => resolve());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("offline filter refetch signal (DR-143)", () => {
|
||||
beforeEach(() => {
|
||||
h.setShowServerCatalog.mockReset();
|
||||
h.setShowServerCatalog.mockImplementation(
|
||||
() => new Promise<void>((resolve) => h.pending.push(() => resolve()))
|
||||
);
|
||||
h.pending.length = 0;
|
||||
h.isConnectedStore.reset(true);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("bumps the version when going offline closes the gate", async () => {
|
||||
const mod = await import("./offlineCatalog");
|
||||
await settle();
|
||||
const before = get(mod.catalogFilterVersion);
|
||||
|
||||
h.isConnectedStore.set(false); // include: true → false
|
||||
await settle();
|
||||
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
|
||||
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("bumps only after the backend accepts the flag, never before", async () => {
|
||||
const mod = await import("./offlineCatalog");
|
||||
await settle();
|
||||
const before = get(mod.catalogFilterVersion);
|
||||
|
||||
h.isConnectedStore.set(false);
|
||||
await Promise.resolve(); // command issued, not yet resolved
|
||||
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
|
||||
expect(get(mod.catalogFilterVersion)).toBe(before);
|
||||
|
||||
await settle();
|
||||
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("bumps both ways as the offline toggle is flipped", async () => {
|
||||
const mod = await import("./offlineCatalog");
|
||||
h.isConnectedStore.set(false);
|
||||
await settle();
|
||||
const offlineGated = get(mod.catalogFilterVersion);
|
||||
|
||||
mod.showServerCatalog.set(true); // reveal the server catalog
|
||||
await settle();
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true);
|
||||
const revealed = get(mod.catalogFilterVersion);
|
||||
expect(revealed).toBeGreaterThan(offlineGated);
|
||||
|
||||
mod.showServerCatalog.set(false); // back to downloaded-only
|
||||
await settle();
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
|
||||
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(revealed);
|
||||
});
|
||||
|
||||
it("does not bump when the effective gate is unchanged", async () => {
|
||||
const mod = await import("./offlineCatalog");
|
||||
await settle();
|
||||
const before = get(mod.catalogFilterVersion);
|
||||
|
||||
// Online, the toggle cannot close the gate — browsing reads the same cache.
|
||||
mod.showServerCatalog.set(true);
|
||||
await settle();
|
||||
|
||||
expect(get(mod.catalogFilterVersion)).toBe(before);
|
||||
});
|
||||
|
||||
it("retries the push after a failed one rather than latching the old gate", async () => {
|
||||
const mod = await import("./offlineCatalog");
|
||||
await settle();
|
||||
const before = get(mod.catalogFilterVersion);
|
||||
|
||||
h.setShowServerCatalog.mockImplementationOnce(() => Promise.reject(new Error("ipc down")));
|
||||
h.isConnectedStore.set(false);
|
||||
await settle();
|
||||
expect(get(mod.catalogFilterVersion)).toBe(before); // nothing to reload for
|
||||
|
||||
// The same transition must be attempted again, not skipped as "already sent".
|
||||
h.isConnectedStore.set(true);
|
||||
await settle();
|
||||
h.isConnectedStore.set(false);
|
||||
await settle();
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
|
||||
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -25,30 +25,62 @@ import { isConnected } from "$lib/stores/connectivity";
|
||||
*/
|
||||
export const showServerCatalog: Writable<boolean> = writable(false);
|
||||
|
||||
/**
|
||||
* Bumped every time the backend's downloads-only gate *settles* into a new
|
||||
* state. Library pages watch it and re-query.
|
||||
*
|
||||
* The gate lives in Rust and is only consulted when a query runs, so flipping
|
||||
* it changes nothing already on screen. Without this signal the toggle merely
|
||||
* greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation and
|
||||
* updates instantly — while the item list stayed as first loaded. That is the
|
||||
* "shows everything until I filter" behaviour: the listing had never been
|
||||
* re-queried under the closed gate. Going offline had the same problem, since
|
||||
* nothing reloads on the online → offline transition either.
|
||||
*
|
||||
* It bumps *after* the command resolves, never before: a reload racing the push
|
||||
* would re-query under the old gate and undo itself.
|
||||
*
|
||||
* TRACES: UR-052 | DR-143
|
||||
*/
|
||||
export const catalogFilterVersion: Writable<number> = writable(0);
|
||||
|
||||
// Keep the backend's offline library queries in sync with the UI toggle. The
|
||||
// offline cache holds the whole synced catalog, so `get_items` would otherwise
|
||||
// return every server item even offline with the toggle off. Include the
|
||||
// non-downloaded catalog only when online (fast browsing reads the same cache)
|
||||
// or when the "Show all server media" toggle is on.
|
||||
let lastIncludeCatalog: boolean | null = null;
|
||||
function pushCatalogVisibility(connected: boolean, showCatalog: boolean): void {
|
||||
async function pushCatalogVisibility(connected: boolean, showCatalog: boolean): Promise<void> {
|
||||
const include = connected || showCatalog;
|
||||
if (include === lastIncludeCatalog) return;
|
||||
lastIncludeCatalog = include;
|
||||
commands.setShowServerCatalog(include).catch((err) => {
|
||||
|
||||
try {
|
||||
await commands.setShowServerCatalog(include);
|
||||
} catch (err) {
|
||||
console.warn("[OfflineCatalog] Failed to set catalog visibility:", err);
|
||||
});
|
||||
// The backend is still on the old gate, so forget that we sent this —
|
||||
// otherwise the next identical transition is skipped as a no-op and the
|
||||
// frontend and backend disagree about the filter for the rest of the
|
||||
// session. No version bump: there is nothing new to re-query under.
|
||||
lastIncludeCatalog = null;
|
||||
return;
|
||||
}
|
||||
|
||||
catalogFilterVersion.update((n) => n + 1);
|
||||
}
|
||||
|
||||
let connectedNow = true;
|
||||
let showCatalogNow = false;
|
||||
// Fire-and-forget on purpose: the push handles its own failure, and subscribers
|
||||
// must not block. Consumers wait on `catalogFilterVersion` instead.
|
||||
isConnected.subscribe((v) => {
|
||||
connectedNow = v;
|
||||
pushCatalogVisibility(connectedNow, showCatalogNow);
|
||||
void pushCatalogVisibility(connectedNow, showCatalogNow);
|
||||
});
|
||||
showServerCatalog.subscribe((v) => {
|
||||
showCatalogNow = v;
|
||||
pushCatalogVisibility(connectedNow, showCatalogNow);
|
||||
void pushCatalogVisibility(connectedNow, showCatalogNow);
|
||||
});
|
||||
|
||||
/** Last time a full catalog sync completed, for a UI hint. */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// TRACES: UR-025 | DR-132 | UT-123
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { SyncQueueItem } from "$lib/api/bindings";
|
||||
import {
|
||||
describeOperation,
|
||||
describeSubject,
|
||||
isStuck,
|
||||
summarize,
|
||||
sortForDisplay,
|
||||
} from "./pendingSync.logic";
|
||||
|
||||
function row(overrides: Partial<SyncQueueItem> = {}): SyncQueueItem {
|
||||
return {
|
||||
id: 1,
|
||||
userId: "u1",
|
||||
operation: "report_playback_stopped",
|
||||
itemId: "ep1",
|
||||
payload: null,
|
||||
status: "pending",
|
||||
retryCount: 0,
|
||||
createdAt: "2026-08-01T10:00:00Z",
|
||||
errorMessage: null,
|
||||
itemName: null,
|
||||
...overrides,
|
||||
} as SyncQueueItem;
|
||||
}
|
||||
|
||||
describe("pending sync row description", () => {
|
||||
it("labels the operations the backend can queue", () => {
|
||||
expect(describeOperation("report_playback_stopped")).toBe("Watch position");
|
||||
expect(describeOperation("mark_played")).toBe("Marked as watched");
|
||||
expect(describeOperation("report_playback_start")).toBe("Playback started");
|
||||
});
|
||||
|
||||
it("still renders an operation it has no label for", () => {
|
||||
// An unlabelled row is the one heading for abandonment — it must not
|
||||
// render blank, which is the failure this whole surface exists to fix.
|
||||
expect(describeOperation("teleport_item")).toBe("teleport item");
|
||||
});
|
||||
|
||||
it("names the item when the catalog knows it, and falls back to the id", () => {
|
||||
expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe(
|
||||
"The Expanse S01E01",
|
||||
);
|
||||
expect(describeSubject(row({ itemName: null, itemId: "abc123" }))).toBe("abc123");
|
||||
expect(describeSubject(row({ itemName: null, itemId: null }))).toBe("Unknown item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stuck rows", () => {
|
||||
it("treats a failed or retried row as stuck", () => {
|
||||
expect(isStuck(row({ status: "failed", retryCount: 1 }))).toBe(true);
|
||||
expect(isStuck(row({ status: "pending", retryCount: 2 }))).toBe(true);
|
||||
expect(isStuck(row())).toBe(false);
|
||||
});
|
||||
|
||||
it("summarizes a mixed queue", () => {
|
||||
const summary = summarize([row(), row({ id: 2, status: "failed", retryCount: 1 })]);
|
||||
expect(summary).toEqual({ total: 2, stuck: 1, allStuck: false });
|
||||
});
|
||||
|
||||
it("reports allStuck only when every row has failed", () => {
|
||||
expect(summarize([row({ status: "failed", retryCount: 3 })]).allStuck).toBe(true);
|
||||
expect(summarize([]).allStuck).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("display order", () => {
|
||||
it("lists oldest first — the order they will be replayed in", () => {
|
||||
const sorted = sortForDisplay([
|
||||
row({ id: 3, createdAt: "2026-08-01T12:00:00Z" }),
|
||||
row({ id: 1, createdAt: "2026-08-01T10:00:00Z" }),
|
||||
row({ id: 2, createdAt: "2026-08-01T11:00:00Z" }),
|
||||
]);
|
||||
expect(sorted.map((r) => r.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("keeps timestamp-less rows instead of dropping them", () => {
|
||||
const sorted = sortForDisplay([
|
||||
row({ id: 2, createdAt: null }),
|
||||
row({ id: 1, createdAt: "2026-08-01T10:00:00Z" }),
|
||||
]);
|
||||
expect(sorted.map((r) => r.id)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Presentation logic for the pending-sync queue.
|
||||
//
|
||||
// The queue's *meaning* lives in Rust (which operations exist, how they push,
|
||||
// when one is abandoned — DR-131). What lives here is purely how a row reads on
|
||||
// screen: its label, its subtitle, and whether it is currently erroring. Kept
|
||||
// out of the component so it can be unit-tested, the same pattern as
|
||||
// episodeStrip.ts.
|
||||
//
|
||||
// TRACES: UR-025 | DR-132 | UT-123
|
||||
|
||||
import type { SyncQueueItem } from "$lib/api/bindings";
|
||||
|
||||
/** How each queued operation reads in the list. */
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
report_playback_start: "Playback started",
|
||||
report_playback_stopped: "Watch position",
|
||||
update_progress: "Watch position",
|
||||
mark_played: "Marked as watched",
|
||||
mark_unplayed: "Marked as unwatched",
|
||||
mark_favorite: "Added to favourites",
|
||||
unmark_favorite: "Removed from favourites",
|
||||
playlist_create: "Playlist created",
|
||||
playlist_delete: "Playlist deleted",
|
||||
playlist_rename: "Playlist renamed",
|
||||
playlist_add_items: "Added to playlist",
|
||||
playlist_remove_items: "Removed from playlist",
|
||||
playlist_reorder_item: "Playlist reordered",
|
||||
};
|
||||
|
||||
/**
|
||||
* A human label for a queued operation. An operation this build has no label
|
||||
* for still reads as something — an unknown row is the case most worth showing,
|
||||
* since it is the one that will end up abandoned.
|
||||
*/
|
||||
export function describeOperation(operation: string): string {
|
||||
return OPERATION_LABELS[operation] ?? operation.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/** What the row is about: the item's title if the catalog knows it, else its id. */
|
||||
export function describeSubject(item: SyncQueueItem): string {
|
||||
return item.itemName ?? item.itemId ?? "Unknown item";
|
||||
}
|
||||
|
||||
/**
|
||||
* A row is "stuck" once it has failed at least once — that is what justifies
|
||||
* showing its error, and what a Retry button acts on.
|
||||
*/
|
||||
export function isStuck(item: SyncQueueItem): boolean {
|
||||
return item.status === "failed" || item.retryCount > 0;
|
||||
}
|
||||
|
||||
export interface PendingSyncSummary {
|
||||
total: number;
|
||||
stuck: number;
|
||||
/** True when every queued row has already failed — retrying needs the server. */
|
||||
allStuck: boolean;
|
||||
}
|
||||
|
||||
export function summarize(items: SyncQueueItem[]): PendingSyncSummary {
|
||||
const stuck = items.filter(isStuck).length;
|
||||
return {
|
||||
total: items.length,
|
||||
stuck,
|
||||
allStuck: items.length > 0 && stuck === items.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Oldest first — the order they will be replayed in, so the list reads as the
|
||||
* queue it is. Rows without a timestamp sort last rather than being dropped.
|
||||
*/
|
||||
export function sortForDisplay(items: SyncQueueItem[]): SyncQueueItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (!a.createdAt && !b.createdAt) return a.id - b.id;
|
||||
if (!a.createdAt) return 1;
|
||||
if (!b.createdAt) return -1;
|
||||
const diff = Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
return diff !== 0 ? diff : a.id - b.id;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Platform playback capabilities, read from Rust.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-152
|
||||
*
|
||||
* "Which backend does this platform have" is a *backend* fact, so Rust owns it
|
||||
* (`player_get_capabilities`, gated on the same `cfg!` the backends are built
|
||||
* under). This module is a thin cache over that command.
|
||||
*
|
||||
* It exists because the frontend used to re-derive the answer by sniffing
|
||||
* `navigator.userAgent` for "android"/"linux" — a second, silently drifting copy
|
||||
* of a decision Rust already makes. Consume the value; never re-derive it.
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
|
||||
export interface PlaybackCapabilities {
|
||||
/** Audio renders through a webview `<audio>` element, not a native backend. */
|
||||
usesWebviewAudio: boolean;
|
||||
/** Video can render on a native surface behind a transparent webview. */
|
||||
supportsNativeVideo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative defaults for when the backend cannot be reached (very early
|
||||
* startup, or a command failure). Both false = "assume no special platform
|
||||
* facilities": no stray `<audio>` element is mounted, and video stays on the
|
||||
* HTML5 path, which is the safe behaviour everywhere.
|
||||
*/
|
||||
const FALLBACK: PlaybackCapabilities = {
|
||||
usesWebviewAudio: false,
|
||||
supportsNativeVideo: false,
|
||||
};
|
||||
|
||||
let cached: PlaybackCapabilities | null = null;
|
||||
let inflight: Promise<PlaybackCapabilities> | null = null;
|
||||
|
||||
/**
|
||||
* Fetch (and memoize) this platform's capabilities. Cached because the answer is
|
||||
* compile-time constant in Rust — it cannot change during a session.
|
||||
*/
|
||||
export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
|
||||
cached = {
|
||||
usesWebviewAudio: !!caps?.usesWebviewAudio,
|
||||
supportsNativeVideo: !!caps?.supportsNativeVideo,
|
||||
};
|
||||
return cached;
|
||||
} catch (err) {
|
||||
console.warn("[capabilities] player_get_capabilities failed:", err);
|
||||
// Do NOT cache the fallback — a later call should get the real answer.
|
||||
return FALLBACK;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** Reset the cache. Test-only. */
|
||||
export function __resetPlaybackCapabilitiesCache(): void {
|
||||
cached = null;
|
||||
inflight = null;
|
||||
}
|
||||
@@ -103,15 +103,15 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for sync to server (the sync service will handle retry logic)
|
||||
// Report to the server. Rust queues the position for the next reconnect if
|
||||
// the server cannot be reached (DR-154), so a throw here means the report
|
||||
// did not land *this time* — not that the position was lost.
|
||||
if (userId && positionSeconds > 0) {
|
||||
try {
|
||||
// Get the repository to check if we should queue
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStopped(itemId, positionMs);
|
||||
} catch (e) {
|
||||
console.error("[PlaybackReporting] Failed to report to server:", e);
|
||||
// Server error - could queue, but for now just log
|
||||
console.warn("[PlaybackReporting] Stop-report did not reach the server; queued for sync:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,15 @@
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
// Types matching Rust structs
|
||||
export interface SyncQueueItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
operation: string;
|
||||
itemId: string | null;
|
||||
payload: string | null;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
createdAt: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
// The queue row shape comes from the generated bindings rather than a
|
||||
// hand-written mirror — the mirror had already drifted (it predates `itemName`),
|
||||
// and a drifted duplicate is how a field silently stops reaching the UI.
|
||||
import type { SyncQueueItem } from "$lib/api/bindings";
|
||||
export type { SyncQueueItem };
|
||||
|
||||
export type SyncOperation =
|
||||
| "mark_played"
|
||||
| "mark_unplayed"
|
||||
| "mark_favorite"
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
@@ -108,12 +102,30 @@ class SyncService {
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Update local state first
|
||||
await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId);
|
||||
// storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
|
||||
// it has to cover a season or series' episodes too. storageMarkPlayed stays
|
||||
// the single-item "this finished playing" path.
|
||||
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, true);
|
||||
|
||||
return this.queueMutation("mark_played", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue mark as unwatched, the inverse of {@link queueMarkPlayed}.
|
||||
*
|
||||
* Same shape deliberately: the watched toggle has to work in both directions
|
||||
* offline, or un-marking would be the one half that needs a connection. The
|
||||
* drain pushes this as `clear_watch_history` — Jellyfin's mark-unplayed, which
|
||||
* is recursive over a season or series and also clears resume positions.
|
||||
*
|
||||
* TRACES: UR-073 | DR-158
|
||||
*/
|
||||
async queueMarkUnplayed(itemId: string): Promise<number> {
|
||||
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, false);
|
||||
|
||||
return this.queueMutation("mark_unplayed", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending sync operations
|
||||
*/
|
||||
|
||||
@@ -23,31 +23,25 @@ import { events } from "$lib/api/bindings";
|
||||
import { playerController } from "$lib/player";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { WebviewAudioAdapter } from "$lib/player/adapters/webviewAudioAdapter";
|
||||
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
let audioEl: HTMLAudioElement | null = null;
|
||||
let adapter: WebviewAudioAdapter | null = null;
|
||||
|
||||
/** Platforms whose Rust backend renders audio in the webview rather than natively. */
|
||||
function usesWebviewAudio(): boolean {
|
||||
// Native audio backends exist only for Linux (mpv) and Android (ExoPlayer).
|
||||
// Everything else (Windows, and any future desktop) uses the webview element.
|
||||
// We detect "not linux/android" rather than "is windows" so new desktop
|
||||
// targets are covered automatically, matching the Rust cfg gate.
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isAndroid = ua.includes("android");
|
||||
const isLinux = ua.includes("linux") && !isAndroid;
|
||||
return !isAndroid && !isLinux;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the webview audio controller. Safe to call unconditionally from the
|
||||
* root layout; it self-gates on platform and is idempotent.
|
||||
*/
|
||||
export async function initWebviewAudio(): Promise<void> {
|
||||
if (unlisten) return;
|
||||
if (!usesWebviewAudio()) return;
|
||||
|
||||
// Whether this platform needs the webview element is a backend fact, so Rust
|
||||
// answers it. This used to sniff `navigator.userAgent` for "android"/"linux"
|
||||
// — a duplicate of the Rust cfg gate that could drift out of step with the
|
||||
// backends it was trying to describe.
|
||||
const { usesWebviewAudio } = await getPlaybackCapabilities();
|
||||
if (!usesWebviewAudio) return;
|
||||
|
||||
audioEl = document.createElement("audio");
|
||||
audioEl.hidden = true;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Native-video compositing state.
|
||||
//
|
||||
// TRACES: UR-003, UR-004 | DR-150, DR-152
|
||||
//
|
||||
// Two separate concerns live here, deliberately:
|
||||
//
|
||||
// 1. `experimentalNativeVideo` — the user-facing opt-in flag. Rust already
|
||||
// decides *which backend this platform has* (`useHtml5Element` from
|
||||
// `player_play_item`); this flag only *suppresses* that decision so a
|
||||
// half-working spike cannot ship as a regression. It never turns native on
|
||||
// where Rust says HTML5.
|
||||
//
|
||||
// 2. `nativeVideoActive` — whether a native surface is on screen right now.
|
||||
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
|
||||
// app.css keys off to clear the app's opaque backgrounds so the SurfaceView
|
||||
// behind the WebView is visible. It is deliberately NOT derived from the
|
||||
// flag: the backgrounds must come back the moment the player unmounts.
|
||||
//
|
||||
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
|
||||
// precedent in library.ts — no Rust settings command backs this.
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const STORAGE_KEY = "jellytau-experimental-native-video";
|
||||
|
||||
/** The attribute app.css keys its transparency rules off. */
|
||||
const NATIVE_VIDEO_ATTR = "data-native-video";
|
||||
|
||||
/**
|
||||
* Whether the native path is on. **Off** unless the user turned it on.
|
||||
*
|
||||
* DR-161 briefly made this default to on, so picture-in-picture could shrink a
|
||||
* real video surface. On a device that shipped as **audio with no picture**:
|
||||
* ExoPlayer decoded correctly and fed its SurfaceView, but the SurfaceView sits
|
||||
* *behind* the WebView and the compositing that clears the opaque layers above it
|
||||
* never took effect — logcat showed `WebView transparent = false` and never
|
||||
* `= true`. So the video was rendering the whole time, behind the page.
|
||||
*
|
||||
* That is the defect the flag existed to contain, and it is why the default is
|
||||
* back off: video working matters more than PiP showing the native surface, and
|
||||
* PiP still works without it via the HTML5 path (DR-160). Native video remains
|
||||
* available in Settings for anyone testing it.
|
||||
*
|
||||
* An explicit stored choice still wins in both directions, so anyone who turned
|
||||
* it on keeps it on.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-172
|
||||
*/
|
||||
function load(): boolean {
|
||||
if (typeof localStorage === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
// Private-mode / disabled storage — default to the safe (HTML5) path.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persist(enabled: boolean) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(enabled));
|
||||
} catch {
|
||||
// Quota or private-mode failure — keep the in-memory value.
|
||||
}
|
||||
}
|
||||
|
||||
function createExperimentalNativeVideoStore() {
|
||||
const { subscribe, set } = writable<boolean>(load());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set(enabled: boolean) {
|
||||
persist(enabled);
|
||||
set(enabled);
|
||||
},
|
||||
/** Read the current value without subscribing (init-time decisions). */
|
||||
current: load,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User opt-in for the native Android video path. **Defaults to off** again since
|
||||
* DR-172 — see `load()`. The name says "experimental" because the flag
|
||||
* remains a suppressor of Rust's backend choice, not a promoter of it.
|
||||
*/
|
||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||
|
||||
function createNativeVideoActiveStore() {
|
||||
const { subscribe, set } = writable<boolean>(false);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
/**
|
||||
* Mark a native video surface as visible (or gone) and sync the <html>
|
||||
* attribute that app.css uses to clear opaque backgrounds.
|
||||
*/
|
||||
set(active: boolean) {
|
||||
if (typeof document !== "undefined") {
|
||||
if (active) {
|
||||
document.documentElement.setAttribute(NATIVE_VIDEO_ATTR, "active");
|
||||
} else {
|
||||
document.documentElement.removeAttribute(NATIVE_VIDEO_ATTR);
|
||||
}
|
||||
}
|
||||
set(active);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a native video surface is currently on screen. Must be cleared on
|
||||
* player teardown, or the rest of the app renders over a transparent window.
|
||||
*/
|
||||
export const nativeVideoActive = createNativeVideoActiveStore();
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Immersive (system-bar-free) full-screen video, Android only.
|
||||
*
|
||||
* TRACES: UR-066 | DR-157
|
||||
*
|
||||
* `requestFullscreen()` is the only fullscreen control the web layer has, and in
|
||||
* an Android WebView it does not touch the Activity window — it expands the
|
||||
* element inside a viewport that already spans the whole screen (MainActivity
|
||||
* calls `enableEdgeToEdge()`, and SDK 36 makes that mandatory). So the status and
|
||||
* navigation bars stayed painted over full-screen video, and "fullscreen"
|
||||
* changed nothing visible.
|
||||
*
|
||||
* Hiding them needs `WindowInsetsControllerCompat` on the Activity, so it goes
|
||||
* through the `AndroidImmersive` @JavascriptInterface installed by MainActivity.
|
||||
* Elsewhere (desktop, the Linux WebKitGTK webview) the real `requestFullscreen()`
|
||||
* already does the right thing and these calls are no-ops.
|
||||
*/
|
||||
|
||||
interface AndroidImmersiveBridge {
|
||||
enter(): void;
|
||||
exit(): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidImmersive?: AndroidImmersiveBridge;
|
||||
}
|
||||
}
|
||||
|
||||
function bridge(): AndroidImmersiveBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.AndroidImmersive;
|
||||
}
|
||||
|
||||
/** Whether native immersive mode exists on this platform. */
|
||||
export function isImmersiveSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[Immersive] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide the system bars. No-op where unsupported. */
|
||||
export function enterImmersive(): void {
|
||||
try {
|
||||
bridge()?.enter();
|
||||
} catch (err) {
|
||||
console.error("[Immersive] Failed to hide the system bars:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the system bars. No-op where unsupported.
|
||||
*
|
||||
* Call this on leaving fullscreen *and* on player teardown — the bars belong to
|
||||
* the Activity, not the player, so a player destroyed while immersive would
|
||||
* leave every screen behind it without a status or navigation bar.
|
||||
*/
|
||||
export function exitImmersive(): void {
|
||||
try {
|
||||
bridge()?.exit();
|
||||
} catch (err) {
|
||||
console.error("[Immersive] Failed to restore the system bars:", err);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
showBottomNav,
|
||||
showGlobalMiniPlayer,
|
||||
showGlobalHeader,
|
||||
showHeaderSearch,
|
||||
routeOwnsLayout,
|
||||
showBottomUi,
|
||||
shellReservesBottomInset,
|
||||
@@ -179,3 +180,25 @@ describe("shellReservesBottomInset", () => {
|
||||
expect(shellReservesBottomInset({ pathname: "/", isAuthenticated: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showHeaderSearch", () => {
|
||||
it("renders the bar on the library routes", () => {
|
||||
expect(showHeaderSearch({ pathname: "/library" })).toBe(true);
|
||||
expect(showHeaderSearch({ pathname: "/library/music/albums" })).toBe(true);
|
||||
expect(showHeaderSearch({ pathname: "/library/abc123" })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the bar on /search, so searching does not swap you to another input", () => {
|
||||
// The regression: the bar existed only under /library, so a header search
|
||||
// landed the user on /search with the box they were typing in gone and a
|
||||
// different one (belonging to the page) in its place.
|
||||
expect(showHeaderSearch({ pathname: "/search" })).toBe(true);
|
||||
expect(showHeaderSearch({ pathname: "/search/" })).toBe(true);
|
||||
});
|
||||
|
||||
it("stays off routes with nothing to search", () => {
|
||||
expect(showHeaderSearch({ pathname: "/" })).toBe(false);
|
||||
expect(showHeaderSearch({ pathname: "/downloads" })).toBe(false);
|
||||
expect(showHeaderSearch({ pathname: "/settings" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* TRACES: UR-005 | DR-009
|
||||
*/
|
||||
|
||||
import { isSearchRoute } from "$lib/utils/searchScope";
|
||||
|
||||
export interface BottomUiVisibilityInput {
|
||||
/** Current route pathname, e.g. `$page.url.pathname`. */
|
||||
pathname: string;
|
||||
@@ -90,6 +92,21 @@ export function showGlobalHeader({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the header renders its search box on this route (md+ only; below md
|
||||
* the bottom-nav Search tab and /search's own input serve that role).
|
||||
*
|
||||
* `/search` is included deliberately: the bar is the single md+ search input,
|
||||
* so it must survive the hop onto the results page instead of being replaced by
|
||||
* a second input belonging to that page. The library routes keep it because
|
||||
* that is where a search is most often started.
|
||||
*
|
||||
* TRACES: UR-049, UR-054 | DR-063
|
||||
*/
|
||||
export function showHeaderSearch({ pathname }: { pathname: string }): boolean {
|
||||
return pathname.startsWith("/library") || isSearchRoute(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any bottom UI is showing for this route (mini player, nav, or both).
|
||||
* The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte),
|
||||
|
||||
@@ -18,6 +18,7 @@ interface AndroidPictureInPictureBridge {
|
||||
isSupported(): boolean;
|
||||
canEnterPip(): boolean;
|
||||
setAutoEnterEnabled(enabled: boolean): void;
|
||||
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -84,3 +85,36 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
console.warn("[PiP] Failed to set auto-enter:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
|
||||
*
|
||||
* This is what makes PiP work on the HTML5 path. The native side only ever knew
|
||||
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
|
||||
* which defaulted to off when this was written — so `canEnterPip` was always
|
||||
* false and pressing the button did nothing. Reporting the element's state gives
|
||||
* native a surface it can legitimately shrink into, plus the intrinsic size it
|
||||
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
||||
* action.
|
||||
*
|
||||
* The flag is back to defaulting **off** (DR-172, after native video shipped as
|
||||
* audio with no picture), so this is once again the path Android normally takes —
|
||||
* which is why PiP does not depend on that flag being on.
|
||||
*
|
||||
* Pass `active: false` when the element goes away, or PiP would be offered over a
|
||||
* video that is no longer there.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
export function setHtml5VideoState(
|
||||
active: boolean,
|
||||
width: number,
|
||||
height: number,
|
||||
playing: boolean
|
||||
): void {
|
||||
try {
|
||||
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
||||
} catch (err) {
|
||||
console.warn("[PiP] Failed to report HTML5 video state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Wires a persistent scroll container to the per-route scroll memory.
|
||||
*
|
||||
* The decision logic is pure and lives in `scrollRestore.ts`; this is the thin
|
||||
* DOM/SvelteKit half. Call it once at component init (SvelteKit's navigation
|
||||
* hooks must be registered during initialisation, not from `onMount`), passing
|
||||
* a getter for the element — the element itself is bound later, so a getter is
|
||||
* the only way to hand it over from the top of `<script>`.
|
||||
*
|
||||
* let scroller: HTMLElement | undefined = $state();
|
||||
* useScrollRestore(() => scroller, "library");
|
||||
* …
|
||||
* <div bind:this={scroller} class="flex-1 overflow-y-auto">
|
||||
*
|
||||
* Memories are keyed by container id and held at module scope, not per call.
|
||||
* Two containers must never share one (the root, home and library scrollers
|
||||
* hold different content for the same URL, so a shared map would restore one
|
||||
* into another) — but a container that *remounts* has to find its offsets again
|
||||
* when it comes back. The home scroller is destroyed on every navigation away,
|
||||
* so a memory owned by the component instance would be empty on return and Back
|
||||
* could only ever land at the top.
|
||||
*
|
||||
* TRACES: UR-072 | DR-156
|
||||
*/
|
||||
|
||||
import { beforeNavigate, afterNavigate } from "$app/navigation";
|
||||
import { tick } from "svelte";
|
||||
import { ScrollMemory, classifyNavigation, scrollKey } from "./scrollRestore";
|
||||
|
||||
/** Container id → its offsets. Outlives the components that mount them. */
|
||||
const memories = new Map<string, ScrollMemory>();
|
||||
|
||||
function memoryFor(containerId: string): ScrollMemory {
|
||||
let memory = memories.get(containerId);
|
||||
if (!memory) {
|
||||
memory = new ScrollMemory();
|
||||
memories.set(containerId, memory);
|
||||
}
|
||||
return memory;
|
||||
}
|
||||
|
||||
/** Forget every container's offsets. For sign-out and tests. */
|
||||
export function clearScrollMemories(): void {
|
||||
memories.clear();
|
||||
}
|
||||
|
||||
export function useScrollRestore(
|
||||
getElement: () => HTMLElement | null | undefined,
|
||||
containerId: string
|
||||
): void {
|
||||
const memory = memoryFor(containerId);
|
||||
|
||||
// Record where we were before the route changes. `nav.from` is absent on the
|
||||
// very first navigation, which is exactly when there is nothing to save.
|
||||
beforeNavigate((nav) => {
|
||||
const element = getElement();
|
||||
if (!element || !nav.from) return;
|
||||
memory.save(scrollKey(nav.from.url), element.scrollTop);
|
||||
});
|
||||
|
||||
afterNavigate(async (nav) => {
|
||||
const target = nav.to;
|
||||
if (!target) return;
|
||||
|
||||
const action = memory.decide(scrollKey(target.url), classifyNavigation(nav));
|
||||
if (action.kind === "none") return;
|
||||
|
||||
const top = action.kind === "restore" ? action.top : 0;
|
||||
|
||||
// Wait for the new route's markup to be in the DOM before moving the
|
||||
// scroller — setting scrollTop past the current content height is clamped,
|
||||
// and a reset applied too early is undone by the incoming render.
|
||||
await tick();
|
||||
const element = getElement();
|
||||
if (!element) return;
|
||||
|
||||
element.scrollTop = top;
|
||||
|
||||
// A restore often targets content that is still loading (a library grid
|
||||
// fetches after mount), so the offset would clamp to a short page. Re-apply
|
||||
// on the next frame, once, which is enough for the common case without
|
||||
// fighting a user who has already started scrolling.
|
||||
if (action.kind === "restore" && top > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = getElement();
|
||||
if (el && el.scrollTop < top) el.scrollTop = top;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { ScrollMemory, classifyNavigation } from "./scrollRestore";
|
||||
|
||||
describe("classifyNavigation", () => {
|
||||
it("treats the initial page load as an entry", () => {
|
||||
expect(classifyNavigation({ type: "enter" })).toBe("enter");
|
||||
});
|
||||
|
||||
it("treats back/forward gestures as a popstate", () => {
|
||||
expect(classifyNavigation({ type: "popstate" })).toBe("popstate");
|
||||
});
|
||||
|
||||
it("treats link and goto navigations as forward moves", () => {
|
||||
expect(classifyNavigation({ type: "link" })).toBe("forward");
|
||||
expect(classifyNavigation({ type: "goto" })).toBe("forward");
|
||||
expect(classifyNavigation({ type: "form" })).toBe("forward");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScrollMemory", () => {
|
||||
let memory: ScrollMemory;
|
||||
|
||||
beforeEach(() => {
|
||||
memory = new ScrollMemory();
|
||||
});
|
||||
|
||||
// The bug: a scroll container that lives in a persistent layout keeps its
|
||||
// offset across a forward navigation, so a page opened from a scrolled list
|
||||
// starts part-way down. A forward move must always land at the top.
|
||||
it("resets to the top on a forward navigation, even from a scrolled page", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
expect(memory.decide("/library/abc123", "forward")).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("resets to the top when navigating forward to a page seen before", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/search", 340);
|
||||
|
||||
// Re-entering /library by tapping a nav link is a fresh visit, not a Back.
|
||||
expect(memory.decide("/library", "forward")).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("restores the saved offset on Back", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
});
|
||||
|
||||
it("restores the top when Back targets a page with no saved offset", () => {
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 0 });
|
||||
});
|
||||
|
||||
it("keeps offsets per route rather than sharing one across pages", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/search", 340);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
expect(memory.decide("/search", "popstate")).toEqual({ kind: "restore", top: 340 });
|
||||
});
|
||||
|
||||
it("leaves the container alone on the initial load", () => {
|
||||
expect(memory.decide("/", "enter")).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("overwrites a stale offset when the same route is saved again", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/library", 80);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 80 });
|
||||
});
|
||||
|
||||
it("forgets nothing on decide, so a repeated Back still restores", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
memory.decide("/library", "popstate");
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Per-route scroll memory for the app's persistent scroll containers.
|
||||
*
|
||||
* The shell keeps its scrollers alive across navigation on purpose: the root
|
||||
* layout, the home page and the library layout each own a
|
||||
* `flex-1 overflow-y-auto` box that outlives the route rendered inside it. That
|
||||
* is what makes the bottom UI a flex sibling rather than a measured overlay —
|
||||
* but it also means the *element* never remounts, so its `scrollTop` survives a
|
||||
* route change and the next page opens part-way down.
|
||||
*
|
||||
* SvelteKit's own scroll restoration cannot help here: it saves and restores
|
||||
* `window` scroll, and in this app the window never scrolls at all.
|
||||
*
|
||||
* So each container gets its own memory, which reproduces normal browser
|
||||
* behaviour:
|
||||
*
|
||||
* - **forward** (link/goto/form) — a fresh visit, always lands at the top;
|
||||
* - **popstate** (hardware/gesture Back or Forward) — restores the offset the
|
||||
* route was left at, so Back out of a detail page returns you to your place
|
||||
* in the list rather than to the top of it;
|
||||
* - **enter** (initial load) — left alone; there is nothing to leak yet.
|
||||
*
|
||||
* The decision is pure and lives here so it can be unit-tested without a DOM;
|
||||
* `scrollContainer.svelte.ts` is the thin action that applies it.
|
||||
*
|
||||
* TRACES: UR-054 | DR-156
|
||||
*/
|
||||
|
||||
/** How a navigation should affect a persistent scroll container. */
|
||||
export type NavKind = "enter" | "popstate" | "forward";
|
||||
|
||||
/** What to do with the container once the new route has rendered. */
|
||||
export type ScrollAction =
|
||||
| { kind: "reset" }
|
||||
| { kind: "restore"; top: number }
|
||||
| { kind: "none" };
|
||||
|
||||
/**
|
||||
* Collapse SvelteKit's navigation types into the three cases that matter.
|
||||
*
|
||||
* `enter` is the initial load. `popstate` is a Back/Forward gesture. Everything
|
||||
* else — `link`, `goto`, `form` — is a forward move into a new page.
|
||||
*/
|
||||
export function classifyNavigation(nav: { type?: string | null }): NavKind {
|
||||
if (nav.type === "enter") return "enter";
|
||||
if (nav.type === "popstate") return "popstate";
|
||||
return "forward";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the offset each route was left at, for one scroll container.
|
||||
*
|
||||
* One instance per container: the root scroller, the home scroller and the
|
||||
* library scroller hold different content for the same URL, so a shared map
|
||||
* would restore one container's offset into another.
|
||||
*/
|
||||
export class ScrollMemory {
|
||||
#offsets = new Map<string, number>();
|
||||
|
||||
/** Record where `key` was scrolled to, before we navigate away from it. */
|
||||
save(key: string, top: number): void {
|
||||
this.#offsets.set(key, Math.max(0, top));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what the container should do on arriving at `key`.
|
||||
*
|
||||
* Note this does not consume the saved offset: a route can be returned to
|
||||
* more than once, and each Back should restore the same place.
|
||||
*/
|
||||
decide(key: string, kind: NavKind): ScrollAction {
|
||||
if (kind === "enter") return { kind: "none" };
|
||||
if (kind === "popstate") return { kind: "restore", top: this.#offsets.get(key) ?? 0 };
|
||||
return { kind: "reset" };
|
||||
}
|
||||
|
||||
/** Drop everything. Intended for tests and sign-out. */
|
||||
clear(): void {
|
||||
this.#offsets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory key for a URL.
|
||||
*
|
||||
* Path plus query: a library grid filtered by genre is a different list from
|
||||
* the unfiltered one, and returning to it should restore its own place.
|
||||
*/
|
||||
export function scrollKey(url: { pathname: string; search?: string }): string {
|
||||
return `${url.pathname}${url.search ?? ""}`;
|
||||
}
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
composeSearchGroups,
|
||||
DEFAULT_GROUP_ORDER,
|
||||
groupsForScope,
|
||||
isSearchRoute,
|
||||
moveGroup,
|
||||
normalizeGroupOrder,
|
||||
parseSearchScope,
|
||||
reorderGroups,
|
||||
resolveSearchScope,
|
||||
searchRouteUrl,
|
||||
seedFromSearchUrl,
|
||||
shouldNavigateToSearch,
|
||||
type SearchGroupId,
|
||||
} from "./searchScope";
|
||||
@@ -397,3 +400,66 @@ describe("shouldNavigateToSearch", () => {
|
||||
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSearchRoute", () => {
|
||||
it("recognises the search surface through query strings and trailing slashes", () => {
|
||||
expect(isSearchRoute("/search")).toBe(true);
|
||||
expect(isSearchRoute("/search/")).toBe(true);
|
||||
expect(isSearchRoute("/search?q=jazz&scope=music")).toBe(true);
|
||||
expect(isSearchRoute("/library/music")).toBe(false);
|
||||
expect(isSearchRoute("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSearchScope", () => {
|
||||
it("accepts the known scopes", () => {
|
||||
expect(parseSearchScope("music")).toBe("music");
|
||||
expect(parseSearchScope("tv")).toBe("tv");
|
||||
});
|
||||
|
||||
it("falls back on absent or unrecognised values", () => {
|
||||
expect(parseSearchScope(null)).toBe("all");
|
||||
expect(parseSearchScope("")).toBe("all");
|
||||
expect(parseSearchScope("books")).toBe("all");
|
||||
expect(parseSearchScope(null, "music")).toBe("music");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedFromSearchUrl", () => {
|
||||
const params = (search: string) => new URL(`http://x/search${search}`).searchParams;
|
||||
|
||||
it("seeds a fresh page from the URL", () => {
|
||||
expect(seedFromSearchUrl(params("?q=jazz&scope=music"), null)).toEqual({
|
||||
query: "jazz",
|
||||
scope: "music",
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty query so an emptied URL clears the page", () => {
|
||||
expect(seedFromSearchUrl(params(""), null)).toEqual({ query: "", scope: "all" });
|
||||
expect(seedFromSearchUrl(params(""), { query: "jazz", scope: "all" })).toEqual({
|
||||
query: "",
|
||||
scope: "all",
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for nothing once that same URL has been applied", () => {
|
||||
// The regression this exists for: the page must consume the URL once, not
|
||||
// keep re-asserting it. While the seed is unchanged the user's typing and
|
||||
// chip picks own the input — re-applying snapped it back a keystroke later.
|
||||
const applied = { query: "jazz", scope: "music" } as const;
|
||||
expect(seedFromSearchUrl(params("?q=jazz&scope=music"), applied)).toBeNull();
|
||||
});
|
||||
|
||||
it("re-seeds when the query or the scope actually changes", () => {
|
||||
const applied = { query: "jazz", scope: "music" } as const;
|
||||
expect(seedFromSearchUrl(params("?q=blues&scope=music"), applied)).toEqual({
|
||||
query: "blues",
|
||||
scope: "music",
|
||||
});
|
||||
expect(seedFromSearchUrl(params("?q=jazz"), applied)).toEqual({
|
||||
query: "jazz",
|
||||
scope: "all",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,16 @@ export function searchRouteUrl(query: string, scope: SearchScope): string {
|
||||
return `/search?${params.toString().replace(/\+/g, "%20")}`;
|
||||
}
|
||||
|
||||
/** Normalise a pathname for comparison: drop query, hash and trailing slashes. */
|
||||
function normalizePath(pathname: string): string {
|
||||
return pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
/** Whether `pathname` is the search surface itself. */
|
||||
export function isSearchRoute(pathname: string): boolean {
|
||||
return normalizePath(pathname) === "/search";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
|
||||
*
|
||||
@@ -82,8 +92,48 @@ export function searchRouteUrl(query: string, scope: SearchScope): string {
|
||||
*/
|
||||
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
|
||||
if (!query.trim()) return false;
|
||||
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
||||
return path !== "/search";
|
||||
return !isSearchRoute(pathname);
|
||||
}
|
||||
|
||||
/** Read a `?scope=` value, falling back when it is absent or unrecognised. */
|
||||
export function parseSearchScope(
|
||||
raw: string | null | undefined,
|
||||
fallback: SearchScope = "all"
|
||||
): SearchScope {
|
||||
return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback;
|
||||
}
|
||||
|
||||
/** The query + scope a `/search` URL asks the page to show. */
|
||||
export interface SearchSeed {
|
||||
query: string;
|
||||
scope: SearchScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a `/search` URL should seed the page with, or `null` if it asks for
|
||||
* nothing new.
|
||||
*
|
||||
* The URL is *consumed once per value*, not continuously reconciled against the
|
||||
* live input. `applied` is the seed the caller last took from the URL: while it
|
||||
* still matches, the user's own typing and chip picks govern, and only a real
|
||||
* navigation (the header search sending a new query) re-seeds the page.
|
||||
*
|
||||
* Reconciling instead of consuming was the bug this replaced — the old effect
|
||||
* compared the URL against `library.searchQuery`, so every keystroke's search
|
||||
* re-ran it and snapped the input back to the query the header had sent.
|
||||
*
|
||||
* TRACES: UR-049 | DR-063, DR-064
|
||||
*/
|
||||
export function seedFromSearchUrl(
|
||||
params: URLSearchParams,
|
||||
applied: SearchSeed | null
|
||||
): SearchSeed | null {
|
||||
const seed: SearchSeed = {
|
||||
query: params.get("q") ?? "",
|
||||
scope: parseSearchScope(params.get("scope")),
|
||||
};
|
||||
if (applied && applied.query === seed.query && applied.scope === seed.scope) return null;
|
||||
return seed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user