Compare commits

...
20 Commits
Author SHA1 Message Date
dtourolle 5759a97289 chore: ignore Arch packaging build artifacts
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m49s
Build & Release / Build Linux (push) Successful in 17m46s
Build & Release / Build Windows (push) Successful in 13m20s
Build & Release / Build Android (push) Successful in 29m14s
Build & Release / Create Release (push) Successful in 15s
A local `scripts/build-arch.sh` run leaves a vendored cargo cache
(`.cargo-arch/`), a makepkg workdir (`packaging/arch/pkg/`, `src/`) and the
built package in the tree — tens of thousands of untracked files that bury real
changes in `git status`.
2026-07-25 15:53:10 +02:00
dtourolle b9f026e215 chore(release): bump to 0.1.2
Adds CHANGELOG.md, which the release-notes template in docs/release-checklist.md
already linked to but which had never been created.
2026-07-25 15:21:51 +02:00
dtourolle b7a7037194 docs: add UR-060 search relevance requirement; regenerate matrix
Records the search relevance and grouping behaviour as UR-060, with DR-090
(Rust relevance ranking) and DR-091 (Shows/Episodes split, People group,
stored-order migration). DR-066 now points at DR-091 for the current group set
instead of restating a default order that has since changed.
2026-07-25 15:13:52 +02:00
dtourolle 124da29fc7 fix(search): route the library header search to /search
Typing in the desktop header search bar ran library.search() in place and
relied on /library rendering the results inline. On every other /library/**
route nothing rendered them, so the search bar looked broken: results were
fetched and never shown.

Make /search the single surface that renders results. The header bar becomes a
navigator — it hands the query and route-derived scope to /search via ?q= and
?scope=, which seed the page and run the search on arrival. The inline result
block and the header's scope chips are removed; the chips live on /search,
which owns the results. The empty `all` scope is omitted from the URL, and
typing while already on /search does not push a history entry per keystroke.
2026-07-25 15:13:45 +02:00
dtourolle 5927299c0f feat(search): rank results by match quality and split TV/People groups
Neither search backend orders by *where* the query matched, so a mid-word hit
could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above
"Parks and Recreation".

Add `domain/search_rank.rs`, which sorts by match position (prefix →
word-start → mid-word substring → no name match), then by media kind so a
container outranks its own contents. The sort is stable, so each backend's own
relevance still breaks ties it was never overruled on. `repository_search`
applies it to both the instant cache result and the merged cache+server union,
so the list does not reshuffle when server results land. Ranking lives in Rust
because "a better match" is domain vocabulary, not presentation.

On the frontend, the combined `tvShows` result group splits into separate
Shows and Episodes groups so a show no longer competes with its own episodes
for a slot, and a People group is added so searching an actor's name reaches
their bio. A stored `tvShows` order expands in place, keeping the position an
upgrading user chose for it.
2026-07-25 15:13:32 +02:00
dtourolle 7650efcb7f fix(player): scale video to fill the player viewport
The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.

Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
2026-07-25 15:12:53 +02:00
dtourolle 4b9350c949 chore(release): bump to 0.1.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m25s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 3m52s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m52s
Build & Release / Build Linux (push) Successful in 17m32s
Build & Release / Build Windows (push) Successful in 13m14s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 13s
2026-07-25 09:25:52 +02:00
dtourolle d01c1216b8 docs: red-green rule for bug fixes; regenerate traceability matrix
CLAUDE.md now states the failing-test-first rule explicitly: write a test
that reproduces the bug and watch it fail before applying the fix, and
extract buried logic into a plain .ts module so it can be unit-tested. A
test written against already-fixed code can pass for the wrong reason.
2026-07-25 09:21:22 +02:00
dtourolle fb967433f0 fix(library): populate "More Episodes" for series without season folders
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
2026-07-25 09:21:15 +02:00
dtourolle ee584aced2 fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a
MediaType::Audio item, so autoplay's video-only checks stopped
recognising it as an episode: playback simply ended at the episode
boundary instead of continuing to the next one.

- Carry episode identity (item_type, series_id) through the
  background-audio handoff so the backend queue item still knows it's an
  episode; is_episode_item now trusts item_type over the media_type
  heuristic, and the sleep timer's episode counter follows.
- The frontend normally performs the advance by navigating to
  /player/<id>, which is unavailable while the WebView is suspended.
  advance_to_next_episode_audio_only drives it entirely in the backend:
  fetch the next episode, build its audio-only stream URL, and load it
  into the native audio player, preserving episode identity so the
  following boundary advances too.
- Android's autoplay dispatch routes background-audio episodes to that
  backend advance and keeps the countdown path for the foreground.
- get_audio_only_stream_url_for_video joins the MediaRepository trait
  (online delegates to the existing builder, offline errors) so the
  controller can reach it without a frontend round-trip.

TRACES: UR-040, UR-023 | DR-052 | JA-032
2026-07-25 09:21:08 +02:00
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00
dtourolle c3ead64748 ci(release): build Windows NSIS installer on tag
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m37s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m13s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m36s
Build & Release / Build Linux (push) Successful in 17m58s
Build & Release / Build Windows (push) Successful in 22m16s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 15s
Adds a build-windows job to the release workflow, cross-compiling the
Windows NSIS installer from Linux via the builder image (MSVC target +
cargo-xwin, no toolchain installs). Wires its artifacts into
create-release alongside Linux and Android.

TRACES: UR-003 | DR-004
2026-07-25 00:02:33 +02:00
dtourolle 742ad88a29 feat(build): cross-platform desktop packaging; bump to 0.1.0
Build & Release / Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Build Linux (push) Has been cancelled
Build & Release / Build Android (push) Has been cancelled
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Build & Release / Create Release (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
Adds Docker-based packaging for Linux desktop (deb/rpm), Arch
(.pkg.tar.zst via makepkg), and Windows. Windows cross-compiles from
Linux via the official Tauri path — the x86_64-pc-windows-msvc target
driven by cargo-xwin — and produces an NSIS installer (nsis via
tauri.conf.json targets, since the CLI rejects --bundles nsis on a Linux
host). Verified end to end: builds jellytau.exe + jellytau_x64-setup.exe.

Unifies everything on one registry builder image (Dockerfile.builder):
Android SDK/NDK, rpm/file, clang(+clang-cl)/lld/llvm/nsis, cargo-xwin and
the msvc target. Packaging tools sit in a trailing layer so tool changes
rebuild in ~1min instead of ~15. Desktop stages are thin FROM
${BUILDER_IMAGE} layers; Arch uses a separate archlinux image.

CLAUDE.md: CI must install no system toolchains — everything lives in the
image. Bumps version 0.0.18 -> 0.1.0 (Windows support + webview audio +
equalizer).

TRACES: UR-003, UR-005 | DR-004
2026-07-24 23:49:50 +02:00
dtourolle d4e2cd120c feat(player): webview audio backend for platforms without a native one
Adds WebviewAudioBackend, used on non-Linux/non-Android targets (e.g.
Windows) where there is no libmpv/ExoPlayer. Instead of decoding, it
emits a WebviewAudioLoad event with the stream URL; a frontend <audio>
element (WebviewAudioAdapter + webviewAudio service) plays it and reports
state/position back through the existing player_report_* round-trip, so
the Rust PlayerController stays the single source of truth. Play/pause/
seek reach the element via the existing ControlCommand event.

All video already renders in the webview on every platform, so this
completes audio-only playback for Windows (video via WebView2, audio via
<audio>). Pure Rust + Tauri events, so it still cross-compiles from Linux.

Regenerates bindings.ts (adds webview_audio_load; also carries the
equalizer EQ bindings).

TRACES: UR-003, UR-004, UR-005 | DR-004
2026-07-24 23:49:23 +02:00
dtourolle c543f90ad3 feat(audio): graphic equalizer with presets and custom bands
Adds a 10-band graphic equalizer to AudioSettings (enabled flag +
per-band dB gains, normalised to 10 entries and clamped to range).
Presets return gain curves; the settings page gains EQ UI. libmpv
applies the filter on Linux (Android parity pending). Old persisted
settings without EQ fields load as disabled + flat.

Also includes the requirements/traceability/ux-flows doc updates for
this feature and the home long-press routing (UR-058/DR-087).

TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
2026-07-24 23:49:13 +02:00
dtourolle 589f08b873 feat(home): tap opens detail, long-press plays from home cards
Home carousel cards route a tap to the item's detail / Episode Focus
View and a ~500ms long-press to a confirm-then-play flow. MediaCard gains
an onLongPress prop with pointer-based detection (cancelled on >10px move
so carousel scroll is unaffected, trailing click suppressed). Episode
taps route to /library/<seriesId>?episode=<id>; the bare-episode detail
page links back to its parent series/season.

TRACES: UR-058 | DR-087
2026-07-24 23:49:03 +02:00
dtourolle e2c9d68311 docs(downloads): mark UR-055/056 Done; fix colliding UT ids
The browsable Downloaded library + Transfers split + on-disk usage
(f25deba, plus today's grouping/perf fixes) fully implement UR-055 and
UR-056, but the requirements doc still listed them and DR-081..085 as
Planned. Flip to Done.

Also fix UT-id collisions: the downloaded-browse and formatBytes tests
reused UT-046..050 (already assigned to smart-cache/playlist tests in the
matrix). Reassign to UT-071..078 and register them in §4, including the
new music/TV container-rollup and orphan-leaf regression tests.
2026-07-24 22:22:43 +02:00
dtourolle 57b24f8c74 fix(offline): Downloaded browse groups by container and loads on large libraries
Two bugs on the Downloaded browse surface (UR-055/UR-056):

1. Grouping — browsing a downloaded *library* listed individual leaves
   (songs, episodes) instead of their containers. The library-level match in
   `get_downloaded_items` selected every downloaded item on the server; add a
   NOT EXISTS clause so the top level shows only albums/series/movies, with
   leaves still reachable by drilling in. Regression tests for music + TV.

2. "Loading your downloads…" hung on large libraries. The disk-usage
   partiality query did an OR-based self-join over the entire synced catalog
   (O(items^2), unindexable). Narrow it to downloaded containers first via a
   CTE, and add the missing idx_items_season index (migration 020 + base
   schema) — parent_id/album_id/series_id were already indexed.

Also annotate the existing backend tests that cover the IT-016/IT-017
end-to-end offline-listing scenarios with their trace IDs.
2026-07-24 21:42:12 +02:00
dtourolle 6391720d23 docs(offline): mark UR-052 offline-listing feature and its tests Done
The DR-079/DR-080 root-cause fixes for issue #10 landed in 8f4f651; the
requirements doc still listed UR-052 (and DR-078/079/080, UT-068/069/070,
IT-016/017) as Broken/Partial/Pending. Flip them to Done and regenerate the
traceability matrix. Existing backend tests already cover the IT-016/017
end-to-end scenarios (annotated with their IDs in the code commits).
2026-07-24 21:41:57 +02:00
dtourolle 90f03dd142 fix(player): show background-audio button on all Android video playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m18s
Traceability Validation / Check Requirement Traces (push) Successful in 1m2s
Build & Release / Run Tests (push) Successful in 10m46s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m16s
Build & Release / Build Linux (push) Successful in 24m59s
Build & Release / Build Android (push) Successful in 33m5s
Build & Release / Create Release (push) Successful in 17s
The audio-only (background-audio) button was gated on the
AndroidBackgroundAudio JS-bridge probe, resolved once as a const at mount.
The bridge is injected into the WebView asynchronously and races component
mount, so on some loads the probe returned false and never recovered,
hiding the button on 'some videos' at random.

Gate on platform() === 'android' instead (synchronous, stable), matching
the convention in VolumeControl. toggleBackgroundAudio() already no-ops if
the bridge is momentarily absent.

Bump version to 0.0.18.
2026-07-24 20:32:08 +02:00
72 changed files with 5361 additions and 906 deletions
+69 -2
View File
@@ -121,6 +121,64 @@ jobs:
path: dist/linux/
retention-days: 30
build-windows:
name: Build Windows
runs-on: linux/amd64
needs: test
# Cross-compiled from Linux via the official Tauri path (MSVC + cargo-xwin),
# baked into the builder image. No toolchain installs here — the image has
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cache/cargo-xwin
src-tauri/target
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-windows-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- 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
- name: Build Windows (NSIS installer + exe)
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
- name: List Windows artifacts
run: ls -lah dist/windows/
- name: Upload Windows build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-windows
path: dist/windows/
retention-days: 30
build-android:
name: Build Android
runs-on: linux/amd64
@@ -239,7 +297,7 @@ jobs:
create-release:
name: Create Release
runs-on: linux/amd64
needs: [build-linux, build-android]
needs: [build-linux, build-windows, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
@@ -259,6 +317,12 @@ jobs:
name: jellytau-linux
path: artifacts/linux/
- name: Download Windows artifacts
uses: actions/download-artifact@v3
with:
name: jellytau-windows
path: artifacts/windows/
- name: Download Android artifacts
uses: actions/download-artifact@v3
with:
@@ -277,6 +341,9 @@ jobs:
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
echo "" >> release_notes.md
echo "#### Windows" >> release_notes.md
echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
echo "" >> release_notes.md
echo "#### Android" >> release_notes.md
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
@@ -358,7 +425,7 @@ jobs:
fi
echo "Release id=$RELEASE_ID"
for f in artifacts/android/* artifacts/linux/*; do
for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do
[ -f "$f" ] || continue
echo "⬆️ Uploading $(basename "$f")"
curl -fsS -X POST \
+6
View File
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
/docs/README.md
/docs/api-redirect.md
/docs-site/book/
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
/.cargo-arch/
/packaging/arch/pkg/
/packaging/arch/src/
/packaging/arch/*.pkg.tar.zst
+47
View File
@@ -0,0 +1,47 @@
# Changelog
All notable changes to JellyTau are documented here.
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.1.2
### ✨ Features
- **Search results are ordered by how well they match.** A name that *starts*
with the query now outranks one matching mid-word — typing "parks" finds
"Parks and Recreation" before "Sparks of Love" — and at equal match quality a
container outranks its contents, so a series lands above its own episodes.
Ranking is applied to the instant cached results and to the merged
cache+server list alike, so the list no longer reshuffles when server results
arrive. (UR-060, DR-090)
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
group splits into Shows and Episodes so a show never competes with its own
episodes for a slot, and a new People group means searching an actor's name
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
Albums → Artists → People; a group order saved before the split keeps the
position it was dragged to. (UR-060, DR-091)
### 🐛 Bug Fixes
- **The library header search bar works on every library page.** It previously
searched in place and depended on `/library` rendering results inline, so on
any other `/library/**` route the results were fetched and never shown.
`/search` is now the single surface that renders results, and the header bar
hands its query and scope over via the URL. (UR-049, DR-063)
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
oversized media, so a 480p source on a 1080p display played as a small picture
in the middle of a black frame. The picture now fits whichever axis constrains
it, in both directions, preserving aspect ratio. (UR-005)
### 📋 Requirements
**Linux:** 64-bit, GLIBC 2.29+
**Android:** 8.0+
## v0.1.1 and earlier
Released before this file existed — see the git history and the release notes on
each tag.
+31
View File
@@ -35,6 +35,20 @@ CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`.
> **🔴 CI installs no system tools.** Never add an `apt-get`, `rustup`,
> `sdkmanager`, mingw/nsis, or any other *toolchain/system-package* install to a
> CI workflow step. Every build, test, and packaging **tool** must already live
> in the Docker image the job runs in — the unified builder (`Dockerfile.builder`
> → `gitea.tourolle.paris/dtourolle/jellytau-builder`) for Android/Linux/Windows,
> or `Dockerfile.arch` for Arch. If a job needs a tool the image lacks, **add it
> to the image, rebuild + push it** (`scripts/build-builder-image.sh`), and use
> it from CI — do not install it at job time. This keeps builds reproducible and
> fast, and is why the packaging stages are thin `FROM ${BUILDER_IMAGE}` layers.
>
> `bun install` (fetching the project's own JS deps per the lockfile) is **not**
> a violation — that's project dependencies, not a toolchain. The rule is about
> system tools, not npm/bun/cargo *packages* declared by the project.
## Before Committing
- Frontend: `bun run check` and `bun run test` must pass.
@@ -252,6 +266,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
## Testing
### 🔴 Bug fixes: failing test FIRST, then the fix
When fixing a bug, **write a test that reproduces it and watch it fail before
touching the fix.** Red → green, in that order:
1. Write a test that exercises the broken behavior and **run it — it must fail**,
proving the test actually catches the bug (a test that passes before the fix
proves nothing).
2. Apply the fix.
3. Re-run — the test now passes, and so does the rest of the suite.
Never fix first and backfill the test afterward: a test written against
already-fixed code can pass for the wrong reason and silently fails to guard the
regression. If the logic is buried in a component, extract the pure part into a
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
pattern as `TrackList.logic.test.ts`.
```bash
# Rust
cd src-tauri && cargo test
+31
View File
@@ -1,4 +1,11 @@
# Multi-stage build for JellyTau - Tauri Jellyfin client
#
# The desktop packaging stages (desktop-linux-build, windows-cross) build FROM
# the unified registry builder image, which carries every packaging tool. Declared
# here (before the first FROM) so it's in scope for those stages' FROM lines.
# Override for local iteration: --build-arg BUILDER_IMAGE=jellytau-builder:latest
ARG BUILDER_IMAGE=gitea.tourolle.paris/dtourolle/jellytau-builder:latest
FROM ubuntu:24.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive \
@@ -108,6 +115,30 @@ RUN cd src-tauri && cargo fetch && cd .. && \
bun run tauri android build --apk true && \
echo "APK build complete!"
# Desktop packaging stages build FROM the unified registry builder image (see the
# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
# (rpm/file for Linux, mingw-w64 + nsis + the x86_64-pc-windows-gnu rust target
# for Windows). ONE source of dependency truth, shared with CI — no per-stage
# apt/rustup here.
# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
# Thin layer over the builder — the actual build runs at container-run time on
# the bind-mounted source (see docker-compose.yml / scripts/build-desktop-linux.sh),
# matching the `dev` service model. Run standalone with:
# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/app/dist" <img> \
# bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
FROM ${BUILDER_IMAGE} AS desktop-linux-build
WORKDIR /app
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
# exe-only. Build runs at container-run time like above.
FROM ${BUILDER_IMAGE} AS windows-cross
WORKDIR /app
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"]
# Final output stage
FROM ubuntu:24.04 AS final
RUN apt-get update && apt-get install -y --no-install-recommends \
+37
View File
@@ -0,0 +1,37 @@
# JellyTau Arch Linux package builder.
#
# Tauri has no pacman bundle target, so we build a real .pkg.tar.zst with makepkg
# from packaging/arch/PKGBUILD. makepkg refuses to run as root, so we create a
# non-root `builder` user with passwordless sudo (for `makepkg -s` pacman calls).
#
# docker build -f Dockerfile.arch -t jellytau-arch .
# docker run --rm -v "$PWD/dist:/out" jellytau-arch
FROM archlinux:latest
RUN pacman -Syu --noconfirm \
base-devel git sudo \
rust cargo nodejs \
webkit2gtk-4.1 mpv gtk3 libayatana-appindicator \
libsoup3 pkgconf openssl \
&& pacman -Scc --noconfirm
# Bun is not in the official repos; install the upstream binary.
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /root/.bun/bin/bun /usr/local/bin/bun
# Non-root build user with passwordless sudo for makepkg's dependency step.
RUN useradd -m builder && \
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder && \
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
WORKDIR /app
COPY . .
RUN chown -R builder:builder /app
USER builder
ENV OUTPUT_DIR=/out
RUN mkdir -p /out
VOLUME ["/out"]
# Default: build the package. Output lands in /out (mount it to collect the pkg).
CMD ["bash", "-c", "OUTPUT_DIR=/out scripts/build-arch.sh"]
+33 -1
View File
@@ -1,5 +1,9 @@
# JellyTau Builder Image
# Pre-built image with all dependencies for building and testing
# Pre-built image with all dependencies for building, testing, and packaging:
# - Android APK (SDK/NDK), Linux desktop (deb/rpm),
# - Windows cross via the official Tauri path: MSVC target + cargo-xwin + NSIS
# Arch packages build in a separate archlinux image (Dockerfile.arch) since
# makepkg is Arch-specific.
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
FROM ubuntu:24.04
@@ -83,6 +87,34 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
# ---------------------------------------------------------------------------
# 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
# layers above (a tool tweak becomes a ~1-2 min rebuild, not ~15). Covers Linux
# (deb/rpm) and Windows cross (MSVC via cargo-xwin + NSIS).
RUN apt-get update && apt-get install -y --no-install-recommends \
# Linux desktop packaging: rpmbuild for the .rpm bundle (deb needs nothing extra)
rpm \
file \
# Windows cross-compile (official Tauri path: MSVC target via cargo-xwin).
# clang provides clang-cl, the MSVC-compatible C compiler cc-rs uses to build
# C deps (bundled sqlite, ring, ...); lld = linker; llvm = llvm-lib/ar etc;
# nsis = installer generator.
clang \
lld \
llvm \
nsis \
&& rm -rf /var/lib/apt/lists/* \
# Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
# invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
# so provide it as a symlink.
&& ln -sf /usr/bin/clang /usr/local/bin/clang-cl
# Windows rust target + cargo-xwin (downloads the MSVC CRT/SDK at build time).
RUN . $HOME/.cargo/env && \
rustup target add x86_64-pc-windows-msvc && \
cargo install --locked cargo-xwin
WORKDIR /app
ENTRYPOINT ["/bin/bash"]
+52
View File
@@ -33,6 +33,58 @@ services:
ports:
- "5172:5172" # In case you want to run dev server
# Linux desktop packages - deb + rpm + pacman into ./dist
desktop-linux-build:
build:
context: .
dockerfile: Dockerfile
target: desktop-linux-build
args:
# Defaults to the registry builder (Dockerfile's ARG). Point at a locally
# built builder with: BUILDER_IMAGE=jellytau-builder:latest docker compose ...
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
container_name: jellytau-desktop-linux-build
volumes:
- .:/app
- cargo-cache:/root/.cargo
- bun-cache:/root/.bun
environment:
- RUST_BACKTRACE=1
- OUTPUT_DIR=/app/dist
command: bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
# Arch Linux package (.pkg.tar.zst via makepkg) into ./dist
arch-build:
build:
context: .
dockerfile: Dockerfile.arch
container_name: jellytau-arch-build
volumes:
- ./dist:/out
environment:
- RUST_BACKTRACE=1
- OUTPUT_DIR=/out
# Windows cross-compile (MSVC via cargo-xwin). Emits NSIS installer + .exe to
# ./dist. Override WIN_BUNDLES=none for exe-only.
windows-cross:
build:
context: .
dockerfile: Dockerfile
target: windows-cross
args:
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
container_name: jellytau-windows-cross
volumes:
- .:/app
- cargo-cache:/root/.cargo
- bun-cache:/root/.bun
environment:
- RUST_BACKTRACE=1
- OUTPUT_DIR=/app/dist
- WIN_BUNDLES=${WIN_BUNDLES:-nsis}
command: bash -c "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"
# Development container - for interactive development
dev:
build:
+91
View File
@@ -0,0 +1,91 @@
# Desktop packaging (Linux, Arch, Windows)
How to produce distributable desktop packages for JellyTau. All three flows can
run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
## One builder image (shared with CI)
The deb/rpm and Windows-cross flows build on the **unified registry builder**
([../Dockerfile.builder](../Dockerfile.builder) →
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
(Windows). There is **one** dependency source of truth — no per-stage tool
installs.
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
your bind-mounted source (like the `dev` service), so source edits need no image
rebuild.
**If you changed `Dockerfile.builder`** (e.g. added a tool), rebuild and push it
first, or the packaging flows use the stale registry image:
```bash
scripts/build-builder-image.sh # build + push :latest to the registry
# ...or iterate locally without pushing:
docker build -f Dockerfile.builder -t jellytau-builder:latest .
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
```
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
because `makepkg` is Arch-specific — it is not part of the unified builder.
| Target | Format | Docker command | Functional? |
|--------|--------|----------------|-------------|
| Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes |
| Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes |
| Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned) |
## Linux: deb + rpm
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
```bash
bun run docker:build:linux # deb + rpm -> ./dist
# or, on a host with the Tauri Linux deps installed:
BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
```
Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5
transcoded video). The deb/rpm declare these.
> Note: `appimage` is also a valid Tauri target if you want a portable bundle —
> add it to `BUNDLES`.
## Arch Linux: pacman package
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
```bash
bun run docker:build:arch # .pkg.tar.zst -> ./dist
```
The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to
publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.
`makepkg` refuses to run as root, so the Docker stage builds as a non-root
`builder` user. Because the image `COPY`s the source at build time, the
`arch-build` compose service does **not** bind-mount the repo — rebuild the image
to pick up source changes.
## Windows: NSIS installer cross-compiled from Linux
Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri
cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`.
Video plays via WebView2 and audio via the webview `<audio>` backend. See
[build-windows.md](build-windows.md) for the full explanation.
```bash
bun run docker:build:windows # NSIS installer + .exe -> ./dist
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
```
The Docker `windows-cross` stage is a thin layer over the builder, which carries
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target.
Cross-compilation is Tauri's "last resort" path (less tested than building on
Windows); a `windows-latest` CI job is the fallback if it misbehaves.
+78
View File
@@ -0,0 +1,78 @@
# Windows build
JellyTau targets Linux and Android primarily, but a working Windows build —
including an **NSIS installer cross-compiled from Linux** — is produced by the
Docker tooling. It is not yet a first-class release target (no code signing / CI
job / SMTC lockscreen), but it runs and plays media.
## How playback works on Windows
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
h264 fine. No Windows-specific code.
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
ExoPlayer (Android); neither exists on Windows. Instead
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
URL to a webview `<audio>` element (see
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
which reports state back through the same `player_report_*` round-trip the video
path uses. Pure Rust + Tauri events.
## Cross-compiling from Linux (MSVC + cargo-xwin)
We use the [official Tauri cross-compile path](https://v2.tauri.app/distribute/windows-installer/):
the **MSVC** target (`x86_64-pc-windows-msvc`) driven by
[`cargo-xwin`](https://github.com/rust-cross/cargo-xwin), which downloads the MSVC
CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri
officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the
Tauri CLI bundle the **NSIS installer from a Linux host**.
> Why not mingw/GNU? The GNU target *does* link a valid `.exe`, but the Tauri CLI
> gates `--bundles` by the host OS unless it recognizes a real Windows build.
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
`llvm`, and `nsis`.
```bash
bun run docker:build:windows # NSIS installer + .exe -> ./dist
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
```
Or directly on a host that has the toolchain:
```bash
scripts/build-windows-cross.sh # nsis installer + exe
WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only
```
Under the hood the build runs:
```bash
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
```
Outputs:
- `.exe``src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
- NSIS installer — `.../release/bundle/nsis/*-setup.exe`
(both copied to `./dist` when `OUTPUT_DIR` is set).
## Caveats
- **Cross-compilation is a last resort** per Tauri's own docs — it's less tested
than building on Windows. If it misbehaves, a `windows-latest` CI job or a
Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
- **Code signing is not wired up** — the installer is unsigned, so Windows
SmartScreen will warn on first run.
## Outstanding for a first-class Windows release
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
path.
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
frontend; streaming works today.
3. Code signing + a Windows packaging CI job.
+44 -20
View File
@@ -37,7 +37,7 @@ For a narrative overview of the system design, see
| UR-024 | View recently added content on server | Medium | Done |
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
| UR-027 | Audio equalizer for sound customization | Low | Planned |
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
@@ -62,12 +62,15 @@ For a narrative overview of the system design, see
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Done |
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
---
@@ -99,7 +102,7 @@ External system integrations and platform-specific implementations.
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
@@ -185,7 +188,7 @@ Internal architecture, components, and application logic.
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
@@ -219,7 +222,7 @@ Internal architecture, components, and application logic.
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
@@ -227,16 +230,21 @@ Internal architecture, components, and application logic.
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog``INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog``INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Done |
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Done |
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Done |
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Done |
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Done |
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Done |
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
---
@@ -303,6 +311,8 @@ Internal architecture, components, and application logic.
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
---
@@ -374,10 +384,24 @@ Internal architecture, components, and application logic.
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Done |
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
| UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 23 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done |
| UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done |
| UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done |
| UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done |
| UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done |
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
### Integration Tests
@@ -396,8 +420,8 @@ Internal architecture, components, and application logic.
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
---
+171
View File
@@ -0,0 +1,171 @@
# Spec: Audio equalizer
**Status:** Accepted
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
**UX spec:** n/a (extends the Settings Audio section, ux-flows §8.1 instant-apply).
**Supersedes / revises:** —
## Summary
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
gains, from a new block in Settings Audio. On Linux the gains apply live via
MPV's audio-filter chain; the settings persist and re-apply on the next track
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
no-op for now (documented parity gap, same as those three features).
## Motivation
UR-027 is one of the few still-unbuilt audio features. The audio-settings
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
ride that pipeline. The equalizer is the same shape: N more fields on
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
settings panel. No new command, no new state machine.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
in Rust because a preset is a set of band gains defined *by the band layout*,
which is an engine property. The frontend only ever names a preset and renders
the resulting gains; it never defines them.
## Design
### `AudioSettings` (Rust, `settings.rs`)
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
struct attribute):
```rust
/// Equalizer enabled. When false, no `af` EQ filter is applied.
pub equalizer_enabled: bool,
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
pub equalizer_bands: Vec<f32>,
```
Fixed 10-band ISO layout (domain constant in `settings.rs`):
```rust
pub const EQ_BANDS: [f32; 10] =
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
pub const EQ_GAIN_MIN: f32 = -12.0;
pub const EQ_GAIN_MAX: f32 = 12.0;
```
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
### Presets (Rust, `settings.rs`)
```rust
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
impl EqPreset {
/// The 10-band gain curve (dB) for this preset.
pub fn gains(&self) -> [f32; 10] { /* table */ }
}
```
Preset selection is a *frontend* convenience: tapping a chip sets
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
the numbers. (If exposing the whole table is awkward through specta, expose
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
filter. Errors map to `PlayerError` like the gapless code.
### No new persistence table
`AudioSettings` is already round-tripped by the frontend settings store and
re-pushed via `player_set_audio_settings` on change and on load. The two new
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
### Wire summary
- Command names unchanged: `player_set_audio_settings`,
`player_get_audio_settings` (now carry the EQ fields).
- New (optional) read-only command for preset curves — kebab n/a (it's a
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
## Out of scope
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
- Per-track or per-library EQ profiles — one global profile only.
- Automatic loudness/room correction; only manual bands + presets.
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
the missing `with_crossfade_clamped` call.
## Acceptance criteria
- [ ] Settings Audio has an Equalizer block: enable toggle, preset chips, 10
band sliders with live dB readouts, instant-apply (no Save button).
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
band switches the highlighted preset to "Custom" (frontend-only label).
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
and app restart; disabling clears the filter without affecting other audio.
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated.
## Testing
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
clamps out-of-range gains and pads/truncates band length; serialization
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
preset returns a 10-length curve; Flat is all zeros.
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
expected band array; editing a band flips the label to Custom; enable toggle
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
## TRACES
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
- MPV EQ filter application: `UR-027 | IR-020`
- Settings EQ UI block: `UR-027 | DR-030`
- Preset-curve command: `UR-027 | DR-030`
## Notes for the implementer
- A parallel Claude session is active in this repo (it has touched
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
build scripts, and the Rust build is currently broken by its
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
anything you didn't write; keep EQ changes isolated to `settings.rs`,
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
`commands/player/settings.rs`, and the settings page.
- Mirror the volume-normalization block in the settings page for the toggle +
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
MPV property handling.
- Confirm the exact MPV filter name available in the linked libmpv
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
string; gate cleanly if unavailable.
+1157 -630
View File
File diff suppressed because it is too large Load Diff
+38 -3
View File
@@ -609,9 +609,12 @@ flowchart TB
```
An episode is **never** browsed as a bare `Episode` item page. Clicking an
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
episode is always shown in the context of its series and the series' full
episode list is already loaded.
episode anywhere — a series' season list, a Home carousel (§5B.5), etc. —
navigates to `/library/<seriesId>?episode=<episodeId>`, so the episode is always
shown in the context of its series and the series' full episode list is already
loaded. Should an episode ever arrive without a `seriesId` (deep link, stale
cache), the bare Episode page renders as a fallback and links back to its parent
series and season by title so the user is never stranded.
### 5B.2 Episode Focus View — section order
@@ -695,6 +698,38 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
The reason a user opens a series page is to pick an episode; discovery content
is secondary and sits underneath.
### 5B.5 Home-card interaction — tap opens, long-press plays
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
Watching, Recently Added, …) **do not play on tap.** A plain tap opens the
item; playback is the deliberate, second gesture.
| Card kind | Tap (short) | Long-press (~500 ms hold) |
|-----------|-------------|---------------------------|
| Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`) |
| Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`) |
| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target) |
| Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now |
Rationale and rules:
- **Tap is navigation, not commitment.** Previously a tap on a movie/episode
jumped straight into the player, which made it easy to lose your place in a
half-watched item or start a stream you only meant to inspect. Tap now lands
on the detail/focus page, where Play is an explicit button.
- **Long-press is the shortcut for "just play it."** It surfaces a native
confirm (`Play "<name>" now?`) before starting playback, so an accidental
hold never blows away a resume position silently.
- **The long-press must not fight the carousel.** Detection cancels if the
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
to scroll never triggers play.
- **Episodes still obey §5B.1** — a home tap on an episode opens the series
Focus View, never a bare Episode page, so the series context loads.
This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based
detection) so any surface can opt in; today the Home carousels are the only
opt-in. Grids and other surfaces keep tap-to-open with no long-press.
---
## 6. Search Flow
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.0.16",
"version": "0.1.2",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -25,6 +25,12 @@
"android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh",
"android:logs": "./scripts/logcat.sh",
"desktop:build:linux": "./scripts/build-desktop-linux.sh",
"desktop:build:arch": "./scripts/build-arch.sh",
"desktop:build:windows": "./scripts/build-windows-cross.sh",
"docker:build:linux": "docker compose run --rm desktop-linux-build",
"docker:build:arch": "docker compose run --rm arch-build",
"docker:build:windows": "docker compose run --rm windows-cross",
"clean": "./scripts/clean.sh",
"tauri": "tauri",
"traces": "bun run scripts/extract-traces.ts",
+52
View File
@@ -0,0 +1,52 @@
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
#
# JellyTau — a cross-platform Jellyfin client (Tauri + SvelteKit).
#
# This PKGBUILD builds from the local source tree by default (see the `dev`
# convenience below), which is what scripts/build-arch.sh uses inside the Arch
# Docker stage. For AUR distribution, replace the `source=()` line with a release
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.0.18
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
url="https://gitea.tourolle.paris/dtourolle/jellytau"
license=('MIT')
# Runtime: libmpv for audio, webkit2gtk for the webview + HTML5 transcoded video.
depends=('webkit2gtk-4.1' 'mpv' 'gtk3' 'libayatana-appindicator')
makedepends=('rust' 'cargo' 'bun' 'nodejs' 'pkgconf' 'libsoup3')
options=('!strip' '!lto')
# Populated from the working tree by scripts/build-arch.sh (SRC env var).
_srcdir="${JELLYTAU_SRC:-$startdir/../..}"
build() {
cd "$_srcdir"
export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
bun install --frozen-lockfile || bun install
bun run build
# Only the raw binary is needed; packaging is done in package() below so we
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
(cd src-tauri && cargo build --release --locked)
}
package() {
cd "$_srcdir"
install -Dm755 "src-tauri/target/release/jellytau" \
"$pkgdir/usr/bin/jellytau"
# Desktop entry
install -Dm644 "packaging/arch/jellytau.desktop" \
"$pkgdir/usr/share/applications/jellytau.desktop"
# Icons (hicolor)
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/jellytau.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/jellytau.png"
}
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=JellyTau
Comment=A cross-platform Jellyfin client
Exec=jellytau
Icon=jellytau
Terminal=false
Categories=AudioVideo;Player;Audio;Video;
StartupWMClass=jellytau
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Build an Arch Linux package (.pkg.tar.zst) for JellyTau via makepkg.
#
# Tauri's bundler has no pacman target (as of tauri-cli 2.9.x), so we ship a
# hand-written PKGBUILD in packaging/arch/ and build it with makepkg. This must
# run on an Arch host / the `arch-build` Docker stage — makepkg is Arch-specific
# and refuses to run as root, so run it as a non-root user with sudo for deps.
#
# Usage (typically inside the arch-build Docker stage as a non-root user):
# scripts/build-arch.sh
# OUTPUT_DIR=/app/dist scripts/build-arch.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT/packaging/arch"
echo "🏛️ Building JellyTau Arch package"
echo "=================================="
# Point the PKGBUILD at the working tree and give cargo/bun a writable home.
export JELLYTAU_SRC="$REPO_ROOT"
export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
# -s installs missing deps (needs sudo/root privileges for pacman), -f overwrites.
makepkg -sf --noconfirm
echo ""
echo "✅ Built Arch package(s):"
ls -1 ./*.pkg.tar.zst
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
cp -v ./*.pkg.tar.zst "$OUTPUT_DIR/"
echo ""
echo "📦 Copied Arch package(s) to $OUTPUT_DIR"
fi
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Build Linux desktop packages (deb + rpm) for JellyTau.
#
# Produces bundles under src-tauri/target/release/bundle/{deb,rpm}.
# Runs on the existing Ubuntu builder image. NOTE: Tauri has no pacman bundle
# target — the Arch package is built separately with makepkg (scripts/build-arch.sh
# / Dockerfile.arch). `appimage` is also available if you want a portable bundle.
#
# Usage:
# scripts/build-desktop-linux.sh # deb + rpm
# BUNDLES="deb,appimage" scripts/build-desktop-linux.sh # subset / add appimage
# OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh # copy bundles out
set -euo pipefail
cd "$(dirname "$0")/.."
BUNDLES="${BUNDLES:-deb,rpm}"
echo "🐧 Building JellyTau Linux desktop packages"
echo "==========================================="
echo "Bundles: $BUNDLES"
echo ""
bun install --frozen-lockfile 2>/dev/null || bun install
bun run build
# --bundles overrides tauri.conf.json bundle.targets so this script controls
# exactly which Linux formats are produced (never NSIS here).
bun run tauri build --bundles "$BUNDLES"
BUNDLE_ROOT="src-tauri/target/release/bundle"
echo ""
echo "✅ Built packages:"
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
-exec cp -v {} "$OUTPUT_DIR/" \;
echo ""
echo "📦 Copied bundles to $OUTPUT_DIR"
fi
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# Cross-compile JellyTau for Windows from Linux, producing an NSIS installer.
#
# Uses the OFFICIAL Tauri cross-compile path (https://v2.tauri.app/distribute/
# windows-installer/): the MSVC target driven by cargo-xwin, which downloads the
# MSVC CRT/Windows SDK headers and links with lld. This is the target Tauri
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
# it can bundle the NSIS installer from a Linux host.
#
# Playback on Windows: video renders via WebView2 and audio via the webview
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
#
# Requirements (present in the Docker windows-cross target / unified builder):
# - rustup target x86_64-pc-windows-msvc
# - cargo-xwin (cargo install --locked cargo-xwin)
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
# - nsis (makensis) (installer generator)
#
# Usage:
# scripts/build-windows-cross.sh # exe + NSIS installer
# WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only, skip bundling
# OUTPUT_DIR=/app/dist scripts/build-windows-cross.sh
set -euo pipefail
cd "$(dirname "$0")/.."
TARGET="x86_64-pc-windows-msvc"
WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
echo "================================================================"
echo "Video plays via WebView2; audio via the webview <audio> backend."
echo "Bundles: $WIN_BUNDLES"
echo ""
bun install --frozen-lockfile 2>/dev/null || bun install
bun run build
# --runner cargo-xwin + the MSVC target is what makes the Tauri CLI treat this as
# a real Windows build and enable the nsis/msi bundlers on a Linux host.
#
# IMPORTANT: do NOT pass `--bundles nsis` here. tauri-cli 2.9.x validates the
# `--bundles` flag against a static clap enum gated by the HOST OS (Linux allows
# only deb/rpm/appimage) *before* it considers --target/--runner, so `--bundles
# nsis` is rejected at arg-parse time. Instead the Windows bundle targets come
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
# that CLI validation — the bundler then picks nsis once it knows the target is
# Windows.
if [[ "$WIN_BUNDLES" == "none" ]]; then
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
else
bun run tauri build --runner cargo-xwin --target "$TARGET"
fi
BIN_DIR="src-tauri/target/$TARGET/release"
echo ""
echo "✅ Built Windows artifacts:"
find "$BIN_DIR" -maxdepth 1 -name '*.exe' -print
find "$BIN_DIR/bundle" -type f \( -name '*.exe' -o -name '*.msi' \) -print 2>/dev/null || true
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
echo ""
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
fi
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.1.0"
version = "0.1.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
+6
View File
@@ -433,6 +433,12 @@ mod tests {
.unwrap()
}
/// IT-017: a download queued from a greyed-out offline catalog entry
/// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
/// resolved and the row is healed (URL + target dir) so the pump can start
/// it — while already-resolved rows are left untouched.
///
/// TRACES: UR-052, UR-011 | IT-017
#[tokio::test]
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
let db = test_db();
+13 -2
View File
@@ -205,6 +205,15 @@ pub struct PlayItemRequest {
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
#[serde(default)]
pub duration_seconds: Option<f64>,
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
/// background-audio handoff so an episode played as audio-only is still
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
#[serde(default)]
pub item_type: Option<String>,
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
/// look up the next episode when a background-audio track ends.
#[serde(default)]
pub series_id: Option<String>,
}
/// Queue context for remote transfer - what type of queue is this?
@@ -601,7 +610,9 @@ pub async fn player_enter_background_audio(
artists: None,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
item_type: None,
// Carry episode identity so autoplay can advance to the next episode when
// this audio-only handoff ends while backgrounded (UR-040).
item_type: item.item_type.clone(),
playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
@@ -616,7 +627,7 @@ pub async fn player_enter_background_audio(
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
series_id: item.series_id.clone(),
server_id: item.server_id.clone(),
};
+22 -3
View File
@@ -1,12 +1,12 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
use tauri::State;
use super::{PlayerStateWrapper, VideoSettingsWrapper};
use crate::player::AutoplaySettings;
use crate::settings::{AudioSettings, VideoSettings};
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
#[tauri::command]
#[specta::specta]
@@ -14,13 +14,32 @@ pub async fn player_set_audio_settings(
player: State<'_, PlayerStateWrapper>,
settings: AudioSettings,
) -> Result<AudioSettings, String> {
// Validate/normalise domain values before applying: clamp crossfade to its
// range and normalise the equalizer band vector (length + gain clamps).
let validated = settings
.with_crossfade_clamped()
.with_equalizer_normalised();
let mut controller = player.0.lock().await;
controller
.set_audio_settings(&settings)
.set_audio_settings(&validated)
.map_err(|e| e.to_string())?;
Ok(controller.audio_settings())
}
/// The built-in equalizer presets and their per-band gain curves (dB), for the
/// settings UI. The curve numbers are domain data defined by the band layout,
/// so the frontend reads them here rather than encoding them.
///
/// TRACES: UR-027 | DR-030
#[tauri::command]
#[specta::specta]
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
Ok(EqPreset::ALL
.iter()
.map(|p| (*p, p.gains().to_vec()))
.collect())
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_audio_settings(
+13 -2
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
@@ -409,7 +410,7 @@ pub async fn repository_search(
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
let mut cache_result = repo
.search_cache_only(&query, options.clone())
.await
.unwrap_or_else(|e| {
@@ -420,6 +421,12 @@ pub async fn repository_search(
}
});
// Neither backend orders by *where* the query matched, so a mid-word hit
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
// Both phases are ranked with the same rules so the list does not reshuffle
// when the server results land.
rank_search_results(&mut cache_result.items, &query);
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
@@ -428,7 +435,11 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let mut merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
// Rank the union, not each half: a server-only prefix match must
// be able to outrank a cached mid-word one.
rank_search_results(&mut merged.items, &query);
let event = SearchUpdateEvent {
request_id,
result: merged,
+2
View File
@@ -5,6 +5,8 @@
pub mod from_jellyfin;
pub mod media;
pub mod search_rank;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind};
pub use search_rank::rank_search_results;
+313
View File
@@ -0,0 +1,313 @@
//! Relevance ranking for search results.
//!
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
//! in an order that ignores *where* in the name the query matched: a server
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
//! the query "parks". Neither backend is going to change, so the app imposes its
//! own ordering on the union.
//!
//! Ranking is domain logic, not presentation: it encodes what a "better match"
//! means and which media kinds outrank which. The frontend only renders the
//! order it is given.
//!
//! Two rules, in priority order:
//!
//! 1. **Match position** — a prefix match beats a word-start match, which beats
//! a mid-word substring match. This is what makes "parks" find
//! "Parks and Recreation" before "Sparks of Love".
//! 2. **Kind** — containers before their contents at equal match quality, so a
//! series outranks its own episodes.
//!
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
//! `rank`) still breaks ties it was never overruled on.
use crate::domain::MediaKind;
use crate::repository::types::MediaItem;
/// How well a query matched an item's name — better matches sort first.
///
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
/// comparison for free, so adding a tier in the right position is all it takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchQuality {
/// The name starts with the query — "parks" in "Parks and Recreation".
Prefix,
/// Some later *word* starts with the query — "recreation" in "Parks and
/// Recreation". Still a deliberate hit: users type whole words.
WordStart,
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
/// that still counts as a match.
Substring,
/// No match on the name at all. The backend returned it for some other
/// reason (overview, artist, album), so it is kept but sorted last.
None,
}
/// Rank of a media kind when match quality ties — lower sorts first.
///
/// Containers outrank the items they contain: searching a show's name should
/// surface the show, not an arbitrary episode of it. Within a tier the order is
/// arbitrary but stable, and equal ranks fall through to input order.
fn kind_rank(kind: MediaKind) -> u8 {
match kind {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
}
// Peripheral matches.
MediaKind::Person | MediaKind::Other => 3,
}
}
/// Classify how `query` matches `name`, case-insensitively.
///
/// Both sides are trimmed and lowercased; an empty query matches everything
/// equally (`Prefix`), which leaves the input order untouched.
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
let query = query.trim().to_lowercase();
if query.is_empty() {
return MatchQuality::Prefix;
}
let name = name.trim().to_lowercase();
let Some(index) = name.find(&query) else {
return MatchQuality::None;
};
if index == 0 {
return MatchQuality::Prefix;
}
// A word start is any match preceded by a non-alphanumeric character, so
// "the-office" and "The Office" behave the same. Indexing back one char is
// safe on the byte index `find` returned only via `char_indices`, since a
// multi-byte char would panic on a raw slice.
let preceded_by_boundary = name[..index]
.chars()
.next_back()
.is_some_and(|c| !c.is_alphanumeric());
if preceded_by_boundary {
MatchQuality::WordStart
} else {
MatchQuality::Substring
}
}
/// Sort search results by relevance to `query`, in place.
///
/// Stable, so items the rules rank equally keep the order the backend supplied
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
///
/// TRACES: UR-060 | DR-090
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
// An empty query carries no relevance signal, so there is nothing to rank
// by — reordering on kind alone would shuffle the backend's own ordering
// for no reason.
if query.trim().is_empty() {
return;
}
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
}
#[cfg(test)]
mod tests {
use super::*;
fn item(name: &str, kind: MediaKind) -> MediaItem {
let mut item = MediaItem::default();
item.id = format!("id-{}-{:?}", name, kind);
item.name = name.to_string();
item.kind = kind;
item
}
fn names(items: &[MediaItem]) -> Vec<&str> {
items.iter().map(|i| i.name.as_str()).collect()
}
/// UT-085: a prefix match outranks a mid-word substring match.
#[test]
fn prefix_match_beats_midword_substring() {
assert_eq!(
match_quality("Parks and Recreation", "parks"),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Sparks of Love", "parks"),
MatchQuality::Substring
);
assert!(MatchQuality::Prefix < MatchQuality::Substring);
}
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
#[test]
fn ranks_prefix_match_before_substring_match() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Sparks of Love"]
);
}
/// A match at a later word start beats a mid-word one but loses to a prefix.
#[test]
fn word_start_ranks_between_prefix_and_substring() {
assert_eq!(
match_quality("The Office", "office"),
MatchQuality::WordStart
);
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
let mut items = vec![
item("Bofficer", MediaKind::Series),
item("The Office", MediaKind::Series),
item("Office Space", MediaKind::Movie),
];
rank_search_results(&mut items, "office");
assert_eq!(
names(&items),
vec!["Office Space", "The Office", "Bofficer"]
);
}
/// UT-086: at equal match quality a series outranks an episode.
#[test]
fn series_ranks_before_episode_at_equal_match_quality() {
let mut items = vec![
item("Parks and Recreation S01E01", MediaKind::Episode),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
);
}
/// Albums outrank their tracks for the same reason series outrank episodes.
#[test]
fn album_ranks_before_track_at_equal_match_quality() {
let mut items = vec![
item("Rumours", MediaKind::Track),
item("Rumours", MediaKind::Album),
];
rank_search_results(&mut items, "rumours");
assert_eq!(items[0].kind, MediaKind::Album);
}
/// Match quality dominates kind: a better-matching episode beats a
/// worse-matching series, so kind never drags an irrelevant show to the top.
#[test]
fn match_quality_outranks_kind() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks Cleanup", MediaKind::Episode),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
}
/// Items the backend returned for a non-name reason (overview, artist) are
/// kept, but sort below everything that actually matched the name.
#[test]
fn non_matching_names_sort_last_without_being_dropped() {
let mut items = vec![
item("Unrelated Documentary", MediaKind::Movie),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Unrelated Documentary"]
);
}
/// Ranking is stable: equally-ranked items keep the backend's order, so the
/// FTS/server relevance signal still breaks ties.
#[test]
fn equal_rank_preserves_input_order() {
let mut items = vec![
item("Parks A", MediaKind::Series),
item("Parks B", MediaKind::Series),
item("Parks C", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
}
/// Case and surrounding whitespace never change the tier.
#[test]
fn matching_is_case_and_whitespace_insensitive() {
assert_eq!(
match_quality("PARKS AND RECREATION", " parks "),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Parks and Recreation", "PARKS"),
MatchQuality::Prefix
);
}
/// An empty query leaves the order alone rather than reshuffling on kind.
#[test]
fn empty_query_preserves_input_order() {
let mut items = vec![
item("Zebra", MediaKind::Episode),
item("Apple", MediaKind::Series),
];
rank_search_results(&mut items, "");
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
}
/// A multi-byte name must not panic when the match is mid-string — the
/// boundary check walks chars rather than slicing raw bytes.
#[test]
fn handles_multibyte_names_without_panicking() {
assert_eq!(
match_quality("Pokémon Journeys", "journeys"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Café Parks", "parks"),
MatchQuality::WordStart
);
}
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
#[test]
fn punctuation_counts_as_a_word_boundary() {
assert_eq!(
match_quality("The-Office", "office"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Show: Parks", "parks"),
MatchQuality::WordStart
);
}
}
+19 -3
View File
@@ -121,6 +121,7 @@ use commands::{
player_get_audio_settings,
player_get_autoplay_settings,
player_get_cache_config,
player_get_eq_presets,
player_get_queue,
// Session management commands
player_get_session,
@@ -515,6 +516,12 @@ fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str
}
/// Create the appropriate player backend for the current platform.
// playback_reporter/position_throttler are consumed only by the native audio
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
#[cfg_attr(
not(any(target_os = "linux", target_os = "android")),
allow(unused_variables)
)]
fn create_player_backend(
app_handle: tauri::AppHandle,
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
@@ -615,11 +622,19 @@ fn create_player_backend(
}
}
// Fallback for other platforms
// Platforms with no native audio backend (e.g. Windows): render audio-only
// playback through a webview <audio> element (all video already renders in
// the webview). Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
warn!("WARNING: No audio backend available for this platform");
Box::new(NullBackend::new())
info!("No native audio backend for this platform - using webview <audio> backend");
match player::WebviewAudioBackend::new(_event_emitter) {
Ok(backend) => Box::new(backend),
Err(e) => {
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
Box::new(NullBackend::new())
}
}
}
}
@@ -666,6 +681,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_skip_to,
player_set_audio_settings,
player_get_audio_settings,
player_get_eq_presets,
player_set_video_settings,
player_get_video_settings,
// Sleep timer and autoplay commands
+33 -5
View File
@@ -858,12 +858,40 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
});
}
// Start countdown if auto_advance enabled
if auto_advance {
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
// Background audio-only episode: the frontend that normally
// performs the advance (goto /player/<id>) is suspended, so
// the backend must load the next episode's audio-only stream
// itself — otherwise playback just stops at the boundary.
let is_bg_audio_episode =
controller.lock().await.current_is_audio_episode();
if is_bg_audio_episode {
log::info!(
"[Autoplay] Background audio episode — advancing to {} in backend",
next_episode.id
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
log::error!(
"[Autoplay] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else {
ctrl.emit_queue_changed();
}
} else {
// Foreground: frontend drives the advance off the countdown.
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
}
}
}
Err(e) => {
+1
View File
@@ -332,6 +332,7 @@ mod tests {
gapless_playback: false,
normalize_volume: true,
volume_level: VolumeLevel::Loud,
..Default::default()
};
backend.set_audio_settings(&settings).unwrap();
+19
View File
@@ -156,6 +156,25 @@ pub enum PlayerStatusEvent {
/// Target position in seconds (only meaningful for "seek").
position: Option<f64>,
},
/// Ask the frontend webview `<audio>` element to load and play a stream.
///
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
/// element in the webview, mirroring how all video already renders through
/// the webview `<video>`. The element then reports its state/position back
/// through the `player_report_*` commands, so the Rust controller stays the
/// single source of truth. Subsequent play/pause/seek/stop reach the element
/// via `ControlCommand`.
WebviewAudioLoad {
/// Stream URL for the `<audio>` element to play.
url: String,
/// Jellyfin item id, used as the media_id when reporting state back.
media_id: Option<String>,
/// Resume position in seconds (0 = start from the beginning).
position: f64,
/// Whether to begin playing immediately after loading.
autoplay: bool,
},
}
/// Trait for emitting player events to the frontend.
+208 -10
View File
@@ -22,6 +22,11 @@ pub mod android;
#[cfg(target_os = "linux")]
pub mod mpv_backend;
// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod webview_audio_backend;
// Re-export commonly used types
pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError};
@@ -40,6 +45,9 @@ pub use android::ExoPlayerBackend;
#[cfg(target_os = "linux")]
pub use mpv_backend::MpvBackend;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub use webview_audio_backend::WebviewAudioBackend;
#[cfg(target_os = "android")]
pub use android::{
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
@@ -650,6 +658,24 @@ impl PlayerController {
self.queue.clone()
}
/// True when the current item is a TV episode being played in audio-only
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
/// `MediaType::Audio`. Used to decide whether the backend must drive the
/// next-episode advance itself (the frontend is suspended in the background).
///
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn current_is_audio_episode(&self) -> bool {
self.queue
.lock_safe()
.current()
.map(|item| {
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
})
.unwrap_or(false)
}
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
@@ -955,9 +981,11 @@ impl PlayerController {
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
// Only count TV episodes (not audio tracks or movies)
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).await;
// Only count TV episodes (not audio tracks or movies). Note an
// episode played in background-audio mode is MediaType::Audio, so
// rely on is_episode_item (which checks item_type) rather than the
// media_type alone.
let is_episode = self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
@@ -973,10 +1001,12 @@ impl PlayerController {
}
}
// For video episodes, fetch next episode and show popup
// For episodes, fetch next episode and show popup.
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
// It's here for the Android ExoPlayer path where episode items sit in the
// backend queue — including background-audio mode, where the episode is a
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
if self.is_episode_item(&current).await {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
@@ -1034,6 +1064,77 @@ impl PlayerController {
}
}
/// Advance to the next episode while playing audio-only in the background.
///
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
/// which is unavailable when the app is backgrounded and the WebView is
/// suspended. This drives the advance entirely in the backend: build the next
/// episode's *audio-only* stream URL and load it into the native audio player,
/// so playback continues without any frontend involvement (UR-040).
///
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
///
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
/// TRACES: UR-040, UR-023 | DR-052
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
) -> Result<(), String> {
let repo = self
.repository
.lock_safe()
.clone()
.ok_or_else(|| "No repository for background episode advance".to_string())?;
// Details for session metadata (title/series/artwork) and the stream URL.
let next = repo
.get_item(next_episode_id)
.await
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
// Audio-only transcode from the start of the episode (no resume offset —
// a freshly-started next episode always plays from the beginning).
let stream_url = repo
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
.await
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
let media_item = MediaItem {
id: next.id.clone(),
title: next.name.clone(),
name: Some(next.name.clone()),
artist: next.series_name.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: next.primary_image_tag.clone(),
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
// Preserve episode identity so the NEXT end-of-track also advances.
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url,
jellyfin_item_id: next.id.clone(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: next.series_id.clone(),
server_id: Some(next.server_id.clone()),
};
self.play_item(media_item).map_err(|e| e.to_string())
}
/// Handle video playback ended from HTML5 video element.
///
/// HTML5 video plays independently of the Rust backend, so the backend
@@ -1127,11 +1228,19 @@ impl PlayerController {
Ok(AutoplayDecision::Stop)
}
/// Check if a media item is an episode (has Jellyfin ID to query)
/// Check if a media item is an episode (has Jellyfin ID to query).
///
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
/// to the audio path for background playback (UR-040) is still recognised as
/// an episode — otherwise autoplay would fall through to the queue-based
/// audio path, find nothing next, and stop at the episode boundary. When the
/// type is unknown we fall back to the historical heuristic (video == episode).
async fn is_episode_item(&self, item: &MediaItem) -> bool {
// For now, assume video items are episodes
// In production, we'd check item metadata or query Jellyfin
item.media_type == MediaType::Video
match item.item_type.as_deref() {
Some("Episode") => true,
Some(_) => item.media_type == MediaType::Video,
None => item.media_type == MediaType::Video,
}
}
/// Fetch next episode for a series by looking up the season's episodes
@@ -2303,6 +2412,15 @@ mod tests {
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, repo_types::RepoError> {
Ok(format!("http://example.com/{}-audio.mp3", item_id))
}
async fn get_live_tv_channels(
&self,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
@@ -2495,6 +2613,86 @@ mod tests {
}
}
/// Background audio-only mode (UR-040): a video episode is handed off to the
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
/// playing while the app is backgrounded. When that audio track ends, autoplay
/// must STILL recognise it as an episode and offer the next one — otherwise
/// playback just pauses at the episode boundary (the reported bug). The item
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
#[tokio::test]
async fn test_playback_ended_background_audio_episode_advances() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
let episode = MediaItem {
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, // audio-only handoff, not Video
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end.
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep3");
}
other => panic!(
"background-audio episode end must advance to the next episode, got {:?}",
other
),
}
}
/// The backend-driven advance (used when backgrounded) must load the next
/// episode as an AUDIO item carrying its episode identity, so the *following*
/// end-of-track also advances rather than stopping.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should be loaded");
assert_eq!(current.id, "ep2");
assert_eq!(current.media_type, MediaType::Audio);
assert_eq!(current.item_type.as_deref(), Some("Episode"));
assert_eq!(current.series_id.as_deref(), Some("series1"));
// Uses the audio-only URL, not a video stream.
match &current.source {
MediaSource::Remote { stream_url, .. } => {
assert!(
stream_url.contains("audio"),
"expected audio-only URL, got {}",
stream_url
);
}
other => panic!("expected Remote source, got {:?}", other),
}
// The controller now considers itself mid background-audio episode, so the
// next end-of-track will advance again rather than stop.
assert!(controller.current_is_audio_episode());
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
+204 -2
View File
@@ -3,7 +3,7 @@ use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::AudioSettings;
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
@@ -552,8 +552,19 @@ impl PlayerBackend for MpvBackend {
})?;
}
// Audio filter chain: build a single lavfi graph combining the EQ
// peaking bands and (optionally) a dynamic loudness normalizer, and
// set the `af` property. An empty string clears all filters. Both
// features share one `af` graph because MPV exposes a single filter
// property. See docs/specs/audio-equalizer.md and IR-020.
let af = build_af_filter(settings);
self.mpv
.set_property("af", af.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set audio filters: {:?}", e),
})?;
// TODO: Implement crossfade via MPV audio filters if needed
// TODO: Implement volume normalization if needed
Ok(())
}
@@ -563,9 +574,200 @@ impl PlayerBackend for MpvBackend {
}
}
/// Build the full MPV `af` (audio filter) value from the audio settings.
///
/// Combines the equalizer peaking bands and the loudness-normalization filter
/// into a single `lavfi` graph, because MPV exposes one `af` property. The
/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
/// empty string when neither feature contributes a filter, which clears `af`.
///
/// TRACES: UR-027, UR-033 | IR-020, DR-036
fn build_af_filter(settings: &AudioSettings) -> String {
let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
entries.push(norm);
}
if entries.is_empty() {
return String::new();
}
format!("lavfi=[{}]", entries.join(","))
}
/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
/// peaking) per band with a non-zero gain, e.g.
/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
/// is disabled or every gain is ~0. Gains are assumed already normalised by
/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
/// ignored.
///
/// TRACES: UR-027 | IR-020
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
if !enabled {
return Vec::new();
}
bands
.iter()
.zip(EQ_BANDS.iter())
.filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
.map(|(gain, freq)| {
// width_type=o → octave bandwidth; width=1 → one octave per band.
format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
})
.collect()
}
/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
/// [`VolumeLevel::Normal`] (14 LUFS) target, leaving 1.2 dB of headroom.
const NORMALIZE_REF_PEAK: f32 = 0.87;
/// Reference loudness the peak table is anchored at (Normal preset, 14 LUFS).
const NORMALIZE_REF_LUFS: f32 = -14.0;
/// The loudness-normalization filter entry (unwrapped), or `None` when
/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
/// mode can produce on very dynamic material.
///
/// `dynaudnorm` targets a peak amplitude (`p`, linear 01), not a LUFS value,
/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
/// offset from the Normal reference is applied as a dB offset to the reference
/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
/// so loud presets never request full-scale.
///
/// TRACES: UR-033 | DR-036
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
if !enabled {
return None;
}
// LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
// 3 decimals is plenty for a peak target and keeps the filter string stable.
Some(format!("dynaudnorm=p={:.3}:g=15", peak))
}
impl Drop for MpvBackend {
fn drop(&mut self) {
info!("[MpvBackend] Shutting down");
// MPV will be automatically cleaned up
}
}
#[cfg(test)]
mod af_filter_tests {
use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
use crate::settings::{AudioSettings, VolumeLevel};
fn settings() -> AudioSettings {
AudioSettings {
equalizer_enabled: false,
equalizer_bands: vec![0.0; 10],
normalize_volume: false,
..AudioSettings::default()
}
}
/// Disabled EQ, or an all-zero curve, produces no EQ entries.
///
/// TRACES: UR-027 | IR-020 | UT-083
#[test]
fn test_eq_entries_empty_when_disabled_or_flat() {
assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
// Sub-threshold gains count as flat.
assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
}
/// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
/// centre frequency and gain, chained inside a single `lavfi` filter.
///
/// TRACES: UR-027 | IR-020 | UT-084
#[test]
fn test_eq_filter_builds_lavfi_chain() {
// First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
let mut s = settings();
s.equalizer_enabled = true;
s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let af = build_af_filter(&s);
assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
assert!(af.ends_with("]"));
assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
// Only two bands are non-zero → exactly two peaking filters.
assert_eq!(af.matches("equalizer=").count(), 2);
}
/// Disabled normalization yields no filter entry; the combined `af` for a
/// fully default (all-off) settings is empty, which clears `af`.
///
/// TRACES: UR-033 | DR-036 | UT-085
#[test]
fn test_normalize_disabled_produces_no_filter() {
assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
assert_eq!(build_af_filter(&settings()), "");
}
/// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
/// the peak preserves the Loud > Normal > Quiet ordering.
///
/// TRACES: UR-033 | DR-036 | UT-086
#[test]
fn test_normalize_peak_preserves_preset_ordering() {
fn peak_of(entry: &str) -> f32 {
// "dynaudnorm=p=0.870:g=15" → 0.870
entry
.split("p=")
.nth(1)
.and_then(|s| s.split(':').next())
.and_then(|s| s.parse().ok())
.expect("parseable peak")
}
let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
for entry in [&loud, &normal, &quiet] {
assert!(
entry.starts_with("dynaudnorm="),
"dynaudnorm filter: {entry}"
);
}
assert!(
peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
"Loud {} > Normal {} > Quiet {}",
peak_of(&loud),
peak_of(&normal),
peak_of(&quiet),
);
// Every preset stays within the safe (0, 0.99] clamp.
for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
}
let mut s = settings();
s.normalize_volume = true;
s.volume_level = VolumeLevel::Quiet;
let af = build_af_filter(&s);
assert!(af.starts_with("lavfi=["));
assert!(af.contains("dynaudnorm=p="));
}
/// EQ and normalization coexist in one `lavfi` graph, with the normalizer
/// placed after the EQ bands so it levels the post-EQ signal.
///
/// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
#[test]
fn test_eq_and_normalize_combine_in_order() {
let mut s = settings();
s.equalizer_enabled = true;
s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
s.normalize_volume = true;
s.volume_level = VolumeLevel::Normal;
let af = build_af_filter(&s);
let eq_pos = af.find("equalizer=").expect("has EQ");
let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
}
}
@@ -0,0 +1,295 @@
//! Webview audio backend — audio-only playback for platforms without a native
//! audio backend (currently Windows).
//!
//! ## Why this exists
//! All *video* already renders through the webview HTML5 `<video>` element on
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
//! *audio-only* (music) playback. On Windows there is no native audio backend,
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
//! silent.
//!
//! This backend fills that gap without any C dependency (so it still
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
//! its real state/position back through the `player_report_*` commands, so the
//! Rust `PlayerController` remains the single source of truth (the controller's
//! `report_html5_*` methods fold those reports into the normal event pipeline).
//!
//! Because the reported state flows through the event pipeline (not through this
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
//! backend for HTML5-rendered media), this backend only needs to keep a
//! best-effort local mirror for direct `player_get_state` queries.
//!
//! TRACES: UR-003, UR-004, UR-005 | DR-004
use std::sync::Arc;
use log::{debug, info};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::settings::AudioSettings;
use crate::utils::lock::MutexSafe;
/// Extract a webview-playable URL from a media item's source.
///
/// Remote/DirectUrl are HTTP(S) URLs the `<audio>` element can play directly.
/// Local files would need the Tauri asset protocol (`convertFileSrc`) on the
/// frontend; for now we pass the path through and let the frontend resolve it.
fn stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
MediaSource::DirectUrl { url } => url.clone(),
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
}
}
struct InternalState {
current_media: Option<MediaItem>,
volume: f32,
position: f64,
duration: Option<f64>,
state: PlayerState,
audio_settings: AudioSettings,
}
pub struct WebviewAudioBackend {
emitter: Arc<dyn PlayerEventEmitter>,
state: Arc<std::sync::Mutex<InternalState>>,
}
impl WebviewAudioBackend {
pub fn new(emitter: Arc<dyn PlayerEventEmitter>) -> Result<Self, PlayerError> {
info!("[WebviewAudioBackend] Initializing (audio renders in webview <audio>)");
Ok(Self {
emitter,
state: Arc::new(std::sync::Mutex::new(InternalState {
current_media: None,
volume: 1.0,
position: 0.0,
duration: None,
state: PlayerState::Idle,
audio_settings: AudioSettings::default(),
})),
})
}
/// Emit a backend-originated control intent to the active frontend adapter
/// (the webview `<audio>` element, via `playerEvents.ts` -> active adapter).
fn emit_control(&self, action: &str, position: Option<f64>) {
self.emitter.emit(PlayerStatusEvent::ControlCommand {
action: action.to_string(),
position,
});
}
}
impl PlayerBackend for WebviewAudioBackend {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
let url = stream_url(media);
info!("[WebviewAudioBackend] load: {} - {}", media.title, url);
{
let mut st = self.state.lock_safe();
st.current_media = Some(media.clone());
st.position = 0.0;
st.duration = media.duration;
st.state = PlayerState::Loading {
media: media.clone(),
};
}
// Hand the URL to the frontend <audio> element. autoplay=true so a plain
// load-then-play (the common queue-advance path) starts immediately; an
// explicit pause afterwards is still honored via ControlCommand.
self.emitter.emit(PlayerStatusEvent::WebviewAudioLoad {
url,
media_id: media.jellyfin_id().map(|s| s.to_string()),
position: 0.0,
autoplay: true,
});
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] play");
self.emit_control("play", None);
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] pause");
self.emit_control("pause", None);
Ok(())
}
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] stop");
{
let mut st = self.state.lock_safe();
st.current_media = None;
st.position = 0.0;
st.duration = None;
st.state = PlayerState::Idle;
}
self.emit_control("stop", None);
Ok(())
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] seek: {}", position);
self.state.lock_safe().position = position;
self.emit_control("seek", Some(position));
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
self.state.lock_safe().volume = clamped;
// Volume is applied on the element by the frontend, which observes the
// volume via the player store; no dedicated ControlCommand action yet.
Ok(())
}
fn position(&self) -> f64 {
self.state.lock_safe().position
}
fn duration(&self) -> Option<f64> {
self.state.lock_safe().duration
}
fn state(&self) -> PlayerState {
self.state.lock_safe().state.clone()
}
fn volume(&self) -> f32 {
self.state.lock_safe().volume
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
self.state.lock_safe().audio_settings = settings.clone().with_crossfade_clamped();
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
self.state.lock_safe().audio_settings.clone()
}
}
// TRACES: UR-003, UR-004, UR-005 | DR-004
#[cfg(test)]
mod tests {
use super::*;
use crate::player::events::PlayerStatusEvent;
use crate::player::media::{MediaSource, MediaType};
use std::sync::Mutex as StdMutex;
/// Test emitter that records everything emitted.
struct RecordingEmitter {
events: Arc<StdMutex<Vec<PlayerStatusEvent>>>,
}
impl PlayerEventEmitter for RecordingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
}
}
fn test_media() -> MediaItem {
MediaItem {
id: "track1".to_string(),
title: "Song".to_string(),
name: Some("Song".to_string()),
artist: Some("Artist".to_string()),
album: Some("Album".to_string()),
album_name: Some("Album".to_string()),
album_id: None,
artist_items: None,
artists: Some(vec!["Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(200.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::DirectUrl {
url: "http://example.com/song.mp3".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
fn backend() -> (WebviewAudioBackend, Arc<StdMutex<Vec<PlayerStatusEvent>>>) {
let events = Arc::new(StdMutex::new(Vec::new()));
let emitter = Arc::new(RecordingEmitter {
events: events.clone(),
});
(WebviewAudioBackend::new(emitter).unwrap(), events)
}
#[test]
fn load_emits_webview_audio_load_with_url() {
let (mut b, events) = backend();
b.load(&test_media()).unwrap();
let ev = events.lock().unwrap();
let load = ev
.iter()
.find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. }))
.expect("WebviewAudioLoad emitted");
if let PlayerStatusEvent::WebviewAudioLoad { url, autoplay, .. } = load {
assert_eq!(url, "http://example.com/song.mp3");
assert!(*autoplay);
}
assert!(matches!(b.state(), PlayerState::Loading { .. }));
}
#[test]
fn pause_and_seek_emit_control_commands() {
let (mut b, events) = backend();
b.load(&test_media()).unwrap();
b.pause().unwrap();
b.seek(42.0).unwrap();
let ev = events.lock().unwrap();
assert!(ev.iter().any(|e| matches!(
e,
PlayerStatusEvent::ControlCommand { action, .. } if action == "pause"
)));
assert!(ev.iter().any(|e| matches!(
e,
PlayerStatusEvent::ControlCommand { action, position: Some(p) }
if action == "seek" && (*p - 42.0).abs() < f64::EPSILON
)));
assert_eq!(b.position(), 42.0);
}
#[test]
fn volume_is_clamped_and_stored() {
let (mut b, _events) = backend();
b.set_volume(1.5).unwrap();
assert_eq!(b.volume(), 1.0);
b.set_volume(-0.2).unwrap();
assert_eq!(b.volume(), 0.0);
}
#[test]
fn stop_resets_to_idle() {
let (mut b, _events) = backend();
b.load(&test_media()).unwrap();
b.stop().unwrap();
assert!(matches!(b.state(), PlayerState::Idle));
}
}
+38
View File
@@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode of a video requires the server - delegate to online.
self.online
.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
@@ -1028,6 +1046,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
@@ -1276,6 +1304,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
+16
View File
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// Used when autoplay advances to the next episode while the app is playing a
/// video in audio-only mode in the background: the backend needs the next
/// episode's audio-only URL without any frontend round-trip. Online-only;
/// offline/cache repositories return an error.
///
/// TRACES: UR-040 | JA-032
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
+167 -12
View File
@@ -587,6 +587,18 @@ impl OfflineRepository {
String::new()
};
// When the parent is a LIBRARY, cached items carry no link back to it
// (library_id/parent_id are NULL), so the `libraries` EXISTS clause below
// matches every downloaded item on the server — both containers
// (MusicAlbum/Series/…) AND their leaves (Audio/Episode). Listing the
// leaves alongside the containers is the "I see individual songs, not
// albums" bug: a library landing page must show only *top-level* items.
// So at the library level we exclude any leaf whose own container
// (album/season/series/parent) is itself present in `downloaded_items` —
// that container represents it in the grid. Items with no downloaded
// container (e.g. a downloaded Movie, or a stray track whose album isn't
// cached) still surface. This mirrors the online music library, which
// routes to a dedicated albums view. See [[offline-libraries-never-cached]].
let sql = format!(
"{cte}
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
@@ -599,9 +611,19 @@ impl OfflineRepository {
WHERE i.server_id = ?
AND (
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
OR EXISTS (
SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id
OR (
EXISTS (
SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id
)
-- Top-level only: hide leaves whose container is downloaded.
AND NOT EXISTS (
SELECT 1 FROM downloaded_items parent
WHERE parent.id = i.album_id
OR parent.id = i.season_id
OR parent.id = i.series_id
OR parent.id = i.parent_id
)
)
){type_filter}
ORDER BY i.sort_name ASC, i.name ASC
@@ -736,17 +758,32 @@ impl OfflineRepository {
// descendants that are NOT downloaded. We compare downloaded-descendant
// count against total-cached-descendant count (the offline cache holds
// the synced full catalog, so this is meaningful).
//
// Perf: restrict `c` to containers that actually have a completed
// download *first* (the CTE), so the OR-based self-join runs over that
// handful of rows instead of the entire synced catalog. Without this the
// join is an unindexable O(items²) scan and the Downloaded page hangs on
// a large library ("Loading your downloads…" forever).
let partial_query = Query::with_params(
"SELECT c.id,
"WITH downloaded_containers AS (
SELECT DISTINCT c.id
FROM items c
INNER JOIN items children
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed'
AND c.server_id = ?
AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
)
SELECT c.id,
COUNT(children.id) AS total_children,
SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
FROM items c
INNER JOIN downloaded_containers dc ON dc.id = c.id
INNER JOIN items children
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
WHERE c.server_id = ?
AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
AND children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
GROUP BY c.id",
vec![QueryParam::String(self.server_id.clone())],
);
@@ -1481,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode requires the server; offline downloads play locally.
Err(RepoError::Offline)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
@@ -2340,7 +2388,12 @@ mod tests {
/// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
/// offline library pages showed every server item regardless of the toggle.
///
/// TRACES: UR-052 | DR-078 | UT-067
/// This is also the backend half of the end-to-end offline-listing scenario
/// IT-016: toggle off ⇒ downloaded media only; toggle on ⇒ the cached server
/// catalog is additionally revealed (greyed-out in the UI, distinguished by
/// the absence of a `downloads` row — see `MediaCard.isServerOnly`).
///
/// TRACES: UR-052 | DR-078 | UT-067, IT-016
#[tokio::test]
async fn test_get_items_toggle_gates_synced_catalog() {
use crate::storage::db_service::DatabaseService;
@@ -2641,7 +2694,7 @@ mod tests {
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
///
/// TRACES: UR-055 | DR-082, DR-083 | UT-046
/// TRACES: UR-055 | DR-082, DR-083 | UT-072
#[tokio::test]
async fn test_get_downloaded_items_returns_leaf_and_container() {
let db = create_test_db();
@@ -2659,10 +2712,112 @@ mod tests {
assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
}
/// Regression: browsing a downloaded *library* (top level) lists containers,
/// not their leaves — a music library shows the album, not the individual
/// downloaded songs. The leaf is still reachable by drilling into the album.
///
/// TRACES: UR-055 | DR-082, DR-083 | UT-076
#[tokio::test]
async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
let db = create_test_db();
seed_library(&db, "music-lib", "music").await;
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
// Tracks link to the album via album_id (parent_id NULL in the cache).
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
seed_completed_download(&db, "track-1", 1000).await;
seed_completed_download(&db, "track-2", 1000).await;
let repo = make_repo(&db);
// Library level: only the album shows, not the two tracks.
let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
ids,
vec!["album-1"],
"library browse lists the album container, not its tracks"
);
// Drilling into the album still returns the downloaded tracks.
let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
track_ids.sort();
assert_eq!(track_ids, vec!["track-1", "track-2"]);
}
/// 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.
///
/// TRACES: UR-055 | DR-082, DR-083 | UT-077
#[tokio::test]
async fn test_get_downloaded_items_library_lists_series_not_episodes() {
let db = create_test_db();
seed_library(&db, "tv-lib", "tvshows").await;
insert_item(&db, "series-1", "Series", None, None, None).await;
// Season links to its series; episode links to both season and series.
insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
insert_item(
&db,
"ep-1",
"Episode",
None,
Some("series-1"),
Some("season-1"),
)
.await;
seed_completed_download(&db, "ep-1", 4000).await;
let repo = make_repo(&db);
// Library level: only the series shows.
let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
ids,
vec!["series-1"],
"TV library browse lists the series, not seasons/episodes"
);
// Drilling into the series returns its season; into the season, the episode.
let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
assert!(
in_series.items.iter().any(|i| i.id == "season-1"),
"series drill returns the season"
);
let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
assert!(
in_season.items.iter().any(|i| i.id == "ep-1"),
"season drill returns the episode"
);
}
/// A downloaded leaf with no cached container (e.g. a Movie, or a track whose
/// album isn't in the cache) still surfaces at the library level.
///
/// TRACES: UR-055 | DR-082, DR-083 | UT-078
#[tokio::test]
async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
let db = create_test_db();
seed_library(&db, "movie-lib", "movies").await;
insert_item(&db, "movie-1", "Movie", None, None, None).await;
seed_completed_download(&db, "movie-1", 5000).await;
let repo = make_repo(&db);
let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
ids,
vec!["movie-1"],
"a downloaded movie with no container shows"
);
}
/// UT: an empty downloaded-only browse is authoritative — no rows, no error,
/// regardless of the catalog-browse flag (which the DR-080 fallthrough uses).
///
/// TRACES: UR-055 | DR-082 | UT-047
/// TRACES: UR-055 | DR-082 | UT-073
#[tokio::test]
async fn test_get_downloaded_items_empty_is_authoritative() {
let db = create_test_db();
@@ -2681,7 +2836,7 @@ mod tests {
/// UT: only libraries with downloaded content are listed; an empty one is omitted.
///
/// TRACES: UR-055 | DR-082 | UT-048
/// TRACES: UR-055 | DR-082 | UT-074
#[tokio::test]
async fn test_get_downloaded_libraries_omits_empty() {
let db = create_test_db();
@@ -2703,7 +2858,7 @@ mod tests {
/// UT: disk usage reports a leaf's own size, a container's summed descendants,
/// and reconciles the device total with the sum of leaves.
///
/// TRACES: UR-056 | DR-085 | UT-049
/// TRACES: UR-056 | DR-085 | UT-075
#[tokio::test]
async fn test_download_disk_usage_aggregates_containers() {
let db = create_test_db();
+17 -1
View File
@@ -450,7 +450,7 @@ impl OnlineRepository {
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn get_audio_only_stream_url_for_video(
pub async fn build_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
@@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
Ok(url)
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
self.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
+180 -1
View File
@@ -1,4 +1,4 @@
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
use serde::{Deserialize, Serialize};
@@ -26,6 +26,67 @@ impl VolumeLevel {
}
}
/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
/// and layout are a property of the audio engine, not the UI — presets and the
/// MPV filter are defined against these bands. See docs/specs/audio-equalizer.md.
///
/// TRACES: UR-027 | DR-030, IR-020
pub const EQ_BANDS: [f32; 10] = [
31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
];
/// Minimum per-band gain in dB.
pub const EQ_GAIN_MIN: f32 = -12.0;
/// Maximum per-band gain in dB.
pub const EQ_GAIN_MAX: f32 = 12.0;
/// 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
/// in Rust so the frontend never encodes the taxonomy.
///
/// TRACES: UR-027 | DR-030
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EqPreset {
Flat,
Rock,
Pop,
Jazz,
Classical,
BassBoost,
TrebleBoost,
Vocal,
}
impl EqPreset {
/// All presets, for enumerating the curve table across the IPC boundary.
pub const ALL: [EqPreset; 8] = [
EqPreset::Flat,
EqPreset::Rock,
EqPreset::Pop,
EqPreset::Jazz,
EqPreset::Classical,
EqPreset::BassBoost,
EqPreset::TrebleBoost,
EqPreset::Vocal,
];
/// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`].
/// Curves are conservative (within ±8 dB) so presets stack safely with the
/// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.
pub fn gains(&self) -> [f32; 10] {
match self {
EqPreset::Flat => [0.0; 10],
EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
}
}
}
/// Audio playback settings
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -38,6 +99,18 @@ pub struct AudioSettings {
pub normalize_volume: bool,
/// Target volume level for normalization
pub volume_level: VolumeLevel,
/// Enable the graphic equalizer. When false, no EQ filter is applied.
#[serde(default)]
pub equalizer_enabled: bool,
/// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
/// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
#[serde(default = "default_eq_bands")]
pub equalizer_bands: Vec<f32>,
}
/// Flat 10-band curve — the default equalizer state.
fn default_eq_bands() -> Vec<f32> {
vec![0.0; EQ_BANDS.len()]
}
impl Default for AudioSettings {
@@ -47,6 +120,8 @@ impl Default for AudioSettings {
gapless_playback: true,
normalize_volume: false,
volume_level: VolumeLevel::Normal,
equalizer_enabled: false,
equalizer_bands: default_eq_bands(),
}
}
}
@@ -57,6 +132,20 @@ impl AudioSettings {
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
self
}
/// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()`
/// entries (pad with 0 dB / truncate) and clamp each gain to the valid
/// range. Guards against malformed persisted or IPC input.
///
/// TRACES: UR-027 | DR-030
pub fn with_equalizer_normalised(mut self) -> Self {
let n = EQ_BANDS.len();
self.equalizer_bands.resize(n, 0.0);
for g in &mut self.equalizer_bands {
*g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
}
self
}
}
/// Video playback settings
@@ -101,6 +190,95 @@ mod tests {
assert!(settings.gapless_playback);
assert!(!settings.normalize_volume);
assert_eq!(settings.volume_level, VolumeLevel::Normal);
// Equalizer defaults: disabled and flat.
assert!(!settings.equalizer_enabled);
assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
}
/// EQ presets each return one gain per band; Flat is all zeros.
///
/// TRACES: UR-027 | DR-030 | UT-079
#[test]
fn test_eq_preset_curves() {
for preset in EqPreset::ALL {
assert_eq!(
preset.gains().len(),
EQ_BANDS.len(),
"preset {:?} must have one gain per band",
preset
);
// Every preset stays within the advertised gain range.
for g in preset.gains() {
assert!(
(EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
"preset {:?} gain {} out of range",
preset,
g
);
}
}
assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
// Bass boost lifts the low bands and leaves the top flat.
let bass = EqPreset::BassBoost.gains();
assert!(bass[0] > 0.0 && bass[9] == 0.0);
}
/// `with_equalizer_normalised` clamps out-of-range gains and forces the
/// band vector to exactly EQ_BANDS.len() (pad short, truncate long).
///
/// TRACES: UR-027 | DR-030 | UT-080
#[test]
fn test_eq_normalisation() {
// Out-of-range gains are clamped.
let s = AudioSettings {
equalizer_bands: vec![100.0, -100.0, 3.0],
..Default::default()
}
.with_equalizer_normalised();
assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
assert_eq!(s.equalizer_bands[2], 3.0);
// Short vector padded with 0 dB.
assert_eq!(s.equalizer_bands[9], 0.0);
// Over-long vector truncated.
let long = AudioSettings {
equalizer_bands: vec![1.0; 20],
..Default::default()
}
.with_equalizer_normalised();
assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
}
/// Old persisted JSON without the EQ fields loads as disabled + flat.
///
/// TRACES: UR-027 | DR-030 | UT-081
#[test]
fn test_audio_settings_eq_backward_compat() {
let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
let parsed: AudioSettings = serde_json::from_str(json).unwrap();
assert!(!parsed.equalizer_enabled);
assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
}
/// EQ fields serialize as camelCase and round-trip.
///
/// TRACES: UR-027 | DR-030 | UT-082
#[test]
fn test_audio_settings_eq_serialization() {
let settings = AudioSettings {
equalizer_enabled: true,
equalizer_bands: EqPreset::Rock.gains().to_vec(),
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"equalizerEnabled\":true"));
assert!(json.contains("\"equalizerBands\":"));
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
assert!(parsed.equalizer_enabled);
assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
}
#[test]
@@ -134,6 +312,7 @@ mod tests {
gapless_playback: true,
normalize_volume: true,
volume_level: VolumeLevel::Loud,
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();
+14
View File
@@ -24,6 +24,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018),
("019_genres_cache", MIGRATION_019),
("020_items_season_index", MIGRATION_020),
];
/// Initial schema migration
@@ -281,6 +282,7 @@ CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
@@ -714,3 +716,15 @@ CREATE TABLE IF NOT EXISTS genres (
CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
"#;
/// Migration to index `items.season_id`.
///
/// Episodes link to their season via `season_id` (parent_id is NULL in the
/// cache). The container-rollup queries used by the Downloaded browse and the
/// disk-usage aggregation join `children.season_id = c.id`, which without this
/// index degrades to an unindexable scan — a large synced catalog then makes
/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
/// `album_id`, and `series_id` were already indexed; this closes the gap.
const MIGRATION_020: &str = r#"
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
"#;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.0.16",
"version": "0.1.2",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -23,7 +23,7 @@
},
"bundle": {
"active": true,
"targets": ["deb", "rpm"],
"targets": ["deb", "rpm", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+53 -3
View File
@@ -173,6 +173,16 @@ async playerSetAudioSettings(settings: AudioSettings) : Promise<AudioSettings> {
async playerGetAudioSettings() : Promise<AudioSettings> {
return await TAURI_INVOKE("player_get_audio_settings");
},
/**
* The built-in equalizer presets and their per-band gain curves (dB), for the
* settings UI. The curve numbers are domain data defined by the band layout,
* so the frontend reads them here rather than encoding them.
*
* TRACES: UR-027 | DR-030
*/
async playerGetEqPresets() : Promise<([EqPreset, number[]])[]> {
return await TAURI_INVOKE("player_get_eq_presets");
},
async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
return await TAURI_INVOKE("player_set_video_settings", { settings });
},
@@ -1570,7 +1580,16 @@ normalizeVolume: boolean;
/**
* Target volume level for normalization
*/
volumeLevel: VolumeLevel }
volumeLevel: VolumeLevel;
/**
* Enable the graphic equalizer. When false, no EQ filter is applied.
*/
equalizerEnabled?: boolean;
/**
* Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
* clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
*/
equalizerBands?: number[] }
/**
* Response for audio track switching operations
*/
@@ -1746,6 +1765,14 @@ export type DownloadVideoRequest = { itemId: string; userId: string; filePath: s
* Enhanced response with pre-computed stats
*/
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
/**
* 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
* in Rust so the frontend never encodes the taxonomy.
*
* TRACES: UR-027 | DR-030
*/
export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" | "bassBoost" | "trebleBoost" | "vocal"
/**
* Genre
*/
@@ -2028,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
* handoff so the lockscreen MediaSession advertises a real duration a
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
*/
durationSeconds?: number | null }
durationSeconds?: number | null;
/**
* Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
* background-audio handoff so an episode played as audio-only is still
* recognised as an episode by autoplay (UR-040) and advances to the next one.
*/
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 }
/**
* Queue context for remote transfer - what type of queue is this?
*/
@@ -2356,7 +2394,19 @@ export type PlayerStatusEvent =
* or remote so they can pause/play/seek/stop the webview element.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/
{ type: "control_command"; action: string; position: number | null }
{ type: "control_command"; action: string; position: number | null } |
/**
* Ask the frontend webview `<audio>` element to load and play a stream.
*
* Emitted by `WebviewAudioBackend` on platforms with no native audio
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
* element in the webview, mirroring how all video already renders through
* the webview `<video>`. The element then reports its state/position back
* through the `player_report_*` commands, so the Rust controller stays the
* single source of truth. Subsequent play/pause/seek/stop reach the element
* via `ControlCommand`.
*/
{ type: "webview_audio_load"; url: string; media_id: string | null; position: number; autoplay: boolean }
/**
* Result of creating a playlist
*
+3 -1
View File
@@ -7,10 +7,11 @@
title: string;
items: MediaItem[];
onItemClick?: (item: MediaItem) => void;
onItemLongPress?: (item: MediaItem) => void;
showAll?: () => void;
}
let { title, items, onItemClick, showAll }: Props = $props();
let { title, items, onItemClick, onItemLongPress, showAll }: Props = $props();
let scrollContainer: HTMLDivElement | null = $state(null);
let showLeftArrow = $state(false);
@@ -60,6 +61,7 @@
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
onLongPress={onItemLongPress ? () => onItemLongPress(item) : undefined}
/>
{/each}
</div>
@@ -4,6 +4,7 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -14,63 +15,12 @@
let { episode, series, allEpisodes, onBack }: Props = $props();
// Check if an episode matches the focused episode (by ID or season/episode number)
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
// Also match by season/episode number in case IDs differ
return ep.parentIndexNumber === episode.parentIndexNumber &&
ep.indexNumber === episode.indexNumber;
return isSameEpisode(ep, episode);
}
// Find adjacent episodes - use season/episode numbers if ID not found
const adjacentEpisodes = $derived(() => {
// First, try to find the episode by ID
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
// If not found by ID, try to find by season/episode number
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
idx = allEpisodes.findIndex(
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
);
}
// If still not found, filter to same season and show those centered around the episode number
if (idx === -1) {
const sameSeasonEpisodes = allEpisodes
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
if (sameSeasonEpisodes.length > 0) {
// Find position based on episode number
const epNum = episode.indexNumber || 1;
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
const start = Math.max(0, actualIdx - 3);
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
const result = sameSeasonEpisodes.slice(start, end);
// Insert the focused episode if not already present (by season/episode number match)
const hasCurrentEpisode = result.some(isCurrentEpisode);
if (!hasCurrentEpisode) {
// Insert at correct position based on episode number
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
if (insertIdx === -1) {
result.push(episode);
} else {
result.splice(insertIdx, 0, episode);
}
}
return result;
}
// Last resort: return focused episode with first 9 episodes
return [episode, ...allEpisodes.slice(0, 9)];
}
// Get 3 before and 6 after (or adjust based on position)
const start = Math.max(0, idx - 3);
const end = Math.min(allEpisodes.length, idx + 7);
return allEpisodes.slice(start, end);
});
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
+68 -2
View File
@@ -30,9 +30,69 @@
*/
onRemove?: () => void;
onclick?: () => void;
/**
* When set, a long press (touch hold / mouse hold) fires this instead of the
* regular tap. The tap that would otherwise follow the release is suppressed.
* Used on the home page: tap opens the detail page, long-press plays now.
* TRACES: UR-058 | DR-087
*/
onLongPress?: () => void;
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props();
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: 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
// flag so the ensuing click is swallowed. Pointer events cover touch + mouse.
const LONG_PRESS_MS = 500;
const MOVE_CANCEL_PX = 10;
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressFired = false;
let pressStartX = 0;
let pressStartY = 0;
function clearPressTimer() {
if (pressTimer !== null) {
clearTimeout(pressTimer);
pressTimer = null;
}
}
function handlePointerDown(e: PointerEvent) {
if (!onLongPress || isServerOnly) return;
longPressFired = false;
pressStartX = e.clientX;
pressStartY = e.clientY;
clearPressTimer();
pressTimer = setTimeout(() => {
longPressFired = true;
pressTimer = null;
onLongPress?.();
}, LONG_PRESS_MS);
}
function handlePointerMove(e: PointerEvent) {
if (pressTimer === null) return;
if (
Math.abs(e.clientX - pressStartX) > MOVE_CANCEL_PX ||
Math.abs(e.clientY - pressStartY) > MOVE_CANCEL_PX
) {
clearPressTimer();
}
}
function handlePointerUp() {
clearPressTimer();
}
function handleClick() {
// A long press already handled this interaction; swallow the trailing click.
if (longPressFired) {
longPressFired = false;
return;
}
onclick?.();
}
// Check if this item is downloaded
const downloadInfo = $derived(
@@ -150,7 +210,13 @@
type={isServerOnly ? undefined : "button"}
role={isServerOnly ? "group" : undefined}
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
onclick={isServerOnly ? undefined : onclick}
style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined}
onclick={isServerOnly ? undefined : handleClick}
onpointerdown={isServerOnly ? undefined : handlePointerDown}
onpointermove={isServerOnly ? undefined : handlePointerMove}
onpointerup={isServerOnly ? undefined : handlePointerUp}
onpointercancel={isServerOnly ? undefined : handlePointerUp}
oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined}
>
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
<CachedImage
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
id: string,
season: number | null,
number: number | null,
): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
parentIndexNumber: season,
indexNumber: number,
} as unknown as MediaItem;
}
function season(n: number, count: number): MediaItem[] {
return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1));
}
describe("isCurrentEpisode", () => {
const current = ep("abc", 1, 3);
it("matches by id", () => {
expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true);
});
it("matches by season+episode number when id differs", () => {
expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true);
});
it("does not match a different episode number", () => {
expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false);
});
it("does NOT treat two number-less episodes as the same (the reported bug)", () => {
const a = ep("a", null, null);
const b = ep("b", null, null);
expect(isCurrentEpisode(a, b)).toBe(false);
});
it("does not match when only one side has numbers", () => {
expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false);
expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false);
});
});
describe("adjacentEpisodes", () => {
it("returns just the current episode when there are no others", () => {
const current = ep("only", 1, 1);
expect(adjacentEpisodes(current, [])).toEqual([current]);
});
it("returns siblings, not just the current episode", () => {
const eps = season(1, 8);
const current = eps[2]; // S1E3
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
expect(strip).toContain(current);
});
it("windows to 3 before and 6 after the current episode", () => {
const eps = season(1, 20);
const current = eps[9]; // S1E10, index 9
const strip = adjacentEpisodes(current, eps);
// start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items)
expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
const eps = season(1, 5);
// Focused episode has a different id than any in the list but same numbers.
const current = ep("fetched-directly", 1, 3);
const strip = adjacentEpisodes(current, eps);
// It should appear once, anchored at its numeric position, alongside siblings.
expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1);
expect(strip.length).toBeGreaterThan(1);
});
it("falls back to the full list when the current season is unknown", () => {
const eps = season(1, 5);
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
});
});
@@ -0,0 +1,61 @@
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
//
// Extracted from the component so it can be unit-tested: the strip must never
// collapse to just the current episode while real siblings exist, and it must
// not mistake number-less episodes for the current one.
//
// TRACES: UR-048 | DR-062
import type { MediaItem } from "$lib/api/types";
/**
* Does `ep` refer to the same episode as `current`?
*
* Matches by id first. Falls back to season+episode number, but ONLY when both
* numbers are known on both sides otherwise `undefined === undefined` would
* mark every number-less episode as the current one (the bug that made the
* whole strip look like the current episode).
*/
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
);
}
/**
* The window of episodes shown under the hero: up to 3 before and 6 after the
* current episode. Degrades gracefully:
* - prefers the current season, falling back to the full list when the season
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
* - splices the current episode into the pool at its numeric position when it
* isn't present, so it still anchors the window;
* - returns just `[current]` only when there genuinely are no other episodes.
*/
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
const seasonMatches = allEpisodes.filter(
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
);
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
.slice()
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
const epNum = current.indexNumber ?? 0;
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
idx = insertAt === -1 ? pool.length : insertAt;
pool.splice(idx, 0, current);
}
const start = Math.max(0, idx - 3);
const end = Math.min(pool.length, idx + 7);
return pool.slice(start, end);
}
+15 -4
View File
@@ -12,6 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -20,11 +21,11 @@
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
isBackgroundAudioSupported,
setBackgroundAudioEnabled,
subscribeAppBackgrounded,
subscribeAppForegrounded,
} from "$lib/utils/backgroundAudio";
import { platform } from "@tauri-apps/plugin-os";
import {
computeHandoffPosition,
initialHandoffState,
@@ -1178,8 +1179,14 @@
// playback off to the native ExoPlayer audio service; the WebView <video> is
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
//
// Resolved synchronously (no await) for the same native-mode reason as PiP.
const backgroundAudioSupported = isBackgroundAudioSupported();
// Gate on the platform, NOT on the AndroidBackgroundAudio JS-bridge probe.
// The native isSupported() is unconditionally true on Android, but the bridge
// is injected into the WebView asynchronously and races component mount — a
// one-shot bridge probe here comes out false on some loads and, being a const,
// never recovers, so the button vanished on "some videos". platform() is
// available synchronously and is stable. toggleBackgroundAudio() no-ops safely
// if the bridge is momentarily absent.
const backgroundAudioSupported = platform() === "android";
let backgroundAudioOn = $state(false); // v1: default OFF each session
let handoffState: BackgroundAudioState = { ...initialHandoffState };
@@ -1227,6 +1234,10 @@
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
// Episode identity so the backend can auto-advance to the next episode
// when this audio-only stream ends while backgrounded (UR-040).
itemType: media.type ?? null,
seriesId: media.seriesId ?? null,
},
pos,
);
@@ -1585,7 +1596,7 @@
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
class="max-w-full max-h-full"
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
+49
View File
@@ -0,0 +1,49 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}
@@ -53,7 +53,7 @@
<MediaCard
{item}
size="medium"
showProgress={group.id !== "artists"}
showProgress={group.id !== "artists" && group.id !== "people"}
onclick={() => onItemClick?.(item)}
/>
{/each}
@@ -0,0 +1,145 @@
/**
* Webview audio adapter plays audio-only media through a hidden `<audio>`
* element on platforms with no native audio backend (currently Windows).
*
* All *video* already renders through the webview `<video>` element on every
* platform; libmpv/ExoPlayer only drive audio-only playback. On Windows there is
* no native audio backend, so the Rust `WebviewAudioBackend` hands the stream URL
* to the frontend via a `webview_audio_load` event and drives play/pause/seek
* through `control_command`. This adapter owns the `<audio>` element that plays
* it and reports state/position/duration/ended back to Rust through the same
* `player_report_*` round-trip the HTML5 video adapter uses (via {@link AdapterHost}).
*
* It implements the {@link PlayerAdapter} surface so it can be registered with
* `playerController.setActiveAdapter` but only the methods `handleControlCommand`
* actually routes (`play`, `pause`, `seekElement`) carry audio-specific logic;
* the video-only members (subtitles, transcode reload) are inert stubs.
*
* TRACES: UR-003, UR-005 | DR-004
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
export class WebviewAudioAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
private audio: HTMLAudioElement;
private host: AdapterHost;
private endedFired = false;
constructor(audio: HTMLAudioElement, host: AdapterHost) {
this.audio = audio;
this.host = host;
this.wire();
}
private wire(): void {
const a = this.audio;
a.addEventListener("loadedmetadata", () => {
this.host.onMediaLoaded(Number.isFinite(a.duration) ? a.duration : 0);
});
a.addEventListener("timeupdate", () => {
this.host.onPosition(a.currentTime, Number.isFinite(a.duration) ? a.duration : 0);
});
a.addEventListener("playing", () => this.host.onState("playing"));
a.addEventListener("pause", () => {
// A pause fired at the natural end is part of "ended"; don't report paused.
if (!a.ended) this.host.onState("paused");
});
a.addEventListener("waiting", () => this.host.onBuffering(true));
a.addEventListener("canplay", () => this.host.onReady());
a.addEventListener("ended", () => {
if (this.endedFired) return;
this.endedFired = true;
this.host.onState("stopped");
this.host.onEnded();
});
a.addEventListener("error", () => {
const err = a.error;
this.host.onError(err ? `audio error code ${err.code}` : "unknown audio error");
});
}
/** Load `url` at `initialPosition` and (by default) begin playing. */
async load(url: string, options: PlayerLoadOptions): Promise<void> {
this.endedFired = false;
this.host.onState("loading");
this.host.onStreamUrlChanged(url);
this.audio.src = url;
this.audio.load();
if (options.initialPosition > 0) {
// Seek once metadata is ready so currentTime sticks.
const seekWhenReady = () => {
this.audio.currentTime = options.initialPosition;
this.audio.removeEventListener("loadedmetadata", seekWhenReady);
};
this.audio.addEventListener("loadedmetadata", seekWhenReady);
}
await this.play();
}
async play(): Promise<void> {
try {
await this.audio.play();
} catch (e) {
this.host.onError(`play() rejected: ${String(e)}`);
}
}
async pause(): Promise<void> {
this.audio.pause();
}
async toggle(): Promise<boolean> {
if (this.audio.paused) {
await this.play();
return true;
}
await this.pause();
return false;
}
async seekElement(positionSeconds: number, _offset: number): Promise<void> {
this.audio.currentTime = positionSeconds;
}
/** No transcode-reload concept for direct audio; treat as a fresh load. */
async reloadSource(url: string, offset: number): Promise<void> {
await this.load(url, {
mediaId: "",
mediaSourceId: null,
needsTranscoding: false,
initialPosition: offset,
isLive: false,
audioTrackIndex: null,
knownDuration: 0,
subtitleTracks: [],
});
}
attach(_element: HTMLVideoElement | null): void {
// The audio element is owned by the controller, not attached here.
}
setVolume(volume: number): void {
this.audio.volume = Math.max(0, Math.min(1, volume));
}
setMuted(muted: boolean): void {
this.audio.muted = muted;
}
async selectSubtitle(_streamIndex: number | null, _arrayIndex?: number): Promise<void> {
// No subtitles for audio-only playback.
}
getPosition(): number {
return this.audio.currentTime;
}
async dispose(): Promise<void> {
this.audio.pause();
this.audio.removeAttribute("src");
this.audio.load();
}
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Skip-to-next-episode reporting tests.
*
* Regression: pressing "skip to next episode" left the outgoing episode with a
* mid-episode resume position, so it showed a partial progress bar and offered
* to resume. A manual skip means the user is done with that episode it must
* be recorded as fully watched.
*
* TRACES: UR-059, UR-025 | DR-088
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const markAsPlayed = vi.fn(async (_itemId: string) => undefined);
const reportPlaybackStopped = vi.fn(
async (_itemId: string, _positionSeconds: number) => undefined
);
vi.mock("./playbackReporting", () => ({
markAsPlayed: (itemId: string) => markAsPlayed(itemId),
reportPlaybackStopped: (itemId: string, positionSeconds: number) =>
reportPlaybackStopped(itemId, positionSeconds),
}));
import {
shouldSuppressStopReport,
markSkipped,
reportSkippedEpisode,
resetSkipState,
} from "./skipReporting";
describe("skip reporting", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSkipState();
});
describe("reportSkippedEpisode", () => {
it("marks the skipped episode as fully played", async () => {
await reportSkippedEpisode("ep-1");
expect(markAsPlayed).toHaveBeenCalledWith("ep-1");
});
it("does not stamp the mid-episode position as a resume point", async () => {
await reportSkippedEpisode("ep-1");
expect(reportPlaybackStopped).not.toHaveBeenCalled();
});
it("ignores a null item id", async () => {
await reportSkippedEpisode(null);
expect(markAsPlayed).not.toHaveBeenCalled();
});
});
describe("shouldSuppressStopReport", () => {
it("suppresses the unmount stop report for the skipped episode", async () => {
await reportSkippedEpisode("ep-1");
// VideoPlayer.onDestroy fires after navigation with the mid-episode time.
expect(shouldSuppressStopReport("ep-1")).toBe(true);
});
it("only suppresses the episode that was actually skipped", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-2")).toBe(false);
});
it("suppresses only once, so a later real stop still reports", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-1")).toBe(true);
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress when nothing was skipped", () => {
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress a null item id", () => {
markSkipped("ep-1");
expect(shouldSuppressStopReport(null)).toBe(false);
});
});
});
+64
View File
@@ -0,0 +1,64 @@
// Skip-to-next-episode reporting.
//
// Skipping an episode is a "done with it" signal, not a "stopped here" one:
// the user is moving on because they've already seen it. So a manual skip
// records the outgoing episode as fully played rather than saving the
// mid-episode position as a resume point.
//
// The suppression handshake exists because VideoPlayer.onDestroy fires its
// final reportStop *after* the skip navigation, with the mid-episode time. If
// that landed, it would overwrite the just-written 100% progress and the
// episode would look partially watched again. markSkipped() arms a one-shot
// suppression that the stop handler consumes.
//
// TRACES: UR-059, UR-025 | DR-088
import { markAsPlayed, reportPlaybackStopped } from "./playbackReporting";
/** Item id whose next stop report should be dropped, if any. */
let suppressedItemId: string | null = null;
/**
* Arm suppression of the next stop report for `itemId`.
*
* Exported separately from `reportSkippedEpisode` so callers that already
* handled their own reporting can still silence the unmount stop.
*/
export function markSkipped(itemId: string | null): void {
if (!itemId) return;
suppressedItemId = itemId;
}
/**
* Should the pending stop report for `itemId` be dropped?
*
* One-shot: consumes the armed suppression, so a later genuine stop on the
* same episode still reports its position normally.
*/
export function shouldSuppressStopReport(itemId: string | null): boolean {
if (!itemId) return false;
if (suppressedItemId !== itemId) return false;
suppressedItemId = null;
return true;
}
/**
* Record a manually skipped episode as fully watched.
*
* Deliberately does NOT call `reportPlaybackStopped` that would write the
* partial position we are trying to avoid.
*/
export async function reportSkippedEpisode(itemId: string | null): Promise<void> {
if (!itemId) return;
markSkipped(itemId);
await markAsPlayed(itemId);
}
/** Test hook: clear armed suppression between cases. */
export function resetSkipState(): void {
suppressedItemId = null;
}
// Re-exported so the module owns the full skip story; callers that need the
// normal stop path keep importing it from playbackReporting directly.
export { reportPlaybackStopped };
+109
View File
@@ -0,0 +1,109 @@
/**
* Webview audio controller the frontend half of audio-only playback on
* platforms with no native audio backend (currently Windows).
*
* The Rust `WebviewAudioBackend` emits a `webview_audio_load` event carrying the
* stream URL whenever a track loads. This controller owns a single hidden
* `<audio>` element, plays that URL through a {@link WebviewAudioAdapter}, and
* registers the adapter with the player facade so backend `control_command`
* events (play/pause/seek routed by playerEvents.ts) reach the element. The
* adapter reports state/position back through the standard `player_report_*`
* round-trip, keeping the Rust controller the single source of truth.
*
* No-op on platforms with a native audio backend (Linux/Android): the backend
* never emits `webview_audio_load` there, so even if initialized this listener
* stays idle. We still gate initialization on platform to avoid mounting a stray
* element.
*
* TRACES: UR-003, UR-005 | DR-004
*/
import { type UnlistenFn } from "@tauri-apps/api/event";
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";
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;
audioEl = document.createElement("audio");
audioEl.hidden = true;
audioEl.preload = "auto";
// Kept in the DOM so the browser keeps decoding it when not focused.
document.body.appendChild(audioEl);
unlisten = await events.playerStatusEvent.listen((event) => {
const p = event.payload;
if (p.type !== "webview_audio_load") return;
void handleLoad(p.url, p.media_id, p.position, p.autoplay);
});
}
async function handleLoad(
url: string,
mediaId: string | null,
position: number,
autoplay: boolean
): Promise<void> {
if (!audioEl) return;
// Fresh host/adapter per load so reporting targets the current media id.
const host = createRustReportHost(mediaId ?? "", {});
adapter = new WebviewAudioAdapter(audioEl, host);
playerController.setActiveAdapter(adapter);
await adapter.load(url, {
mediaId: mediaId ?? "",
mediaSourceId: null,
needsTranscoding: false,
initialPosition: position,
isLive: false,
audioTrackIndex: null,
knownDuration: 0,
subtitleTracks: [],
});
if (!autoplay) {
await adapter.pause();
}
}
/** Tear down the controller (idempotent). */
export function cleanupWebviewAudio(): void {
if (unlisten) {
unlisten();
unlisten = null;
}
if (adapter) {
playerController.clearActiveAdapter(adapter);
void adapter.dispose();
adapter = null;
}
if (audioEl) {
audioEl.remove();
audioEl = null;
}
}
@@ -0,0 +1,124 @@
/**
* Continue Watching stale-entry suppression tests.
*
* A partially-watched episode should drop off Continue Watching once the user
* has moved past it i.e. when Next Up for that series points at a *later*
* episode. Otherwise skipping an episode leaves it lingering as a resume
* suggestion behind the episode the user is actually on.
*
* TRACES: UR-059 | DR-089
*/
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
function episode(
id: string,
seriesId: string,
season: number | undefined,
index: number | undefined
): MediaItem {
return {
id,
name: `Episode ${index}`,
kind: "episode",
seriesId,
parentIndexNumber: season,
indexNumber: index,
} as MediaItem;
}
function movie(id: string): MediaItem {
return { id, name: "A Movie", kind: "movie" } as MediaItem;
}
describe("filterSupersededResumeItems", () => {
it("drops a partially-watched episode when next up is later in the same season", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result).toEqual([]);
});
it("drops it when next up is in a later season", () => {
const resume = [episode("s1e9", "series-a", 1, 9)];
const nextUp = [episode("s2e1", "series-a", 2, 1)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("keeps the episode the user is actually mid-way through", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps an episode ahead of next up (user jumped forward)", () => {
const resume = [episode("s1e7", "series-a", 1, 7)];
const nextUp = [episode("s1e3", "series-a", 1, 3)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("only compares within the same series", () => {
const resume = [episode("a-s1e2", "series-a", 1, 2)];
const nextUp = [episode("b-s1e9", "series-b", 1, 9)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("never suppresses movies", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps items when ordering is unknown on either side", () => {
const resume = [episode("s1e2", "series-a", undefined, undefined)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("treats a missing season number as season 1 only when both sides agree", () => {
// Flat series (no season folders): episode numbers alone must still order.
const resume = [episode("e2", "series-a", undefined, 2)];
const nextUp = [episode("e6", "series-a", undefined, 6)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("is a no-op when next up is empty", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
expect(filterSupersededResumeItems(resume, [])).toHaveLength(1);
});
it("preserves the original order of surviving items", () => {
const resume = [
episode("a-s1e2", "series-a", 1, 2),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s1e3", "series-c", 1, 3),
];
const nextUp = [episode("b-s1e4", "series-b", 1, 4)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
});
it("uses the furthest-ahead next-up entry for a series", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [
episode("s1e1", "series-a", 1, 1),
episode("s1e8", "series-a", 1, 8),
];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
// Continue Watching stale-entry suppression.
//
// Continue Watching is built from raw resume positions, so an episode the user
// has moved past keeps showing up as a resume suggestion — most visibly after
// skipping an episode, which leaves a partial position behind. Next Up already
// tells us where the user actually is in each series, so an in-progress episode
// that sits *behind* its series' Next Up entry is stale and gets suppressed.
//
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
import type { MediaItem } from "$lib/api/types";
/**
* Position of an episode within its series, as (season, episode).
*
* Returns null when the episode number is unknown without it there is no
* defensible ordering and we must not suppress anything. A missing *season*
* number is normal for flat series (no season folders), so it is only usable
* when both sides are equally season-less; callers compare via `isAheadOf`.
*/
function episodeOrder(item: MediaItem): { season: number | null; index: number } | null {
if (item.indexNumber == null) return null;
return { season: item.parentIndexNumber ?? null, index: item.indexNumber };
}
/** Is `a` strictly later in series order than `b`? */
function isAheadOf(a: MediaItem, b: MediaItem): boolean {
const oa = episodeOrder(a);
const ob = episodeOrder(b);
if (!oa || !ob) return false;
// Mixed season-numbering (one side foldered, the other flat) is not safely
// comparable — leave the entry alone rather than hide something wrongly.
if ((oa.season == null) !== (ob.season == null)) return false;
if (oa.season != null && ob.season != null && oa.season !== ob.season) {
return oa.season > ob.season;
}
return oa.index > ob.index;
}
/**
* Drop resume entries the user has already moved past.
*
* An episode is suppressed when its series has a Next Up entry strictly later
* in series order. Movies, items without a series, and anything whose ordering
* is unknown are always kept suppression must never hide something the user
* genuinely still wants to resume.
*/
export function filterSupersededResumeItems(
resumeItems: MediaItem[],
nextUpItems: MediaItem[]
): MediaItem[] {
if (nextUpItems.length === 0) return resumeItems;
// Furthest-ahead Next Up entry per series: Next Up can carry more than one
// entry for a series, and the latest is the true watch frontier.
const frontier = new Map<string, MediaItem>();
for (const item of nextUpItems) {
if (!item.seriesId) continue;
const current = frontier.get(item.seriesId);
if (!current || isAheadOf(item, current)) {
frontier.set(item.seriesId, item);
}
}
return resumeItems.filter(item => {
if (item.kind !== "episode" || !item.seriesId) return true;
const ahead = frontier.get(item.seriesId);
if (!ahead) return true;
return !isAheadOf(ahead, item);
});
}
+7 -2
View File
@@ -1,8 +1,9 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -50,8 +51,12 @@ function createHomeStore() {
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
+43 -26
View File
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
it("loads a stored order", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
);
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"episodes",
"shows",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
it("migrates a stored `tvShows` from before the group split", async () => {
// Upgrading must keep the user's placement of TV, not append the two new
// groups at the bottom.
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
@@ -59,10 +77,12 @@ describe("searchGroupOrder", () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Default is shows, episodes, movies, songs, … — move movies up one.
searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([
const expected = [
"shows",
"movies",
"episodes",
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
"people",
];
expect(get(searchGroupOrder)).toEqual(expected);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
// Simulate a fresh app start reading the same storage.
vi.resetModules();
const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
});
it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Drag "albums" (index 4) to the front.
searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"shows",
"episodes",
"movies",
"songs",
"artists",
"people",
]);
});
it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1);
searchGroupOrder.move("movies", -1);
searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
});
+8 -2
View File
@@ -1,10 +1,11 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -81,7 +82,12 @@ function createTvStore() {
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { formatBytes } from "./formatBytes";
// TRACES: UR-056 | DR-085 | UT-050
// TRACES: UR-056 | DR-085 | UT-071
describe("formatBytes", () => {
it("renders zero and non-positive as '0 B'", () => {
expect(formatBytes(0)).toBe("0 B");
+158 -30
View File
@@ -7,6 +7,8 @@ import {
normalizeGroupOrder,
reorderGroups,
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
scopeItemTypes,
type SearchGroupId,
} from "./searchScope";
@@ -92,9 +94,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -103,9 +107,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -113,34 +119,78 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
"songs",
"movies",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
it("preserves a complete valid order unchanged", () => {
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
const order: SearchGroupId[] = [
"episodes",
"shows",
"movies",
"artists",
"albums",
"songs",
"people",
];
expect(normalizeGroupOrder(order)).toEqual(order);
});
it("expands a stored `tvShows` into shows + episodes in place", () => {
// Migration: the old combined group split, and a user who put TV first
// must still get TV first rather than appended at the bottom.
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
});
describe("groupsForScope", () => {
it("returns every group in saved order for the all scope", () => {
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
"movies",
"songs",
"tvShows",
"albums",
"artists",
]);
expect(
groupsForScope("all", [
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"people",
])
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
});
it("keeps only in-scope groups, in saved order", () => {
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
const order: SearchGroupId[] = [
"artists",
"movies",
"albums",
"episodes",
"shows",
"songs",
"people",
];
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
expect(groupsForScope("movies", order)).toEqual(["movies"]);
expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
});
it("surfaces people only under the all scope", () => {
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
});
});
@@ -156,13 +206,29 @@ describe("composeSearchGroups", () => {
it("renders groups in the configured order", () => {
const groups = composeSearchGroups(results, "all", [
"tvShows",
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
expect(groups.map((g) => g.id)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"people",
]);
});
it("puts shows ahead of episodes by default", () => {
// Searching a show's name should surface the show itself first, not an
// arbitrary episode of it.
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
expect(ids).toEqual(["shows", "episodes"]);
});
it("omits empty groups", () => {
@@ -177,27 +243,44 @@ describe("composeSearchGroups", () => {
"albums",
]);
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
"tvShows",
"shows",
"episodes",
]);
});
it("groups series and episodes together under tvShows", () => {
it("separates series and episodes into their own groups", () => {
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
});
it("surfaces people so an actor search reaches their bio", () => {
// Person items were previously returned by the backend and silently dropped.
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
});
it("ignores item types that belong to no group", () => {
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
});
it("narrowing then widening restores the full arrangement", () => {
// Scope is a filter over the saved order, never a rewrite of it.
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
const order: SearchGroupId[] = [
"shows",
"songs",
"movies",
"albums",
"artists",
"episodes",
"people",
];
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
composeSearchGroups(results, "music", order);
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
});
it("survives a stored order containing an unknown id", () => {
@@ -205,7 +288,14 @@ describe("composeSearchGroups", () => {
"podcasts",
"movies",
] as unknown as SearchGroupId[]);
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
expect(groups.map((g) => g.id)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"people",
]);
});
it("handles items with a missing type", () => {
@@ -219,7 +309,7 @@ describe("composeSearchGroups", () => {
});
describe("moveGroup", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves a group up", () => {
expect(moveGroup(order, "artists", -1)).toEqual([
@@ -227,7 +317,7 @@ describe("moveGroup", () => {
"artists",
"albums",
"movies",
"tvShows",
"shows",
]);
});
@@ -237,13 +327,13 @@ describe("moveGroup", () => {
"songs",
"artists",
"movies",
"tvShows",
"shows",
]);
});
it("is a no-op at the boundaries", () => {
expect(moveGroup(order, "songs", -1)).toEqual(order);
expect(moveGroup(order, "tvShows", 1)).toEqual(order);
expect(moveGroup(order, "shows", 1)).toEqual(order);
});
it("is a no-op for an unknown id", () => {
@@ -258,18 +348,18 @@ describe("moveGroup", () => {
});
describe("reorderGroups", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves an item from one index to another", () => {
expect(reorderGroups(order, 0, 4)).toEqual([
"albums",
"artists",
"movies",
"tvShows",
"shows",
"songs",
]);
expect(reorderGroups(order, 4, 0)).toEqual([
"tvShows",
"shows",
"songs",
"albums",
"artists",
@@ -283,3 +373,41 @@ describe("reorderGroups", () => {
expect(reorderGroups(order, 0, 9)).toEqual(order);
});
});
describe("searchRouteUrl", () => {
it("encodes the query and the scope", () => {
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
});
it("omits the scope key for the default `all` scope", () => {
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
});
it("targets bare /search for an empty query so the page shows its empty state", () => {
expect(searchRouteUrl("", "all")).toBe("/search");
expect(searchRouteUrl(" ", "music")).toBe("/search");
});
});
describe("shouldNavigateToSearch", () => {
it("navigates from any library page, which cannot render results itself", () => {
// The bug: the header search bar shows on every /library/** route but only
// /library rendered $library.searchResults, so typing did nothing on
// /library/music, /library/tv, /library/movies and detail pages.
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
});
it("stays put when already on /search, so typing does not re-push history", () => {
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
});
it("does not navigate on an empty query", () => {
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
});
});
+108 -17
View File
@@ -62,51 +62,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
return "all";
}
/**
* The URL of the single search surface for a query + scope.
*
* `/search` is the *only* route that renders results, so every other search
* affordance (the desktop header bar) is a navigator to this URL rather than a
* second result renderer. The `all` scope is the page's own default, so it is
* omitted to keep shared/back-navigated URLs clean.
*
* TRACES: UR-049 | DR-063
*/
export function searchRouteUrl(query: string, scope: SearchScope): string {
const trimmed = query.trim();
if (!trimmed) return "/search";
const params = new URLSearchParams({ q: trimmed });
if (scope !== "all") params.set("scope", scope);
// URLSearchParams renders spaces as "+", valid in a query but noisier to
// read; %20 is equally valid and matches how the app builds other links.
return `/search?${params.toString().replace(/\+/g, "%20")}`;
}
/**
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
*
* True for every route except `/search` itself: no other page renders
* `searchResults`, so a search performed there is invisible. Guarding on
* `/search` keeps typing from pushing a history entry per keystroke.
*
* TRACES: UR-049 | DR-063
*/
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
if (!query.trim()) return false;
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
return path !== "/search";
}
// ---------------------------------------------------------------------------
// Result groups
// ---------------------------------------------------------------------------
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
export type SearchGroupId =
| "shows"
| "episodes"
| "movies"
| "songs"
| "albums"
| "artists"
| "people";
/** Shipped default order, per the spec. */
/**
* Shipped default order.
*
* TRACES: UR-060 | DR-091
*
* Containers lead the kinds they contain a show above its episodes, an album
* above nothing (songs are ranked separately) which matches how people search:
* you look for the show, not an arbitrary episode of it. `people` sits last as
* a peripheral match; it exists so searching an actor's name reaches their bio
* page rather than silently dropping the result.
*/
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"movies",
"tvShows",
"people",
];
export const GROUP_LABELS: Record<SearchGroupId, string> = {
shows: "TV Shows",
episodes: "Episodes",
movies: "Movies",
songs: "Songs",
albums: "Albums",
artists: "Artists",
movies: "Movies",
tvShows: "TV Shows",
people: "People",
};
/** Which scopes each group belongs to (`all` always includes everything). */
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
/**
* Which scopes each group belongs to (`all` always includes everything).
*
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
* it surfaces only under All rather than being forced into one of them.
*/
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
shows: "tv",
episodes: "tv",
movies: "movies",
songs: "music",
albums: "music",
artists: "music",
movies: "movies",
tvShows: "tv",
people: null,
};
/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
shows: ["Series"],
episodes: ["Episode"],
movies: ["Movie"],
songs: ["Audio"],
albums: ["MusicAlbum"],
artists: ["MusicArtist"],
movies: ["Movie"],
tvShows: ["Series", "Episode"],
people: ["Person"],
};
export function groupItemTypes(group: SearchGroupId): string[] {
return [...GROUP_ITEM_TYPES[group]];
}
/**
* Stored group ids that no longer exist, mapped to the ids that replaced them.
*
* `tvShows` was one group holding both Series and Episode; it split so a show
* can outrank its own episodes. Expanding in place preserves the position the
* user chose for it.
*
* TRACES: UR-060 | DR-091
*/
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
tvShows: ["shows", "episodes"],
};
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
if (known.has(id)) return [id as SearchGroupId];
return RETIRED_GROUP_IDS[id] ?? [];
}
/**
* Normalise a stored order into a usable one.
*
@@ -123,11 +208,15 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
if (Array.isArray(stored)) {
for (const id of stored) {
if (typeof id !== "string" || !known.has(id)) continue;
const groupId = id as SearchGroupId;
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
if (typeof id !== "string") continue;
// Retired ids expand in place rather than being dropped, so a user who
// dragged the old combined "TV Shows" group to the top keeps TV at the
// top instead of having shows/episodes appended to the bottom.
for (const groupId of migrateGroupId(id, known)) {
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
}
}
}
@@ -143,6 +232,8 @@ export function groupsForScope(
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] {
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
// only under `all` — the `=== scope` test already excludes it elsewhere.
return normalizeGroupOrder(order as SearchGroupId[]).filter(
(id) => scope === "all" || GROUP_SCOPE[id] === scope
);
+7
View File
@@ -7,6 +7,7 @@
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity";
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
import { initWebviewAudio, cleanupWebviewAudio } from "$lib/services/webviewAudio";
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
import { syncService } from "$lib/services/syncService";
import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
@@ -86,6 +87,11 @@
// Initialize player event listener for push-based updates
await initPlayerEvents();
// Initialize the webview audio controller (plays audio-only media in an
// <audio> element on platforms with no native audio backend, e.g. Windows;
// self-gates and is a no-op on Linux/Android).
await initWebviewAudio();
// Initialize download event listener
await initDownloadEvents();
@@ -122,6 +128,7 @@
onDestroy(() => {
stopNetworkReporting?.();
cleanupPlayerEvents();
cleanupWebviewAudio();
cleanupDownloadEvents();
connectivity.stopMonitoring();
syncService.stop();
+42 -8
View File
@@ -56,18 +56,47 @@
previousServerReachable = serverReachable;
});
// Tap → detail page. Non-playable containers already routed to /library; now
// movies and episodes go to their detail page too instead of playing straight
// away. Channel leaves (no detail page) still go direct to the player.
// TRACES: UR-058 | DR-087
function handleItemClick(item: MediaItem) {
switch (item.kind) {
case "series":
case "season":
case "album":
case "artist":
case "folder":
case "channel":
goto(`/library/${item.id}`);
case "channelItem":
case "liveChannel":
goto(`/player/${item.id}`);
break;
case "episode":
// An episode is never browsed as a bare Episode page — it opens in its
// series' Episode Focus View so the series context loads (ux-flows §5B.1).
if (item.seriesId) {
goto(`/library/${item.seriesId}?episode=${item.id}`);
} else {
goto(`/library/${item.id}`);
}
break;
default:
goto(`/player/${item.id}`);
goto(`/library/${item.id}`);
break;
}
}
// Long press → play immediately, confirming first so an accidental hold on a
// half-watched item doesn't blow away the user's spot without warning.
// TRACES: UR-058 | DR-087
function handleItemLongPress(item: MediaItem) {
switch (item.kind) {
case "movie":
case "episode":
case "channelItem":
case "liveChannel":
if (confirm(`Play "${item.name}" now?`)) {
goto(`/player/${item.id}`);
}
break;
default:
// Containers (series/season/album/…) have no single "play now" target.
goto(`/library/${item.id}`);
break;
}
}
@@ -145,6 +174,7 @@
title="Next Movie"
items={resumeMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
@@ -154,6 +184,7 @@
title="Next Episode"
items={nextUpItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
@@ -163,6 +194,7 @@
title="Recently Listened"
items={recentlyPlayedAudio}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
@@ -172,6 +204,7 @@
title="Continue Watching"
items={resumeItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
@@ -181,6 +214,7 @@
title="Recently Added"
items={latestItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
+20 -14
View File
@@ -6,8 +6,12 @@
import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import {
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
type SearchScope,
} from "$lib/utils/searchScope";
import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -48,18 +52,22 @@
}
});
// The header bar is a *navigator*, not a second results surface: /search is
// the only route that renders searchResults, so searching here routes there
// with the query + route-derived scope in the URL. Previously this ran
// library.search() in place, which was invisible on every /library/** page
// except /library itself.
// TRACES: UR-049 | DR-063
async function handleSearch(query: string) {
if (query.trim()) {
await library.search(query, searchScope);
} else {
if (!query.trim()) {
library.clearSearch();
return;
}
}
async function handleScopeChange(next: SearchScope) {
searchScope = next;
if (searchQuery.trim()) {
await library.search(searchQuery, next);
if (shouldNavigateToSearch($page.url.pathname, query)) {
await goto(searchRouteUrl(query, searchScope));
// The query now lives in the URL; clear the header input so returning to
// a library page does not leave a stale term sitting in the box.
searchQuery = "";
}
}
</script>
@@ -74,14 +82,12 @@
<AppHeader search={librarySearch} />
{#snippet librarySearch()}
<!-- Scope chips live on /search, which owns the results. -->
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
{#if searchQuery.trim()}
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
{/if}
{/snippet}
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
+4 -24
View File
@@ -12,8 +12,9 @@
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
let searchResults = $derived($library.searchResults);
let searchQuery = $derived($library.searchQuery);
// Search results are rendered exclusively by /search — this page used to
// render them inline, which made the header search bar appear broken on every
// other /library/** route. TRACES: UR-049 | DR-063
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
@@ -169,28 +170,7 @@
</script>
<div class="space-y-8">
{#if searchQuery}
<!-- Search results -->
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-white">
Search results for "{searchQuery}"
</h1>
<button
onclick={() => library.clearSearch()}
class="text-sm text-gray-400 hover:text-white"
>
Clear search
</button>
</div>
<LibraryGrid
items={searchResults}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else if showInlineLibraryContent}
{#if showInlineLibraryContent}
<!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6">
<div class="flex items-center gap-4">
+51 -6
View File
@@ -147,6 +147,34 @@
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// Some series expose episodes directly as children rather than under
// season folders. In that case the season fetch above yields nothing —
// group the flat episode children by their season number so the Episode
// Focus View still has a populated `allEpisodes` (otherwise "More
// Episodes" collapses to just the current episode).
if (seasonData.every((s) => s.episodes.length === 0)) {
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
if (flatEpisodes.length > 0) {
const bySeason = new Map<number, MediaItem[]>();
for (const ep of flatEpisodes) {
const key = ep.parentIndexNumber ?? 1;
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
}
seasonData = [...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([seasonNumber, episodes]) => ({
// Synthesize a minimal season header from the episodes we have.
season: {
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
kind: "season",
indexNumber: seasonNumber,
name: `Season ${seasonNumber}`,
} as MediaItem,
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
}));
}
}
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
@@ -381,12 +409,29 @@
<div class="flex-1 space-y-4">
<div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)}
<p class="text-lg text-gray-400 mt-1">
{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{#if item.kind === "episode"}
<!-- Links back to the parent series/season so the episode detail
page is a navigable hub, not a dead end. TRACES: UR-058 | DR-087 -->
{#if item.seriesId && item.seriesName}
<p class="text-lg mt-1">
<a
href={`/library/${item.seriesId}`}
class="text-[var(--color-jellyfin)] hover:underline"
>{item.seriesName}</a>
</p>
{/if}
{#if item.parentIndexNumber || item.indexNumber}
<p class="text-lg text-gray-400 mt-1">
{#if item.seasonId && item.parentIndexNumber}
<a
href={`/library/${item.seasonId}`}
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
>Season {item.parentIndexNumber}</a>
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{/if}
{:else if item.artistItems?.length || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
<ArtistLinks
+14 -1
View File
@@ -20,6 +20,7 @@
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -536,7 +537,11 @@
function handleReportStop(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
// A skipped episode was already recorded as fully watched. Its unmount stop
// report arrives after the skip navigation carrying the mid-episode
// position; letting it through would undo that and restore the partial
// progress bar.
if (id && !shouldSuppressStopReport(id)) {
reportPlaybackStopped(id, positionSeconds);
}
// Intentionally do NOT emit a "stopped" player state here. This runs on both
@@ -592,6 +597,14 @@
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Skipping means "I'm done with this one" — record the outgoing episode as
// fully watched rather than leaving a mid-episode resume point behind. This
// also arms suppression of the VideoPlayer's unmount stop report, which
// would otherwise fire after navigation and overwrite the 100% progress
// with the partial position (see skipReporting.ts).
const skippedId = currentMedia?.id ?? itemId ?? null;
void reportSkippedEpisode(skippedId);
// Use replaceState so "close/back" returns to the library, not the previous episode.
// restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
+29 -3
View File
@@ -5,15 +5,41 @@
import Search from "$lib/components/Search.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import { resolveSearchScope, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
import type { MediaItem } from "$lib/api/types";
let searchQuery = $state("");
// `?q=` / `?scope=` seed the page so the desktop header search bar can hand
// a query over by navigating here — /search is the only surface that renders
// results, so every other search affordance routes into it.
// TRACES: UR-049 | DR-063
const initialQuery = $page.url.searchParams.get("q") ?? "";
const initialScope = $page.url.searchParams.get("scope");
let searchQuery = $state(initialQuery);
// Route resolves the *initial* scope only. Deriving it reactively would snap
// a user who widened to All back to the route's scope on any navigation.
// TRACES: UR-049 | DR-064
let scope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
let scope = $state<SearchScope>(
SEARCH_SCOPES.includes(initialScope as SearchScope)
? (initialScope as SearchScope)
: resolveSearchScope($page.url.pathname)
);
// A query arriving in the URL must actually run — mounting with a seeded
// input alone would render the empty state with a filled box.
$effect(() => {
const q = $page.url.searchParams.get("q") ?? "";
if (!q.trim()) return;
const urlScope = $page.url.searchParams.get("scope");
const nextScope = SEARCH_SCOPES.includes(urlScope as SearchScope)
? (urlScope as SearchScope)
: "all";
if (q === $library.searchQuery && nextScope === scope) return;
searchQuery = q;
scope = nextScope;
library.search(q, nextScope);
});
async function handleSearch(query: string) {
if (query.trim()) {
+170 -5
View File
@@ -1,10 +1,11 @@
<!-- TRACES: UR-023, UR-029, UR-057 | DR-048, DR-077, DR-086 -->
<!-- TRACES: UR-023, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086 -->
<script lang="ts">
import { onMount } from "svelte";
import { commands } from "$lib/api/bindings";
import type {
AudioSettings,
CacheConfig,
EqPreset,
VideoSettings,
VolumeLevel,
} from "$lib/api/bindings";
@@ -39,8 +40,21 @@
gaplessPlayback: true,
normalizeVolume: false,
volumeLevel: "normal",
equalizerEnabled: false,
equalizerBands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
// Equalizer band centre-frequency labels (must match Rust EQ_BANDS order).
// Presentation only — the gain curves themselves come from the backend.
const EQ_BAND_LABELS = ["31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"];
const EQ_GAIN_MIN = -12;
const EQ_GAIN_MAX = 12;
// Preset name → gain curve, fetched from the backend (domain data lives in Rust).
let eqPresets = $state<[EqPreset, number[]][]>([]);
// Non-optional view of the bands for template bindings (the wire type marks
// equalizerBands optional via serde default; loadSettings guarantees it dense).
const eqBands = $derived(settings.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
let videoSettings = $state<VideoSettings>({
autoPlayNextEpisode: true,
autoPlayCountdownSeconds: 10,
@@ -86,14 +100,21 @@
try {
loading = true;
networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult] = await Promise.all([
const [audioResult, videoResult, cacheResult, presets] = await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
commands.playerGetEqPresets(),
]);
settings = audioResult;
// equalizerBands is optional on the wire (serde default); guarantee a
// dense 10-band array so the slider bindings are never undefined.
settings = {
...audioResult,
equalizerBands: audioResult.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
videoSettings = videoResult;
cacheConfig = cacheResult;
eqPresets = presets;
// Load cache stats in parallel but don't block on it
loadCacheStats();
} catch (e) {
@@ -210,6 +231,59 @@
persistAudio();
}
// --- Equalizer (UR-027) ---
function handleEqToggle() {
settings.equalizerEnabled = !settings.equalizerEnabled;
persistAudio();
}
// Apply a preset's gain curve (from the backend) to the bands.
function handleEqPreset(gains: number[]) {
settings.equalizerBands = [...gains];
persistAudio();
}
// Live-update a single band while dragging; persist on release (change).
function handleEqBandInput(index: number, e: Event) {
const target = e.target as HTMLInputElement;
const bands = [...eqBands];
bands[index] = parseFloat(target.value);
settings.equalizerBands = bands;
}
function handleEqBandChange(index: number, e: Event) {
const target = e.target as HTMLInputElement;
const bands = [...eqBands];
bands[index] = parseFloat(target.value);
settings.equalizerBands = bands;
persistAudio();
}
// The name of the preset whose curve matches the current bands, or null
// ("Custom"). Presentation-only label — the backend defines the curves.
const activeEqPreset = $derived.by<EqPreset | null>(() => {
const eq = eqBands;
for (const [name, gains] of eqPresets) {
if (gains.length === eq.length && gains.every((g, i) => g === eq[i])) {
return name;
}
}
return null;
});
// Human labels for preset chips.
const EQ_PRESET_LABELS: Record<EqPreset, string> = {
flat: "Flat",
rock: "Rock",
pop: "Pop",
jazz: "Jazz",
classical: "Classical",
bassBoost: "Bass Boost",
trebleBoost: "Treble Boost",
vocal: "Vocal",
};
function handleAutoPlayToggle() {
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
persistVideo();
@@ -419,6 +493,84 @@
{/if}
</div>
<!-- Equalizer (UR-027) -->
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white">Equalizer</h2>
<p class="text-sm text-gray-400 mt-1">
Shape the sound with presets or custom bands (Linux)
</p>
</div>
<button
onclick={handleEqToggle}
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.equalizerEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle equalizer"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.equalizerEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
{#if settings.equalizerEnabled}
<!-- Preset chips -->
<div class="pt-4 border-t border-gray-700">
<p class="text-sm font-medium text-gray-300 mb-3">Presets</p>
<div class="flex flex-wrap gap-2">
{#each eqPresets as [name, gains] (name)}
<button
onclick={() => handleEqPreset(gains)}
class="px-3 py-1.5 rounded-full text-sm transition-all {activeEqPreset ===
name
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
{EQ_PRESET_LABELS[name]}
</button>
{/each}
{#if activeEqPreset === null}
<span
class="px-3 py-1.5 rounded-full text-sm bg-[var(--color-jellyfin)] text-white"
>
Custom
</span>
{/if}
</div>
</div>
<!-- Band sliders -->
<div class="pt-4 border-t border-gray-700">
<p class="text-sm font-medium text-gray-300 mb-4">Bands (dB)</p>
<div class="flex justify-between gap-1 sm:gap-2">
{#each EQ_BAND_LABELS as label, i (label)}
<div class="flex flex-col items-center gap-2 flex-1 min-w-0">
<span class="text-xs text-gray-400 tabular-nums">
{eqBands[i] > 0 ? "+" : ""}{eqBands[i]}
</span>
<input
type="range"
min={EQ_GAIN_MIN}
max={EQ_GAIN_MAX}
step="1"
value={eqBands[i]}
oninput={(e) => handleEqBandInput(i, e)}
onchange={(e) => handleEqBandChange(i, e)}
class="eq-slider"
aria-label="{label} Hz gain"
/>
<span class="text-xs text-gray-500">{label}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Video Playback Settings -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Video Playback</h2>
@@ -746,8 +898,8 @@
album playback
</li>
<li>
<strong>Normalization</strong> uses ReplayGain tags and real-time
loudnorm filtering
<strong>Normalization</strong> evens out loudness between tracks
in real time, toward your selected level
</li>
</ul>
</div>
@@ -756,3 +908,16 @@
</div>
{/if}
</div>
<style>
/* Vertical EQ band sliders. `appearance: slider-vertical` is deprecated;
use writing-mode which is the supported path in modern WebKit/Chromium. */
.eq-slider {
writing-mode: vertical-lr;
direction: rtl;
width: 8px;
height: 96px;
accent-color: var(--color-jellyfin);
cursor: pointer;
}
</style>