Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9350c949 | ||
|
|
d01c1216b8 | ||
|
|
fb967433f0 | ||
|
|
ee584aced2 | ||
|
|
eb76c96e94 | ||
|
|
c3ead64748 | ||
|
|
742ad88a29 | ||
|
|
d4e2cd120c | ||
|
|
c543f90ad3 | ||
|
|
589f08b873 | ||
|
|
e2c9d68311 | ||
|
|
57b24f8c74 | ||
|
|
6391720d23 | ||
|
|
90f03dd142 |
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+39
-19
@@ -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,14 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +101,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 +187,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 |
|
||||
@@ -227,16 +229,19 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -303,6 +308,7 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -374,10 +380,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; 2–3 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 +416,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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
+1029
-568
File diff suppressed because it is too large
Load Diff
+38
-3
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
Executable
+36
@@ -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
|
||||
Executable
+44
@@ -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
|
||||
Executable
+69
@@ -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
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+19
-3
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -332,6 +332,7 @@ mod tests {
|
||||
gapless_playback: false,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
|
||||
@@ -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
@@ -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(¤t).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(¤t).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(¤t).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(¤t).await {
|
||||
let repo = self.repository.lock_safe().clone();
|
||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.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 ¤t.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]
|
||||
|
||||
@@ -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 0–1), 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));
|
||||
}
|
||||
}
|
||||
@@ -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!()
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
"#;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.1.1",
|
||||
"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
@@ -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
|
||||
*
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,11 +20,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 +1178,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 +1233,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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user