Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984e594006 | ||
|
|
f49e6e4648 | ||
|
|
105cc082ea | ||
|
|
0a3ee0791f | ||
|
|
0da0a9f16c | ||
|
|
75bae2556c | ||
|
|
48f63dd763 | ||
|
|
36ef231e2f | ||
|
|
cb79a376b3 | ||
|
|
b11188e9dd | ||
|
|
f636b6b151 | ||
|
|
37ffabee06 | ||
|
|
13e0860401 | ||
|
|
d1c01a6bc3 | ||
|
|
e5d3cc06f2 | ||
|
|
5759a97289 | ||
|
|
b9f026e215 | ||
|
|
b7a7037194 | ||
|
|
124da29fc7 | ||
|
|
5927299c0f | ||
|
|
7650efcb7f | ||
|
|
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 \
|
||||
|
||||
@@ -42,30 +42,45 @@ jobs:
|
||||
echo "📊 Validating requirement traceability..."
|
||||
echo ""
|
||||
|
||||
# Parse JSON
|
||||
# Denominators come from docs/requirements.md at run time — NEVER
|
||||
# hardcode them here. This step previously divided by frozen literals
|
||||
# (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to
|
||||
# 211 requirements, so it reported 158% coverage and the threshold
|
||||
# below could never trip. See docs/specs/traceability-gate-repair.md.
|
||||
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
|
||||
UR=$(jq '.byType.UR | length' traces-report.json)
|
||||
IR=$(jq '.byType.IR | length' traces-report.json)
|
||||
DR=$(jq '.byType.DR | length' traces-report.json)
|
||||
JA=$(jq '.byType.JA | length' traces-report.json)
|
||||
COVERED=$(jq '.coverage.covered' traces-report.json)
|
||||
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
||||
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
||||
|
||||
# Print coverage report
|
||||
echo "✅ TRACES Found: $TOTAL_TRACES"
|
||||
echo ""
|
||||
echo "📋 Coverage Summary:"
|
||||
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
|
||||
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
|
||||
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
|
||||
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
|
||||
echo "📋 Coverage Summary (traced / defined):"
|
||||
for T in UR IR DR JA; do
|
||||
TRACED=$(jq --arg t "$T" '[.byType[$t][] | select(. != null)] | length' traces-report.json)
|
||||
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
||||
echo " $T: $TRACED / $DEFINED"
|
||||
done
|
||||
echo ""
|
||||
|
||||
COVERED=$((UR + IR + DR + JA))
|
||||
TOTAL_REQS=114
|
||||
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
|
||||
|
||||
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
||||
echo ""
|
||||
|
||||
# Traced IDs that requirements.md does not define (typo, or a deleted
|
||||
# requirement). These do not count toward coverage.
|
||||
ORPHANED=$(jq -c '.coverage.orphaned' traces-report.json)
|
||||
if [ "$ORPHANED" != "[]" ]; then
|
||||
echo "⚠️ Traced but not defined in requirements.md: $ORPHANED"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# A ratio above 100% means the computation is broken — the exact
|
||||
# condition that hid the stale-denominator bug. Fail loudly.
|
||||
if [ "$COVERAGE" -gt 100 ]; then
|
||||
echo "❌ ERROR: Coverage ($COVERAGE%) exceeds 100% — the gate is miscomputing."
|
||||
echo " Orphaned IDs: $ORPHANED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check minimum threshold
|
||||
MIN_THRESHOLD=50
|
||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||
|
||||
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
|
||||
/docs/README.md
|
||||
/docs/api-redirect.md
|
||||
/docs-site/book/
|
||||
|
||||
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
|
||||
/.cargo-arch/
|
||||
/packaging/arch/pkg/
|
||||
/packaging/arch/src/
|
||||
/packaging/arch/*.pkg.tar.zst
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to JellyTau are documented here.
|
||||
|
||||
Entries are grouped by the capability they change, not by commit. Requirement
|
||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Audio settings now work on Android.** The equalizer, volume normalization
|
||||
and gapless playback controls in Settings › Audio previously rendered on
|
||||
Android and did nothing — `ExoPlayerBackend` was the only backend that never
|
||||
implemented `set_audio_settings`, and the trait's default silently reported
|
||||
success while applying nothing. All three now take effect:
|
||||
- **Equalizer** — the canonical 10-band ISO curve is resampled onto whatever
|
||||
bands the device's equalizer actually exposes (commonly 5), by nearest
|
||||
centre frequency.
|
||||
- **Volume normalization** — via `LoudnessEnhancer`. Note this is a gain
|
||||
stage, not a true EBU R128 normalizer like the Linux `dynaudnorm` path, so
|
||||
it approximates rather than matches Linux behaviour.
|
||||
- **Gapless playback** — honours the setting via `pauseAtEndOfMediaItems`
|
||||
(ExoPlayer is gapless by default, so this disables it when you turn it off).
|
||||
|
||||
The effects re-attach automatically when ExoPlayer rebuilds its audio sink on
|
||||
a format change, so the equalizer no longer stops applying part-way through a
|
||||
queue. (UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036, IR-004)
|
||||
|
||||
⚠️ **Not yet verified on a physical device.** `AudioEffect` availability and
|
||||
band layouts vary by device and OEM ROM; where an effect is unavailable it is
|
||||
logged and skipped rather than crashing playback.
|
||||
|
||||
### 📋 Documentation
|
||||
|
||||
- **Playback backend unification investigation.** Six new specs in
|
||||
[docs/specs/](docs/specs/) record why the playback backends cannot be unified
|
||||
onto a single engine: every candidate (mpv, GStreamer, libVLC) fails the same
|
||||
webview-compositing constraint, because WebKitGTK/WebView2/Android WebView each
|
||||
own their compositor surface and native video cannot interleave with HTML.
|
||||
Audio *can* unify; video cannot. Also specifies the Android native-video spike,
|
||||
a Windows native audio backend, and the `libmpv2` migration.
|
||||
|
||||
### 🐛 Corrected requirement statuses
|
||||
|
||||
These were documented as working and were not. No behaviour changed — the docs
|
||||
were wrong.
|
||||
|
||||
- **Crossfade (UR-031, DR-034) was marked "Done (Linux only)". It is implemented
|
||||
nowhere**, and is architecturally blocked on mpv: its audio chain is
|
||||
single-stream, and FFmpeg's `acrossfade` requires two inputs. Real crossfade
|
||||
would need two libmpv instances.
|
||||
- The platform parity matrix listed crossfade as a Linux/Android gap (it is
|
||||
neither) and omitted the equalizer (which was a genuine gap, now closed).
|
||||
- `nativeAdapter.ts` cited tauri#10152 as blocking native Android video. That
|
||||
issue is a stale feature request; the capability shipped in September 2024.
|
||||
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
||||
by a spec rather than asserted as an upstream blocker.
|
||||
|
||||
<!--
|
||||
Note: v0.1.3–v0.1.5 have no entries here. Their changes are in the git log
|
||||
and docs/traceability.md.
|
||||
-->
|
||||
|
||||
## v0.1.2
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Search results are ordered by how well they match.** A name that *starts*
|
||||
with the query now outranks one matching mid-word — typing "parks" finds
|
||||
"Parks and Recreation" before "Sparks of Love" — and at equal match quality a
|
||||
container outranks its contents, so a series lands above its own episodes.
|
||||
Ranking is applied to the instant cached results and to the merged
|
||||
cache+server list alike, so the list no longer reshuffles when server results
|
||||
arrive. (UR-060, DR-090)
|
||||
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
|
||||
group splits into Shows and Episodes so a show never competes with its own
|
||||
episodes for a slot, and a new People group means searching an actor's name
|
||||
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
|
||||
Albums → Artists → People; a group order saved before the split keeps the
|
||||
position it was dragged to. (UR-060, DR-091)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **The library header search bar works on every library page.** It previously
|
||||
searched in place and depended on `/library` rendering results inline, so on
|
||||
any other `/library/**` route the results were fetched and never shown.
|
||||
`/search` is now the single surface that renders results, and the header bar
|
||||
hands its query and scope over via the URL. (UR-049, DR-063)
|
||||
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
|
||||
oversized media, so a 480p source on a 1080p display played as a small picture
|
||||
in the middle of a black frame. The picture now fits whichever axis constrains
|
||||
it, in both directions, preserving aspect ratio. (UR-005)
|
||||
|
||||
### 📋 Requirements
|
||||
|
||||
**Linux:** 64-bit, GLIBC 2.29+
|
||||
**Android:** 8.0+
|
||||
|
||||
## v0.1.1 and earlier
|
||||
|
||||
Released before this file existed — see the git history and the release notes on
|
||||
each tag.
|
||||
@@ -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.
|
||||
@@ -169,8 +183,14 @@ and [docs/build-release.md](docs/build-release.md).
|
||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
||||
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from.
|
||||
assignment. The canonical example lives in Rust:
|
||||
`SearchScope::item_types()` in `repository/types.rs` expands an opaque scope the
|
||||
frontend sends. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from — note the tripwire missed that leak for
|
||||
months because the mapping was assigned to a named const rather than written
|
||||
inline at the query, so **a green `check:boundary` is not proof**; it flags
|
||||
item-type array literals only, not run-time-built sets or `switch`/`||`
|
||||
taxonomy.
|
||||
|
||||
## Writing specs
|
||||
|
||||
@@ -252,6 +272,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
|
||||
|
||||
+35
@@ -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,34 @@ 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, cargo-xwin + nsis + the x86_64-pc-windows-msvc rust
|
||||
# target for Windows). ONE source of dependency truth, shared with CI — no
|
||||
# per-stage apt/rustup here.
|
||||
#
|
||||
# NOTE: Windows uses the MSVC target via cargo-xwin, NOT mingw/GNU — the GNU
|
||||
# toolchain cannot bundle an NSIS installer from Linux. See
|
||||
# scripts/build-windows-cross.sh.
|
||||
|
||||
# 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.
|
||||
+81
-33
@@ -37,11 +37,11 @@ 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 |
|
||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||
| UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034) |
|
||||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||||
@@ -62,12 +62,16 @@ For a narrative overview of the system design, see
|
||||
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Done |
|
||||
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +103,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,11 +189,11 @@ 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 |
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md) |
|
||||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
||||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
||||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||||
@@ -216,10 +220,10 @@ Internal architecture, components, and application logic.
|
||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||
| DR-063 | Search scope taxonomy owned by Rust: `SearchScope` (All / Music / Movies / TV) crosses IPC as an opaque enum and `SearchScope::item_types()` expands it to Jellyfin item types, resolved once in `repository_search` before the cache and server paths diverge so online and offline filter identically; `All` expands to *no* filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (`resolveSearchScope`, presentation) and never names an item type for search | Backend | UR-049 | Implemented |
|
||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||
@@ -227,16 +231,24 @@ Internal architecture, components, and application logic.
|
||||
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Done |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Done |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Done |
|
||||
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Done |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Done |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Done |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -303,6 +315,9 @@ Internal architecture, components, and application logic.
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087 |
|
||||
| UR-060 | - | DR-090, DR-091 |
|
||||
| UR-061 | - | DR-092 |
|
||||
|
||||
---
|
||||
|
||||
@@ -373,11 +388,30 @@ Internal architecture, components, and application logic.
|
||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||
| 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-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
|
||||
| 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 |
|
||||
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
|
||||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done |
|
||||
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done |
|
||||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -396,8 +430,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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -467,22 +501,36 @@ The `PlayerBackend` trait defines optional audio settings methods with default e
|
||||
| Basic playback | ✅ | ✅ | Parity |
|
||||
| Volume control | ✅ | ✅ | Parity |
|
||||
| Seek | ✅ | ✅ | Parity |
|
||||
| Crossfade | ✅ | ❌ | Gap |
|
||||
| Gapless playback | ✅ | ❌ | Gap |
|
||||
| Volume normalization | ✅ | ❌ | Gap |
|
||||
| Crossfade | ❌ | ❌ | Not implemented (blocked on MPV) |
|
||||
| Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification |
|
||||
| Volume normalization | ✅ | ⚠️ | Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification |
|
||||
| Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification |
|
||||
| Position updates | 250ms | On-demand | Inconsistent |
|
||||
|
||||
**Future Fix**:
|
||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
||||
5. Standardize position update frequency across platforms
|
||||
**Status** (see docs/specs/android-audio-settings-parity.md):
|
||||
1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
|
||||
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
|
||||
3. ✅ Volume normalization via `LoudnessEnhancer`
|
||||
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands
|
||||
resampled onto the device's band centres
|
||||
5. ⬜ **Not yet verified on a physical device** — the EQ/normalization effects
|
||||
depend on device-specific `AudioEffect` availability and band layouts
|
||||
6. ⬜ Flip the trait's `set_audio_settings` default from `Ok(())` to
|
||||
`Err(not_implemented())` so a backend that omits it fails loudly instead of
|
||||
silently reporting success. Deferred until (5) confirms the Android path works
|
||||
7. ⬜ Standardize position update frequency across platforms
|
||||
|
||||
Crossfade is deliberately absent: it is unimplemented on every platform and
|
||||
architecturally blocked on MPV, so building it on Android alone would invert the
|
||||
parity gap. (The previously suggested `ConcatenatingMediaSource` is also
|
||||
deprecated in current Media3.)
|
||||
|
||||
**Impact**:
|
||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||
- User experience differs between platforms
|
||||
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
||||
- UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now
|
||||
implemented on Android as well as Linux, pending on-device verification
|
||||
- UR-031 (Crossfade) works nowhere — see DR-034
|
||||
|
||||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Spec: Android audio settings parity (EQ, normalization, gapless)
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-031, UR-032, UR-033, UR-027 → DR-034, DR-035, DR-036, DR-030; IR-004
|
||||
**UX spec:** n/a — no UI change; Settings › Audio already renders these controls
|
||||
**Supersedes / revises:** closes the audio half of the parity gap recorded in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `set_audio_settings` / `audio_settings` on `ExoPlayerBackend` so the
|
||||
equalizer, volume normalization, and gapless playback settings actually take
|
||||
effect on Android. Today the Settings › Audio panel renders these controls on
|
||||
Android and they silently do nothing — `ExoPlayerBackend` is the only backend
|
||||
that does not override the trait's no-op defaults.
|
||||
|
||||
Crossfade is explicitly **not** included; see Out of scope.
|
||||
|
||||
## Motivation
|
||||
|
||||
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body.
|
||||
`MpvBackend`, `NullBackend`, and `WebviewAudioBackend` all override it;
|
||||
`ExoPlayerBackend` does not. The settings are persisted, pushed to the backend on
|
||||
every track load, and displayed in the UI — and then dropped on the floor.
|
||||
|
||||
This is the single most user-visible platform divergence in the app: a user who
|
||||
sets a "Rock" EQ preset on Android sees the sliders move and hears no change.
|
||||
|
||||
The backend-unification investigation ruled out fixing this by swapping engines
|
||||
(video cannot be unified; see the sibling spec), so the fix is to implement the
|
||||
trait methods where they are missing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Band count, centre frequencies, gain range, preset→curve map | Rust (existing) | Already domain-owned in `settings.rs` per [audio-equalizer.md](audio-equalizer.md). Android must consume the same `AudioSettings`, not define its own bands. Duplicating the band layout in Kotlin would be a taxonomy leak of exactly the kind `check:boundary` guards against. |
|
||||
| Mapping `AudioSettings` → Android audio-effect parameters | Rust → JNI boundary | Platform playback detail, the direct analogue of `build_af_filter` in `mpv_backend.rs`. Belongs with the other `set_audio_settings` code. |
|
||||
| Attaching/detaching `Equalizer` and `LoudnessEnhancer` to the ExoPlayer audio session | Kotlin (`JellyTauPlayer.kt`) | Android platform API mechanics; needs the live `audioSessionId`, which only the Kotlin layer holds. |
|
||||
| Normalization preset (Loud/Normal/Quiet) → target gain | Rust (existing) | `VolumeLevel` is domain vocabulary; the same preset must mean the same loudness on every platform. |
|
||||
| Rendering sliders / preset chips | Frontend (existing) | Pure presentation; unchanged by this spec. |
|
||||
|
||||
Borderline row: attaching the effects could arguably be driven entirely from
|
||||
Rust via JNI property calls. It goes to Kotlin because `AudioEffect` construction
|
||||
requires the audio session id and must be re-attached when ExoPlayer rebuilds its
|
||||
audio sink — lifecycle state that lives in `JellyTauPlayer.kt`. Rust still owns
|
||||
*what* the values are; Kotlin owns *when* the effect objects exist.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust — `ExoPlayerBackend` (`src-tauri/src/player/android/mod.rs`)
|
||||
|
||||
Override the two defaulted methods, mirroring the shape of the existing
|
||||
`set_audio_track` JNI call:
|
||||
|
||||
```rust
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let s = settings.clone().with_crossfade_clamped().with_equalizer_normalised();
|
||||
// Serialize as JSON — the same pattern load() already uses for subtitles,
|
||||
// avoiding a 6-arg JNI signature that has to change every time a field lands.
|
||||
let json = serde_json::to_string(&s).map_err(|e| PlayerError { message: e.to_string() })?;
|
||||
// Kotlin: fun setAudioSettings(json: String)
|
||||
self.call_player_method_string("setAudioSettings", &json)?;
|
||||
self.shared_state.lock_safe().audio_settings = s;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.shared_state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
```
|
||||
|
||||
`ExoPlayerState` gains an `audio_settings: AudioSettings` field. Note
|
||||
`ExoPlayerBackend` currently holds no such state — `position`/`state`/`volume` are
|
||||
all pushed in by JNI callbacks — so this is the first *pull*-side field. That is
|
||||
correct: audio settings are commanded downward, never reported upward.
|
||||
|
||||
### Kotlin — `JellyTauPlayer.kt`
|
||||
|
||||
```kotlin
|
||||
fun setAudioSettings(json: String) {
|
||||
val s = JSONObject(json)
|
||||
applyEqualizer(s.getBoolean("equalizerEnabled"), s.getJSONArray("equalizerBands"))
|
||||
applyNormalization(s.getBoolean("normalizeVolume"), s.getString("volumeLevel"))
|
||||
exoPlayer.pauseAtEndOfMediaItems = !s.getBoolean("gaplessPlayback")
|
||||
}
|
||||
```
|
||||
|
||||
Three independent mechanisms:
|
||||
|
||||
- **Gapless** — nearly free. ExoPlayer is gapless by default for compatible
|
||||
formats; honouring the setting means *disabling* it when the user turns it off,
|
||||
via `pauseAtEndOfMediaItems`. Note this only applies within a loaded playlist;
|
||||
our queue loads one item at a time, so verify behaviour before claiming DR-035
|
||||
on Android (see Testing).
|
||||
- **Equalizer** — `android.media.audiofx.Equalizer` bound to
|
||||
`exoPlayer.audioSessionId`. Android's EQ exposes a device-dependent band count
|
||||
(commonly 5) at fixed centre frequencies, which will **not** match our 10-band
|
||||
ISO layout. Rust owns the canonical 10 bands; Kotlin resamples them onto the
|
||||
device's bands by nearest-centre-frequency interpolation. Gains are in
|
||||
millibels (`setBandLevel` takes mB, we store dB → ×100), clamped to the
|
||||
device's reported `getBandLevelRange()`.
|
||||
- **Normalization** — `android.media.audiofx.LoudnessEnhancer`, also bound to the
|
||||
audio session, `setTargetGain(mB)` derived from `VolumeLevel`. This is a gain
|
||||
booster, not a true EBU R128 normalizer like MPV's `dynaudnorm`; parity is
|
||||
approximate and should be documented as such rather than overclaimed.
|
||||
|
||||
Lifecycle: build the effects lazily on first use, release them in `release()`,
|
||||
and re-attach on `onAudioSessionIdChanged` — ExoPlayer can rebuild its audio sink
|
||||
(e.g. on a format change), which invalidates effects bound to the old session.
|
||||
|
||||
### Make the silent-failure mode impossible
|
||||
|
||||
The trait's default is the root cause of this whole class of bug:
|
||||
|
||||
```rust
|
||||
// backend.rs:85 — reports success while doing nothing
|
||||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Android inherits this, so every EQ/normalization change on Android returns `Ok`
|
||||
and silently does nothing — the UI ships and has no effect, with no error anywhere.
|
||||
|
||||
Once `ExoPlayerBackend` implements the methods, **change the trait default to
|
||||
`Err(PlayerError::not_implemented())`**, matching how `set_audio_track` /
|
||||
`set_subtitle_track` already behave. Any future backend that forgets to implement
|
||||
audio settings then fails loudly instead of lying.
|
||||
|
||||
Check the call sites before flipping it: `NullBackend` overrides both methods, so
|
||||
the graceful-degradation path is unaffected, but confirm nothing treats a
|
||||
`set_audio_settings` error as fatal to playback.
|
||||
|
||||
### Re-application on track load
|
||||
|
||||
`PlayerController` already re-pushes `AudioSettings` per track on the platforms
|
||||
that implement it; the Android path inherits that for free once the trait methods
|
||||
exist. No controller change.
|
||||
|
||||
### 🔴 Threading note
|
||||
|
||||
`setAudioSettings` is invoked from Rust on whatever thread the command lands on.
|
||||
`AudioEffect` construction must not happen on the ExoPlayer application thread
|
||||
from inside a player callback — that is the re-entrancy hazard CLAUDE.md warns
|
||||
about, and the same shape as the `AutoplayDecision` deadlock. Post the work to
|
||||
the player's handler rather than doing it inline in a listener.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Crossfade (UR-031 / DR-034).** Not implemented on *any* platform today, and
|
||||
architecturally blocked on MPV (single-stream audio chain; `acrossfade` needs
|
||||
two inputs). Implementing it on Android alone would invert the parity gap. It
|
||||
needs its own spec and probably two player instances.
|
||||
- True EBU R128 normalization. `LoudnessEnhancer` is a gain stage; matching
|
||||
`dynaudnorm` exactly is out of reach without a custom `AudioProcessor`.
|
||||
- Windows audio settings — see [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `ExoPlayerBackend` overrides `set_audio_settings` and `audio_settings`.
|
||||
- [ ] EQ preset change on Android audibly changes playback; setting persists across track changes and app restart.
|
||||
- [ ] Normalization toggle audibly changes level; the three presets are ordered Loud > Normal > Quiet.
|
||||
- [ ] Disabling gapless produces a gap between consecutive tracks; enabling it does not.
|
||||
- [ ] Effects are released on `release()` and survive an audio-session rebuild.
|
||||
- [ ] `requirements.md` parity matrix updated: EQ and normalization ✅ Android.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): `set_audio_settings` stores the sanitized settings and
|
||||
`audio_settings()` returns them — assert clamping/normalisation is applied
|
||||
(crossfade clamped to 12s, band vector normalised to `EQ_BANDS.len()`). The JNI
|
||||
call itself is not unit-testable; extract the JSON serialization into a pure
|
||||
function and test that its shape matches what the Kotlin parser expects. That
|
||||
serialization contract is the part most likely to silently break.
|
||||
|
||||
**Kotlin**: the band-resampling function (10 canonical bands → N device bands) is
|
||||
pure arithmetic — extract it and unit-test it, including the degenerate cases of
|
||||
a 5-band device and a device reporting 10 bands.
|
||||
|
||||
**Manual, on device** (these are the ones that actually prove it):
|
||||
1. Set Bass Boost, play a track, confirm audible change.
|
||||
2. Toggle normalization mid-track; confirm level change without a playback stall.
|
||||
3. Queue two gapless-encoded tracks, toggle the setting, confirm the gap appears/disappears.
|
||||
4. Force a format change (44.1kHz → 48kHz track) and confirm the EQ still applies afterwards — this exercises the session-rebuild re-attach.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `ExoPlayerBackend::set_audio_settings` → `// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
||||
- Kotlin `setAudioSettings` / `applyEqualizer` / `applyNormalization` → same IDs
|
||||
- Band-resampling helper + its tests → `DR-030 | UT-xxx`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [audio-equalizer.md](audio-equalizer.md) first — it defines the canonical
|
||||
band layout and the preset→curve rule this spec consumes. Do not redefine bands
|
||||
in Kotlin.
|
||||
- Android source edits go in `src-tauri/android/src` (canonical tree), then run
|
||||
`scripts/sync-android-sources.sh`. Never edit the `gen/` tree.
|
||||
- There is a **stale duplicate** `JellyTauPlayer.kt` (285 lines) at
|
||||
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/` alongside
|
||||
the real 1103-line file at `src-tauri/android/src/main/java/...`. Edit the
|
||||
latter. Consider deleting the former as a separate change.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Spec: Android native video — transparent-webview spike
|
||||
|
||||
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
|
||||
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
|
||||
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
|
||||
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Test whether ExoPlayer's existing `SurfaceView` video path can be composited
|
||||
behind a transparent Tauri WebView on Android. If it works, Android regains
|
||||
hardware video decoding (MediaCodec) and libass-quality ASS/SSA subtitles, both
|
||||
of which the current webview path lacks. If it does not, we document why and
|
||||
delete the dead code.
|
||||
|
||||
This is a **spike**, not a feature commitment. The deliverable is a yes/no answer
|
||||
with evidence, plus either a working path behind a flag or a removal.
|
||||
|
||||
## Motivation
|
||||
|
||||
`createAdapter()` hardcodes `const effectiveKind = "html5"` and does
|
||||
`void backendKind`, discarding the `use_html5_element` value Rust computes in
|
||||
`get_player_status`. As a result:
|
||||
|
||||
- `NativePlayerAdapter` is dead code.
|
||||
- `JellyTauPlayer.kt`'s `getOrCreateSurfaceView()` — which already calls
|
||||
`setZOrderMediaOverlay(false)` and wires `setVideoSurfaceHolder` — is
|
||||
unreachable.
|
||||
- Android video decodes in the WebView instead of via MediaCodec, despite
|
||||
`CodecDetector.kt` going to the trouble of reporting hardware codec
|
||||
capabilities back to Rust for DeviceProfile generation.
|
||||
|
||||
The code comment in `nativeAdapter.ts:11-14` justifies this by citing
|
||||
tauri#10152 as an upstream blocker. **That justification is stale.**
|
||||
|
||||
### Why the blocker no longer holds
|
||||
|
||||
- tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature
|
||||
request* ("Support transparent webviews on mobile"), not a bug report about
|
||||
compositing.
|
||||
- The capability shipped in tauri commit `27d01834` (2024-09-02) — a clippy
|
||||
cleanup that moved `transparent()` out of the desktop-gated impl block, fencing
|
||||
only the tao call behind `#[cfg(desktop)]`. Because it landed as unrelated
|
||||
cleanup, nobody closed the issue.
|
||||
- The black/white-screen reports (tauri#8381, tauri#9408) were a real but
|
||||
*different* bug: a broken JNI signature for `setBackgroundColor`, fixed in
|
||||
**wry 0.39.4** (PR #1237). We ship wry 0.55.x.
|
||||
- Current wry calls `setBackgroundColor(0)` unconditionally on Android when
|
||||
transparency is requested.
|
||||
|
||||
### The honest caveat
|
||||
|
||||
**Nobody has demonstrated SurfaceView-behind-WebView on Tauri Android.** A search
|
||||
of both `tauri-apps/tauri` and `tauri-apps/wry` issues for `surfaceview` returns
|
||||
zero results, and the one native-video Tauri plugin
|
||||
(`YeonV/tauri-plugin-videoplayer`) sidesteps compositing by launching a separate
|
||||
fullscreen Activity. Nothing upstream blocks this; nothing upstream proves it.
|
||||
Hence: spike, not feature.
|
||||
|
||||
Note this is the *Android* question only. The equivalent Linux compositing
|
||||
problem is maintainer-declared unfixable and is **not** in scope — see the
|
||||
unification spec.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which video backend this platform uses | Rust (existing) | `get_player_status` already computes `use_html5_element`. The frontend must *consume* it, not decide it. Restoring that is the point of the spike. |
|
||||
| Surface creation, z-ordering, `setVideoSurfaceHolder` lifecycle | Kotlin | Android platform mechanics; already written in `JellyTauPlayer.kt`. |
|
||||
| Seek/audio-track *strategy* | Rust (existing) | Already returned by `player_seek_video` / `player_switch_audio_track`; `NativePlayerAdapter` executes the chosen primitive. Unchanged — this is exactly what the `PlayerAdapter` contract was built for. |
|
||||
| Positioning the surface under the video viewport | Frontend | Pure presentation/layout. **This is the risk area** — see Design. |
|
||||
|
||||
## Design
|
||||
|
||||
### Phase 1 — prove compositing (no app changes)
|
||||
|
||||
Before touching the adapter factory, verify the primitive works at all:
|
||||
|
||||
1. Set `"transparent": true` in `tauri.conf.json` for the Android build, plus
|
||||
`html, body { background: transparent; }`.
|
||||
2. Confirm the WebView is genuinely transparent (a native view behind it is
|
||||
visible) and that the app does not regress to a black/white screen.
|
||||
|
||||
If this fails, stop — everything downstream is moot, and the finding is that
|
||||
Tauri Android transparency is still broken in practice despite the shipped fix.
|
||||
|
||||
### Phase 2 — un-hardcode the factory
|
||||
|
||||
```ts
|
||||
// src/lib/player/adapters/index.ts
|
||||
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
|
||||
return backendKind === "native"
|
||||
? new NativePlayerAdapter(host)
|
||||
: new Html5PlayerAdapter(host, bridge);
|
||||
}
|
||||
```
|
||||
|
||||
`backendKind` comes from `get_player_status` (`VideoBackend::Native` on Android).
|
||||
Gate behind a setting — `experimentalNativeVideo`, default **off** — so a broken
|
||||
spike cannot ship as a regression. Rust already owns this decision; the flag only
|
||||
suppresses it.
|
||||
|
||||
**Also in scope: remove the user-agent sniffing in
|
||||
`src/lib/services/webviewAudio.ts:30-41`.** It re-derives which audio backend the
|
||||
platform has from `navigator.userAgent` ("matching the Rust cfg gate", per its own
|
||||
comment) — the frontend deciding a backend fact it should be told. Same root cause
|
||||
as the hardcode above, same fix: consume the value Rust already computes. Fold it
|
||||
in here rather than leaving a second, subtler copy of the bug behind. If
|
||||
`get_player_status` does not currently expose enough to cover the audio case, add
|
||||
the field — that is backend work, and correct.
|
||||
|
||||
### Phase 3 — surface positioning
|
||||
|
||||
The hard part, and where this most likely fails. The webview's `<video>` element
|
||||
occupies a laid-out box; the `SurfaceView` must be positioned to match it, and
|
||||
kept matched through scroll, rotation, and mini-player transitions.
|
||||
|
||||
Approach: the video view reports its `getBoundingClientRect()` to Rust, which
|
||||
forwards the rect to Kotlin to position the `SurfaceView`. This is the same
|
||||
"faking it" technique the ecosystem uses on desktop — acceptable here *only if*
|
||||
the video is effectively fullscreen on Android, which it is in the player route.
|
||||
|
||||
**Explicit failure criterion**: if the surface cannot be kept aligned during
|
||||
rotation or the mini-player transition without visible artefacts, the spike fails
|
||||
and we keep HTML5. Do not ship a janky native path for a codec win.
|
||||
|
||||
### What we gain if it works
|
||||
|
||||
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
||||
capabilities; the DeviceProfile would finally match what actually plays.
|
||||
- **ASS/SSA subtitles** are *not* automatic. ExoPlayer cannot render them; that
|
||||
would require libmpv, which is a separate and much larger decision (see the
|
||||
unification spec's engine comparison). Scope this spike to hardware decode
|
||||
only, and do not claim subtitle improvements from it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Linux native video. Maintainer-declared unfixable on WebKitGTK/Wayland.
|
||||
- Replacing ExoPlayer with libmpv on Android.
|
||||
- Windows native video.
|
||||
- Removing the HTML5 path. It stays as the default and the fallback.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The spike is **complete** when one of these is true:
|
||||
|
||||
**Success path**
|
||||
- [ ] Transparent WebView confirmed working on a physical device.
|
||||
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
|
||||
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
|
||||
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
|
||||
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
|
||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
|
||||
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
|
||||
|
||||
**Failure path**
|
||||
- [ ] The blocking behaviour is documented in this spec with evidence.
|
||||
- [ ] `NativePlayerAdapter` and the unreachable `SurfaceView` code are deleted, or explicitly retained with a *correct* comment.
|
||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||
|
||||
Either way:
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
Adapter-selection logic is pure and testable without a device: assert
|
||||
`createAdapter` returns `NativePlayerAdapter` for `backendKind: "native"` with
|
||||
the flag on, and `Html5PlayerAdapter` in every other combination — including that
|
||||
the flag off forces HTML5 even when Rust says native. That last case is the
|
||||
regression guard.
|
||||
|
||||
Everything else is manual on-device; there is no meaningful way to unit-test
|
||||
surface compositing. Test on at least two devices — compositing behaviour varies
|
||||
by OEM and Android version.
|
||||
|
||||
Per CLAUDE.md, if the spike turns into a bug fix (e.g. seek breaks under the
|
||||
native adapter), write the failing test first.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-023, DR-024`
|
||||
- Adapter-selection tests → `UT-xxx`
|
||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Do not skip Phase 1.** If transparency does not work, phases 2 and 3 are
|
||||
wasted effort.
|
||||
- `VideoPlayer.svelte` has a documented hazard: no lifecycle calls after an
|
||||
`await` in `onMount` — it flips to HTML5 mode and breaks Android seek. The
|
||||
adapter swap touches exactly this code path.
|
||||
- tauri-specta tagged responses keep Rust field names (`new_url`, not `newUrl`).
|
||||
- Android source edits go in `src-tauri/android/src`, then run
|
||||
`scripts/sync-android-sources.sh`.
|
||||
- A parallel Claude session may be active — `git diff` first.
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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:** —
|
||||
**Revised by:** [android-audio-settings-parity.md](android-audio-settings-parity.md) — lifts the "Android is a no-op" limitation below.
|
||||
|
||||
## 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).
|
||||
**Now specified in [android-audio-settings-parity.md](android-audio-settings-parity.md)**,
|
||||
which implements `set_audio_settings` on `ExoPlayerBackend`. The canonical band
|
||||
layout and preset→curve map defined here remain authoritative; the Android side
|
||||
resamples those bands onto the device equalizer rather than defining its own.
|
||||
- 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.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Spec: Harden the frontend boundary tripwire
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** DR-094
|
||||
**UX spec:** n/a — developer tooling.
|
||||
**Supersedes / revises:** revises the detection rule in
|
||||
[scripts/check-frontend-boundary.sh](../../scripts/check-frontend-boundary.sh);
|
||||
the boundary *policy* in [scoped-search-boundary.md](scoped-search-boundary.md)
|
||||
is unchanged.
|
||||
|
||||
## Summary
|
||||
|
||||
`bun run check:boundary` passes on a tree that contains the exact leak it was
|
||||
built to catch. It matches a multi-type array only when written **inline at the
|
||||
query site**, so assigning the same array to a named const evades it entirely —
|
||||
which is how [searchScope.ts](../../src/lib/utils/searchScope.ts) has kept a
|
||||
category→item-type mapping through every green CI run. This spec broadens the
|
||||
match to item-type array literals anywhere in `src/`, and resolves the handful
|
||||
of legitimate hits that broadening surfaces.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current pattern is anchored to the `includeItemTypes:` key:
|
||||
|
||||
```sh
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
```
|
||||
|
||||
The live leak is not written that way:
|
||||
|
||||
```ts
|
||||
// src/lib/utils/searchScope.ts:29 — invisible to the tripwire
|
||||
const SCOPE_ITEM_TYPES = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], … };
|
||||
```
|
||||
|
||||
The taxonomy and the query are one indirection apart, and the grep only sees the
|
||||
query. The script's own header is admirably honest that it is "a TRIPWIRE, NOT A
|
||||
PROOF" — but the gap here is not a subtle judgment call it was designed to
|
||||
defer to human review. It is the *crudest form* of the violation, one `const`
|
||||
away from the shape it does match, in the very file the founding incident was
|
||||
written about.
|
||||
|
||||
Broadening the pattern to any item-type array literal finds it, with a
|
||||
manageable number of other hits (measured, not estimated):
|
||||
|
||||
| Site | Verdict |
|
||||
|------|---------|
|
||||
| `searchScope.ts:30,32` | 🔴 The leak. Removed by [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md). |
|
||||
| `PersonDetailView.svelte:30` | Already allowlisted, with a recorded reason. |
|
||||
| `DownloadedBrowse.svelte:95` | Borderline — `["MusicAlbum","Series","Season","BoxSet"].includes(item.type)` as an "is this a container?" predicate. |
|
||||
| `GenericMediaListPage.svelte:298` | Borderline — `["MusicAlbum","MusicArtist","Audio","Playlist"].includes(config.itemType)` as a music-styling predicate. |
|
||||
| 6 hits in `*.test.ts` | Excluded; tests legitimately name types. |
|
||||
|
||||
Four non-test sites total. This is a tractable change, not a boil-the-ocean one.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Tooling only — no application logic, nothing crosses IPC. The two borderline
|
||||
*application* sites do get a layer decision, below.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Detecting item-type array literals in `src/` | Build tooling (`scripts/`) | Static analysis of repo source; belongs beside the existing check. |
|
||||
| "Is this item a container?" (`DownloadedBrowse`) | **Rust** (recommended) | Containers-vs-leaves is Jellyfin structure, and the set grows when Jellyfin adds a container type — the litmus test's "yes". Prefer a `MediaItem.isContainer` boolean from the backend over a type-set predicate in a component. |
|
||||
| "Is this music content?" (`GenericMediaListPage`) | **Frontend, allowlisted** | Selects a grid *style*. It reads `config.itemType`, a value the page already declares about itself, and changes only if the UI is redesigned — the litmus test's "no". Single-type presentation is explicitly not the target of the rule. |
|
||||
|
||||
`DownloadedBrowse` defaults to Rust per the checklist's borderline rule; see
|
||||
Out of scope for why the migration itself is deferred rather than bundled.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Broaden the pattern
|
||||
|
||||
Replace the key-anchored pattern with one matching an array literal of two or
|
||||
more known Jellyfin item types, wherever it appears:
|
||||
|
||||
```sh
|
||||
# Two or more adjacent item-type string literals inside a bracket.
|
||||
TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
||||
PATTERN="\[[[:space:]]*\"($TYPES)\"[[:space:]]*,[[:space:]]*\"($TYPES)\""
|
||||
```
|
||||
|
||||
Properties worth stating, because each is a deliberate trade:
|
||||
|
||||
- **Not anchored to any key**, so a named const, a function return, a `Record`
|
||||
value, or an inline query all match equally.
|
||||
- **Requires two adjacent type literals**, preserving the existing and correct
|
||||
carve-out that single-type presentation (`itemType: "Movie"`) is legitimate.
|
||||
- **Requires string literals**, so `item.type === "Audio"` (display inspection)
|
||||
still does not match.
|
||||
- **Explicit type list**, not `[A-Z][a-z]+`, so arbitrary string arrays
|
||||
(`["High","Low"]`, `["Songs","Albums"]`) do not produce noise.
|
||||
|
||||
Keep `grep -rInE`, the `*.test.*` exclusion, and the allowlist mechanism as they
|
||||
are — all three work.
|
||||
|
||||
### 2. Resolve the surfaced sites
|
||||
|
||||
- `PersonDetailView.svelte` — already allowlisted; entry unchanged.
|
||||
- `GenericMediaListPage.svelte` — **add to the allowlist** with the reason from
|
||||
the layer table (grid styling over a self-declared `itemType`).
|
||||
- `DownloadedBrowse.svelte` — **add to the allowlist with a `TODO` naming the
|
||||
preferred fix** (backend `isContainer`). An allowlist entry that records a
|
||||
known-borderline decision is honest; silently broadening the pattern to miss
|
||||
it would not be.
|
||||
- `searchScope.ts` — **not allowlisted.** It is the leak, and
|
||||
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
|
||||
deletes it.
|
||||
|
||||
### 3. 🔴 Sequencing
|
||||
|
||||
**This spec must land after Stage 1 of
|
||||
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).**
|
||||
Hardening the tripwire first turns `master` red on a violation with no fix
|
||||
available, and the only ways out are reverting the hardening or allowlisting the
|
||||
leak — the second of which is exactly how a boundary rule dies.
|
||||
|
||||
### 4. Keep the allowlist honest
|
||||
|
||||
The script already warns that a growing allowlist means the boundary is eroding.
|
||||
This change takes it from 1 entry to 3, which is close to that line. Add a hard
|
||||
cap so drift is caught mechanically rather than by whoever notices:
|
||||
|
||||
```sh
|
||||
MAX_ALLOWLIST=4
|
||||
if [ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]; then
|
||||
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
||||
echo " Push taxonomy into Rust instead of appending here."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
The cap is deliberately just above the current count: the next exception forces
|
||||
a conversation instead of a one-line append.
|
||||
|
||||
### 5. Restate the limits
|
||||
|
||||
The header's "tripwire, not a proof" caveat stays and gets sharper. The broadened
|
||||
pattern still cannot see:
|
||||
|
||||
- a type set built at run time (`[...musicTypes, "Playlist"]`),
|
||||
- types split across variables (`const A = "Audio"; [A, B]`),
|
||||
- taxonomy expressed as a `switch` or chained `||` rather than an array.
|
||||
|
||||
The spec-review checklist remains the real gate. This raises the floor; it does
|
||||
not close the class.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Migrating `DownloadedBrowse` to a backend `isContainer` flag.** It touches
|
||||
`MediaItem`, `bindings.ts`, and the offline path — its own spec. Allowlisted
|
||||
with a TODO here so it is recorded, not forgotten.
|
||||
- The scoped-search fix itself — [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).
|
||||
- Detecting the run-time-construction cases listed above.
|
||||
- Extending the check to Rust or Kotlin (the rule is about `src/`).
|
||||
- Changing the boundary *policy* in CLAUDE.md.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] With `searchScope.ts` reverted to its leaking form, `bun run check:boundary`
|
||||
**fails** and names `src/lib/utils/searchScope.ts`. This is the criterion
|
||||
that proves the fix — verify it explicitly before landing.
|
||||
- [ ] On the post-fix tree, `bun run check:boundary` passes.
|
||||
- [ ] A newly introduced `const X = ["Movie", "Series"]` in any non-test `src/`
|
||||
file fails the check (regression test for the const-indirection evasion).
|
||||
- [ ] `itemType: "Movie"` and `item.type === "Audio"` do **not** trip the check.
|
||||
- [ ] `["High", "Low"]` and other non-item-type arrays do **not** trip it.
|
||||
- [ ] Test files are still excluded (the 6 known test hits stay silent).
|
||||
- [ ] The allowlist has exactly 3 entries, each with a written reason; a 5th
|
||||
entry fails the check via `MAX_ALLOWLIST`.
|
||||
- [ ] The script header still states it is a tripwire, not a proof, and names the
|
||||
evasions it cannot see.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run test:all` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
The script is bash and has no test harness. Verify by construction — each is a
|
||||
temporary edit, run, revert:
|
||||
|
||||
1. Reintroduce the `SCOPE_ITEM_TYPES` const → **must fail**.
|
||||
2. Add `const T = ["Movie","Series"]` to a scratch `.svelte` file → **must fail**.
|
||||
3. Add the same to a `.test.ts` file → **must pass** (exclusion holds).
|
||||
4. Add `itemType: "Movie"` → **must pass**.
|
||||
5. Add a 5th allowlist entry → **must fail** on the cap.
|
||||
|
||||
Record the five results in the PR description. A grep-based gate that has never
|
||||
been observed failing is indistinguishable from one that cannot fail — which is
|
||||
the precise condition this whole spec exists to correct.
|
||||
|
||||
## TRACES
|
||||
|
||||
Allocate in `requirements.md`:
|
||||
|
||||
- **DR-094** — "Frontend boundary tripwire detects Jellyfin item-type array
|
||||
literals anywhere in `src/` (not only inline at an `includeItemTypes:` query
|
||||
site), so a category→type mapping cannot evade the check via a named const;
|
||||
allowlist is capped to force taxonomy into Rust rather than accumulating
|
||||
exceptions." Category: Tooling. Status: Done on merge.
|
||||
|
||||
Shell scripts carry no `TRACES:` comment convention in this repo; reference
|
||||
DR-094 in the script header comment instead.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Land after Stage 1 of the scoped-search fix** — see §3. This is the one
|
||||
ordering constraint that will break `master` if ignored.
|
||||
- Test the regex against the current tree *before* committing:
|
||||
`grep -rInE "$PATTERN" src/ | grep -v '\.test\.'` should return exactly the
|
||||
four sites in the Motivation table.
|
||||
- The `TYPES` list will need occasional extension as Jellyfin adds types.
|
||||
That is acceptable for a tripwire — an unlisted type produces a false
|
||||
negative, never a false positive, so the check degrades safely.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Spec: Build provenance (git describe + build profile)
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** new DR-093 (build provenance surfaced in-app and in logs); no UR — this is a diagnostic capability, not a user feature
|
||||
**UX spec:** n/a — adds an About block to Settings; no new flow
|
||||
**Supersedes / revises:** —
|
||||
|
||||
## Summary
|
||||
|
||||
Make every build say exactly what it is. Today a running JellyTau reports no
|
||||
version at all — not in the UI, not in the logs — and the only version string in
|
||||
the tree is the hand-maintained `0.2.0` duplicated across three files.
|
||||
|
||||
This adds a `build.rs`-generated provenance string (`git describe` + short SHA +
|
||||
dirty flag + debug/release profile), exposes it over IPC, and renders it in a new
|
||||
Settings › About block. It also removes one of the three hand-bumped version
|
||||
files.
|
||||
|
||||
## Motivation
|
||||
|
||||
The concrete problem: when a user reports "the equalizer does nothing on my
|
||||
device" — which is a live risk for v0.2.0, whose Android audio settings are not
|
||||
yet device-verified — there is currently no way to tell which build they are
|
||||
running. Tag? Master? A local debug build from three weeks ago? The bug report
|
||||
cannot distinguish them.
|
||||
|
||||
Two smaller irritations this also fixes:
|
||||
|
||||
- **Debug builds masquerade as releases.** `0.2.0` is `0.2.0` whether it came
|
||||
from a tagged release or `bun run tauri dev`.
|
||||
- **Three files carry the version.** `package.json`, `src-tauri/Cargo.toml` and
|
||||
`src-tauri/tauri.conf.json` must be bumped in lockstep; the release checklist
|
||||
exists partly to stop them drifting.
|
||||
|
||||
### What this deliberately does *not* do
|
||||
|
||||
**The canonical version stays hand-bumped in `Cargo.toml`.** Cargo requires a
|
||||
literal semver string at manifest-parse time and cannot derive it from git. The
|
||||
same is true of `tauri.conf.json`. Attempting to source the *release* version
|
||||
from a tag trades a scripted, reviewable bump for a fragile build-time
|
||||
dependency that breaks in exactly the environment we care most about (CI, in
|
||||
Docker, from a shallow clone).
|
||||
|
||||
So: **the release version is authored; the build provenance is derived.** They
|
||||
answer different questions — "what release is this?" versus "what commit is this
|
||||
binary actually built from?" — and only the second benefits from git.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Capturing git describe / SHA / dirty state at compile time | Rust (`build.rs`) | Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the *dev server's* state, not the shipped binary's. |
|
||||
| Degrading to a sentinel when git is unavailable | Rust (`build.rs`) | Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone. |
|
||||
| Release version (`0.2.0`) | Rust (`Cargo.toml`, authored) | Domain fact about the product, not derivable from the environment. |
|
||||
| Deciding *what a build is* (release / dev / dirty) | Rust | Domain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told. |
|
||||
| Rendering the About block, copy-to-clipboard | Frontend | Pure presentation. |
|
||||
|
||||
Borderline row: the release/dev/dirty classification could be done in the
|
||||
frontend by pattern-matching the describe string. It goes to Rust because that is
|
||||
a *rule about what constitutes a release build*, and it would have to change if
|
||||
the tagging scheme changed — the litmus test in the template puts that in Rust.
|
||||
Send a typed enum, not a string for the frontend to parse.
|
||||
|
||||
## Design
|
||||
|
||||
### `build.rs`
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
emit_build_provenance();
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
fn emit_build_provenance() {
|
||||
let describe = std::process::Command::new("git")
|
||||
.args(["describe", "--tags", "--always", "--dirty"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}");
|
||||
|
||||
// Rebuild when HEAD moves or a ref is written, so the string does not go
|
||||
// stale across commits. Guarded: these paths do not exist in a git-less
|
||||
// source tarball, and emitting rerun-if-changed for a missing path would
|
||||
// force a rebuild every time.
|
||||
for p in [".git/HEAD", ".git/refs"] {
|
||||
if std::path::Path::new("../").join(p).exists() {
|
||||
println!("cargo:rerun-if-changed=../{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
🔴 **`build.rs` must never fail the build.** Every git call is
|
||||
`.ok()`-swallowed; a missing git binary, a shallow clone, or a source tarball all
|
||||
yield `"unknown"`. A build that breaks because git is absent would be a worse bug
|
||||
than the one this fixes.
|
||||
|
||||
Note the `../` prefixes: `build.rs` runs with CWD at `src-tauri/`, so the repo's
|
||||
`.git` is one level up.
|
||||
|
||||
### The provenance type
|
||||
|
||||
```rust
|
||||
/// TRACES: DR-093
|
||||
#[derive(specta::Type, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildInfo {
|
||||
/// Authored release version (Cargo.toml).
|
||||
pub version: String,
|
||||
/// `git describe --tags --always --dirty`, or "unknown".
|
||||
pub git_describe: String,
|
||||
/// What kind of build this is — classified in Rust, not inferred by the UI.
|
||||
pub kind: BuildKind,
|
||||
}
|
||||
|
||||
/// TRACES: DR-093
|
||||
#[derive(specta::Type, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BuildKind {
|
||||
/// Built from a clean, exactly-tagged commit in release mode.
|
||||
Release,
|
||||
/// Release-mode build that is not on a clean tag (e.g. master, or dirty).
|
||||
Untagged,
|
||||
/// debug_assertions build.
|
||||
Development,
|
||||
/// Git state unavailable at build time.
|
||||
Unknown,
|
||||
}
|
||||
```
|
||||
|
||||
Classification:
|
||||
|
||||
```rust
|
||||
let kind = if cfg!(debug_assertions) {
|
||||
BuildKind::Development
|
||||
} else if describe == "unknown" {
|
||||
BuildKind::Unknown
|
||||
} else if describe.contains('-') { // "v0.2.0-3-gcb79a37" or "...-dirty"
|
||||
BuildKind::Untagged
|
||||
} else {
|
||||
BuildKind::Release
|
||||
};
|
||||
```
|
||||
|
||||
### Command
|
||||
|
||||
```rust
|
||||
/// TRACES: DR-093
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_build_info() -> BuildInfo { … }
|
||||
```
|
||||
|
||||
No parameters, so the camelCase param rule does not apply; the struct fields do
|
||||
need `#[serde(rename_all = "camelCase")]` (above). Regenerate `bindings.ts`.
|
||||
|
||||
Also log the provenance once at startup, next to the existing init logging —
|
||||
that is what makes a user-submitted log file self-identifying, which is most of
|
||||
the value.
|
||||
|
||||
### Settings › About
|
||||
|
||||
A new block at the bottom of `src/routes/settings/+page.svelte`, rendering
|
||||
version, describe string, and a badge for non-release builds. One
|
||||
copy-to-clipboard button that yields a paste-ready block for bug reports:
|
||||
|
||||
```
|
||||
JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development)
|
||||
linux x86_64
|
||||
```
|
||||
|
||||
Platform/arch come from the existing Tauri APIs; do not shell out.
|
||||
|
||||
### Removing one version file
|
||||
|
||||
`tauri.conf.json`'s `"version"` field can be omitted, in which case Tauri falls
|
||||
back to the Cargo version. That takes the bump from three files to two.
|
||||
|
||||
**Verify before adopting**: confirm the Android `versionName`/`versionCode` and
|
||||
the NSIS installer version still resolve correctly with the field absent —
|
||||
Android packaging in particular reads the Tauri config. If either regresses,
|
||||
keep the field and drop this part; it is a convenience, not the point of the
|
||||
spec.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Deriving the *release* version from git tags (see Motivation).
|
||||
- A build-time timestamp. It defeats reproducible builds and adds little over
|
||||
the commit SHA.
|
||||
- CI provenance/attestation, SBOM, signing.
|
||||
- Displaying the Jellyfin server version (separate concern, already available
|
||||
from `/System/Info`).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `cargo build` succeeds with git absent, from a shallow clone, and from a source tarball with no `.git` — yielding `"unknown"` in each case, never a build failure.
|
||||
- [ ] A tagged clean release build reports `BuildKind::Release`; `bun run tauri dev` reports `Development`; a dirty tree reports `Untagged` (release mode) with `-dirty` in the describe string.
|
||||
- [ ] The describe string changes after a new commit without a manual `cargo clean` (rerun-if-changed works).
|
||||
- [ ] Provenance is logged once at startup.
|
||||
- [ ] Settings › About renders version + describe + build-kind badge, with working copy-to-clipboard.
|
||||
- [ ] 🔴 CI checkouts that build a shippable artifact set `fetch-depth: 0`, or their artifacts are knowingly stamped `unknown`. Currently only `publish-docs.yml` sets it; `build-release.yml` has five checkouts and `build-and-test.yml` two, all of which would report `unknown` as-is.
|
||||
- [ ] **No toolchain installed in CI** — git is already present in the builder image; nothing new is added.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
- [ ] DR-093 allocated in `requirements.md`; new code carries `// TRACES:`.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**: the classification is pure and must be extracted from the command as
|
||||
`classify_build(describe: &str, debug: bool) -> BuildKind` so it can be tested
|
||||
directly. Cover: `"v0.2.0"` → `Release`; `"v0.2.0-3-gcb79a37"` → `Untagged`;
|
||||
`"v0.2.0-dirty"` → `Untagged`; `"unknown"` → `Unknown`; `debug = true` → always
|
||||
`Development` regardless of describe.
|
||||
|
||||
`build.rs` itself is not unit-testable. Verify its failure path manually by
|
||||
building with `PATH` stripped of git, and from a `git archive` tarball — both
|
||||
must succeed with `"unknown"`.
|
||||
|
||||
**Frontend**: assert the About block renders each `BuildKind` correctly, and that
|
||||
it renders the backend-supplied kind rather than re-deriving it from the string
|
||||
(a test that passes a `Release` kind with a `-dirty` describe and asserts the
|
||||
badge follows the *kind* would catch that regression).
|
||||
|
||||
## TRACES
|
||||
|
||||
- `build.rs` provenance emission → `// TRACES: | DR-093`
|
||||
- `BuildInfo` / `BuildKind` / `classify_build` → `// TRACES: | DR-093`
|
||||
- `get_build_info` command → `// TRACES: | DR-093`
|
||||
- Settings About block → `// TRACES: | DR-093`
|
||||
- `classify_build` tests → `UT-BUILD-1`
|
||||
- Allocate **DR-093** in `requirements.md` ("Build provenance: git describe and
|
||||
build profile surfaced in-app and in logs"). Next free DR at time of writing
|
||||
is DR-093.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do the `build.rs` + command + logging first; the About UI is the smaller half
|
||||
and the logging alone delivers most of the diagnostic value.
|
||||
- The `fetch-depth: 0` change is the easiest part to forget and the one that
|
||||
makes CI artifacts useless if missed — it is why that acceptance box is
|
||||
flagged. Weigh it per workflow: test-only jobs do not need it.
|
||||
- Do not add a build timestamp "while you are in there" — see Out of scope.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Spec: Migrate to libmpv2 and declare the project licence
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** dependency and licensing housekeeping identified in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Two related pieces of housekeeping that block or complicate later work:
|
||||
|
||||
1. Replace the abandoned `libmpv` crate (pinned to a git branch) with the
|
||||
maintained `libmpv2`.
|
||||
2. Add a `LICENSE` file. The project has none, which leaves its legal status
|
||||
undefined while it links GPL-licensed libmpv.
|
||||
|
||||
Neither changes user-visible behaviour. Both are prerequisites for
|
||||
[windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
|
||||
## Motivation
|
||||
|
||||
### The dependency is dead
|
||||
|
||||
```toml
|
||||
# src-tauri/Cargo.toml
|
||||
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
||||
```
|
||||
|
||||
- crates.io `libmpv` 2.0.1 was published **2020-09-29**.
|
||||
- The upstream repo's last commit was **2023-01-08**; nothing since was released.
|
||||
- We pin a git *branch*, so builds are not reproducible — the same lockfile-less
|
||||
checkout can resolve differently over time, and CI has no protection if the
|
||||
branch moves or the repo disappears.
|
||||
|
||||
`libmpv2` (kohsine/libmpv2-rs) is a maintained fork of exactly this crate:
|
||||
6.0.0 released **2026-05-12**, ~23.5k recent downloads against the original's
|
||||
~1.1k, releases roughly quarterly since 2024.
|
||||
|
||||
### The project has no licence
|
||||
|
||||
There is no `LICENSE`/`COPYING` file and `src-tauri/Cargo.toml` has no `license`
|
||||
field. The project is open source and will never be commercial, so this is purely
|
||||
an omission — but it matters because we link libmpv, and "no licence" defaults to
|
||||
*all rights reserved*, which is incompatible with distributing a GPL-derived
|
||||
work.
|
||||
|
||||
## Design
|
||||
|
||||
### Part 1 — licence
|
||||
|
||||
**Use GPLv3.** This is forced, not chosen:
|
||||
|
||||
- mpv's default build is **GPLv2-or-later**, so the combined work must be
|
||||
GPL-compatible.
|
||||
- Apache-2.0 is **GPLv2-incompatible** (patent-termination and indemnification
|
||||
clauses) but GPLv3-compatible.
|
||||
- A scan of the dependency tree found Apache-2.0-**only** crates with no
|
||||
alternative arm — most importantly **`tao`** (Tauri's own windowing crate),
|
||||
plus `sync_wrapper`, `gethostname`, and `ring` (Apache-2.0 AND ISC).
|
||||
|
||||
`tao` is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's
|
||||
"or later" option puts the combination at **GPLv3**.
|
||||
|
||||
Actions:
|
||||
- Add `LICENSE` containing the GPLv3 text.
|
||||
- Add `license = "GPL-3.0-or-later"` to `src-tauri/Cargo.toml` and `license` to
|
||||
`package.json`.
|
||||
- Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.
|
||||
|
||||
Because the project is open source, we use mpv's **default GPL build** — no
|
||||
`-Dgpl=false`, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis
|
||||
that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg
|
||||
filter.
|
||||
|
||||
🔴 Never build FFmpeg with `--enable-nonfree` — that produces a binary that is
|
||||
**unredistributable under any licence**, open source or not.
|
||||
|
||||
### Part 2 — libmpv → libmpv2
|
||||
|
||||
```toml
|
||||
# Linux (and later Windows, per the Windows audio spec)
|
||||
libmpv2 = "=6.0.0"
|
||||
```
|
||||
|
||||
Pin exactly: `libmpv2` has broken its API in **every** major release.
|
||||
|
||||
Breaking changes to expect, from the changelog:
|
||||
|
||||
| Version | Change | Impact here |
|
||||
|---|---|---|
|
||||
| 4.0.0 | Removed command helper methods — call `mpv.command(...)` directly | Low; we already use `command`/`set_property` |
|
||||
| 5.0.0 | Removed `mpv_node` support entirely (properties return strings; parse JSON yourself); `EventContext` folded into `Mpv`; `ProtocolContext` → `Protocol` | **Medium** — `start_event_loop` uses `create_event_context()`; check whether that call still exists |
|
||||
| 6.0.0 | `RenderContext::new()` → `Mpv::create_render_context()`; `'static` bound on `OpenGLInitParams`; render context now borrows `Mpv` (fixes a use-after-free) | **None** — we do not use the render API |
|
||||
|
||||
The last row matters: we run mpv audio-only (`video = no`), so the entire render
|
||||
surface is irrelevant to us. Consider disabling the default `render` feature to
|
||||
reduce build surface.
|
||||
|
||||
The main porting work is the event loop in `mpv_backend.rs` — `wait_event`,
|
||||
`disable_deprecated_events`, and the `FileLoaded` / `PlaybackRestart` /
|
||||
`PropertyChange` / `EndFile` handling, given 5.0.0 folded `EventContext` into
|
||||
`Mpv`.
|
||||
|
||||
Everything else — `set_property` calls, the `af` filter graph, the 250ms position
|
||||
thread, the seek-suppression window — should port unchanged.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No logic moves. This is a dependency swap plus a licence file; the
|
||||
`PlayerBackend` trait boundary is untouched.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| mpv event → `PlayerStatusEvent` mapping | Rust (unchanged) | Already correct; only the binding API beneath it changes. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any behaviour change. If playback behaves differently after this, that is a bug.
|
||||
- Windows support — separate spec, but this must land first.
|
||||
- Adopting the render API. We are audio-only on mpv.
|
||||
- Re-licensing decisions beyond adding the file the project already implies.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `LICENSE` (GPLv3) present; `license` field set in `Cargo.toml` and `package.json`.
|
||||
- [ ] A full dependency-licence audit has been run (`cargo install cargo-license && cargo license`) and confirms no GPLv3-incompatible dependency. *(The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)*
|
||||
- [ ] `libmpv` git dependency removed; `libmpv2` pinned to an exact version.
|
||||
- [ ] Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
|
||||
- [ ] Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
|
||||
- [ ] `EndFile` still emits `PlaybackEnded` only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
|
||||
- [ ] Builder image updated if the libmpv dev package requirement changed; **no toolchain install added to any CI step**.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
The existing `mpv_backend_test.rs` plus the `build_af_filter`,
|
||||
`eq_filter_entries`, and `normalize_filter_entry` tests are the regression net —
|
||||
they must pass unchanged, since none of them touch the binding API.
|
||||
|
||||
The event loop has no unit tests and is where the risk concentrates. Verify
|
||||
manually on Linux:
|
||||
|
||||
1. Play → pause → play; confirm position does not flash to 0:00 (the known
|
||||
playing-event regression).
|
||||
2. Seek mid-track; confirm no jump-to-zero within 150ms.
|
||||
3. Let a track end naturally; confirm autoplay advances (exercises `EndFile` EOF).
|
||||
4. Press stop; confirm autoplay does **not** advance.
|
||||
5. Sleep-timer expiry; confirm it stops without triggering autoplay.
|
||||
|
||||
Cases 3–5 are the ones most likely to break silently, and each corresponds to a
|
||||
bug already fixed once in this codebase.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `MpvBackend` construction / event loop → existing `// TRACES: UR-003 | IR-003`, unchanged
|
||||
- No new requirement IDs; this is a dependency migration.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do this **before** the Windows audio backend.
|
||||
- Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API
|
||||
in every major release, most recently two months before this spec.
|
||||
- The crates.io `repository` field for `libmpv2` points at `kohsine/libmpv-rs`,
|
||||
but the repo was renamed to **`libmpv2-rs`**; the old raw URLs 404.
|
||||
- `libmpv2-sys` ships pregenerated bindings and vendored headers, so no libclang
|
||||
is needed at build time — relevant to keeping the builder image thin.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,226 @@
|
||||
# Spec: Playback backend unification — findings and strategy
|
||||
|
||||
**Status:** Accepted (analysis; no code changes)
|
||||
**Requirements:** IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** informs [android-native-video-spike.md](android-native-video-spike.md), [android-audio-settings-parity.md](android-audio-settings-parity.md), [windows-native-audio-backend.md](windows-native-audio-backend.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This spec records the outcome of an investigation into unifying JellyTau's
|
||||
playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single
|
||||
engine with hardware acceleration everywhere. **The conclusion is that video
|
||||
cannot be unified onto a native engine, and should not be attempted.** Audio
|
||||
*can* be, and that is where the remaining specs direct effort.
|
||||
|
||||
No code changes follow from this spec directly. It exists so the decision is
|
||||
written down with its evidence, and so a future session does not re-run the same
|
||||
investigation.
|
||||
|
||||
## Motivation
|
||||
|
||||
The requirements doc carries a "Platform Playback Backend Parity" issue noting
|
||||
that audio settings work on Linux but not Android, and proposing eventual
|
||||
convergence. The natural next question — "should we just run one engine
|
||||
everywhere?" — needed answering before spending effort on per-backend patches.
|
||||
|
||||
The investigation also surfaced that several statements in requirements.md and in
|
||||
code comments are factually wrong. Those corrections are part of the deliverable.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. The current architecture is not what the docs describe
|
||||
|
||||
| Platform | Audio | Video |
|
||||
|----------|-------|-------|
|
||||
| Linux | MPV (native, **audio-only**) | webview `<video>` + hls.js |
|
||||
| Android | ExoPlayer (native) | **webview `<video>` + hls.js** |
|
||||
| Windows | webview `<audio>` | webview `<video>` + hls.js |
|
||||
|
||||
Two surprises:
|
||||
|
||||
- **MPV never decodes video.** `mpv_backend.rs` sets `video = no` and
|
||||
`audio-display = no` at construction. Linux video has always been the webview.
|
||||
Correspondingly, `player_play_item` deliberately does *not* load into MPV on
|
||||
Linux (it calls `set_current_item`, which only updates the queue).
|
||||
- **Android video is also the webview.** `createAdapter()` in
|
||||
`src/lib/player/adapters/index.ts` hardcodes `const effectiveKind = "html5"`
|
||||
and does `void backendKind`, discarding the `use_html5_element` signal that
|
||||
`get_player_status` computes in Rust. `NativePlayerAdapter` is dead code, and
|
||||
ExoPlayer's `SurfaceView` path in `JellyTauPlayer.kt` is unreachable.
|
||||
|
||||
So video is *already* unified — on HTML5, everywhere, by accident of that
|
||||
hardcode — and on the path without hardware decoding on Android.
|
||||
|
||||
### 2. Native video cannot be composited with a Tauri webview
|
||||
|
||||
This is the load-bearing finding. It is **not** an mpv limitation; it defeats
|
||||
every candidate engine identically:
|
||||
|
||||
- **mpv**: `tauri-plugin-libmpv`'s own platform table reads Linux ⚠️
|
||||
*"Experimental. Window embedding is not working."*
|
||||
- **GStreamer** (wry discussion #284, 2024): *"Gstreamer was rendering above the
|
||||
surface and covering all html elements."*
|
||||
- **libVLC** (tauri discussion #6343, 2024): *"I had to render the webview in a
|
||||
child window though because vlc kept rendering on top of it."*
|
||||
|
||||
Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):
|
||||
|
||||
> "we are limited to using Webkit2GTK on Linux and that requires a GTK window.
|
||||
> While possible to add a GTK widget as a child X11 window inside raw X11 window,
|
||||
> this is however a bit hacky and **it is not possible on Wayland at all**."
|
||||
|
||||
WebKitGTK, WebView2, and Android WebView each draw into their own compositor
|
||||
surface. A native video surface is either entirely above or entirely below the
|
||||
webview; it cannot interleave with HTML. Every working example in the ecosystem
|
||||
is the same hack — a separate child window position-synced to a
|
||||
`getBoundingClientRect()` div — which breaks on resize, scroll, and any UI drawn
|
||||
over the video. For JellyTau that means the controls, subtitle overlay, and
|
||||
mini-player.
|
||||
|
||||
The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:
|
||||
|
||||
> "I'm faking it and the window is not truly embedded, basically when the parent
|
||||
> moves or resizes I reset the position and size of the libmpv window to align it
|
||||
> with an HTML div."
|
||||
|
||||
**The principle to carry forward: audio can unify on a native engine; video
|
||||
cannot, because video needs a surface and the webview owns the surface.**
|
||||
|
||||
### 3. mpv would regress streaming quality
|
||||
|
||||
mpv has **no adaptive bitrate**. It delegates HLS to FFmpeg's demuxer, which
|
||||
selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR
|
||||
and it never landed. `--hls-bitrate` is a static picker defaulting to `max`.
|
||||
|
||||
The webview path already has real ABR via hls.js. Moving video to mpv would be a
|
||||
**downgrade** on every platform — no graceful degradation on weak networks, and
|
||||
quality changes requiring teardown and reload.
|
||||
|
||||
### 4. Crossfade is architecturally blocked on mpv
|
||||
|
||||
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
||||
requiring two input streams, so there is no second input to feed it. Real
|
||||
crossfade needs **two libmpv instances** with manually ramped volumes. Upstream
|
||||
maintainer response (mpv#4512, closed three minutes after opening):
|
||||
|
||||
> "No. I also find crossfading stupid and complex, so the likeliness of that
|
||||
> happening is low."
|
||||
|
||||
GStreamer *could* do it via `audiomixer`. mpv cannot, at any reasonable cost.
|
||||
|
||||
### 5. Engine comparison summary
|
||||
|
||||
| Criterion | mpv | GStreamer | libVLC |
|
||||
|-----------|-----|-----------|--------|
|
||||
| Webview compositing | ❌ Linux broken | ❌ same wall | ❌ same wall |
|
||||
| Adaptive bitrate HLS | ❌ none | ✅ adaptivedemux2 | ✅ adaptive module |
|
||||
| Rust bindings | ⚠️ `libmpv2` active; our pin is dead | ✅ `gstreamer-rs` excellent | ❌ `vlc-rs` abandoned (2018) |
|
||||
| Windows cross-MSVC | ⚠️ prebuilt DLL | ❌ pkg-config vs cargo-xwin | ❌ no better |
|
||||
| Android packaging | ✅ Maven AAR (used by Findroid) | ⚠️ Cerbero/NDK, painful | ✅ mature AAR |
|
||||
| ASS/SSA subtitles | ✅ libass built in | ✅ libass | ✅ libass |
|
||||
| Crossfade | ❌ impossible | ✅ `audiomixer` | ⚠️ unclear |
|
||||
|
||||
Every candidate fails the first row, which is the disqualifying one.
|
||||
|
||||
### 6. Two further options ruled out
|
||||
|
||||
**Webview `<audio>`/`<video>` everywhere** (i.e. delete the native audio backends
|
||||
too) is dead on Android: `navigator.mediaSession` is *deliberately compiled out*
|
||||
of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification
|
||||
control would be impossible. Chromium has also never shipped `audioTracks`. It
|
||||
remains fine for Windows *video*, which is what we already do.
|
||||
|
||||
**FFmpeg-direct / Rust-native** (`ffmpeg-next`, `rsmpeg`, Symphonia) is not
|
||||
close: the safe bindings do not expose hardware decode at all, `ffmpeg-next` is
|
||||
self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This
|
||||
is a multi-person-year path to reach parity with what we already have.
|
||||
|
||||
### 7. If libmpv is ever revisited on Android
|
||||
|
||||
Recorded so the next investigation starts from evidence rather than repeating the
|
||||
search. The `dev.jdtech.mpv:libmpv` AAR — maintained by Findroid's author, i.e.
|
||||
another Jellyfin Android client — was inspected directly:
|
||||
|
||||
- `libmpv.so` exports the full 54-function `mpv_*` C API with **zero `Java_`
|
||||
symbols**; JNI is a separate optional ~19 KB `libplayer.so`. So it is drivable
|
||||
from Rust without a Java shim. (This is precisely what disqualifies libVLC,
|
||||
whose Android video path hard-requires a Java `AWindow` jobject.)
|
||||
- ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
|
||||
- 🔴 **The published AAR is built `--enable-gpl --enable-version3` — it is
|
||||
GPLv3**, not LGPL. Fine for us (see [libmpv2-migration.md](libmpv2-migration.md)),
|
||||
but it would be a hard constraint for anyone shipping closed source, and an
|
||||
LGPL rebuild would be your own build to own.
|
||||
- Top unverified risk if anyone tries this: whether `libmpv2-sys` can
|
||||
cross-compile for `aarch64-linux-android` against that prebuilt `.so`. No
|
||||
working example of `libmpv2` on Android was found.
|
||||
|
||||
None of this changes the verdict — the cost is the MediaSession/foreground-service
|
||||
rewrite, not the bindings.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Do not unify video onto a native engine.** Video stays in the webview with
|
||||
hls.js on all platforms. This is not a compromise — it is the configuration
|
||||
that falls out of the compositing constraint, and it is the only one that
|
||||
gives us ABR for free.
|
||||
2. **Android native video is worth a bounded spike anyway** — not for
|
||||
unification, but because ExoPlayer's `SurfaceView` path already exists and
|
||||
would restore hardware decode plus ASS/SSA subtitles. See
|
||||
[android-native-video-spike.md](android-native-video-spike.md).
|
||||
3. **Audio parity is the real gap** and is achievable without touching any of the
|
||||
above. See [android-audio-settings-parity.md](android-audio-settings-parity.md)
|
||||
and [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
4. **Migrate the dead libmpv pin** regardless of any of this. See
|
||||
[libmpv2-migration.md](libmpv2-migration.md).
|
||||
|
||||
## Corrections to existing docs
|
||||
|
||||
These are factual errors found during the investigation. Fixing them is in scope
|
||||
for this spec.
|
||||
|
||||
| Location | Says | Actually |
|
||||
|----------|------|----------|
|
||||
| `requirements.md` UR-031 (line ~44) | "Done (Linux only)" | Not implemented on any platform. |
|
||||
| `requirements.md` DR-034 (line ~196) | "Done (Linux only)" | Not implemented anywhere — `mpv_backend.rs` has a bare `// TODO: Implement crossfade via MPV audio filters if needed`. Architecturally blocked on mpv (finding 4). |
|
||||
| `requirements.md` parity matrix | Crossfade ✅ Linux / ❌ Android | ❌ / ❌ |
|
||||
| `requirements.md` parity matrix | (no EQ row) | EQ is also Linux-only — `build_af_filter`/`eq_filter_entries` exist only in `mpv_backend.rs`. Same root cause, same fix. |
|
||||
| `nativeAdapter.ts:11-14` | Native Android video "blocked upstream by tauri#10152" | tauri#10152 is a stale *feature request*, dead since 2024-07-01. The capability shipped in tauri commit `27d01834` (2024-09-02). Not a blocker. |
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No new logic. The one boundary observation worth recording:
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which video backend a platform uses (`use_html5_element`) | Rust | Already correctly computed in `get_player_status`. The frontend currently *discards* it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any code change. This spec is analysis; the sibling specs carry the work.
|
||||
- iOS/macOS. Not current targets.
|
||||
- Replacing hls.js.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `requirements.md` DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
|
||||
- [ ] Stale tauri#10152 comment in `nativeAdapter.ts` corrected.
|
||||
- [ ] The four sibling specs exist and are linked from here.
|
||||
|
||||
## Testing
|
||||
|
||||
n/a — documentation only.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new code. Requirement text changes only; DR-034's status line is the one
|
||||
substantive edit.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- The evidence above was gathered in July 2026. The compositing constraint has
|
||||
been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is
|
||||
unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284
|
||||
are the threads to re-read first.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Spec: Playback documentation corrections
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** revises the status of DR-034; corrects the parity matrix in [requirements.md](../requirements.md)
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** implements the "Corrections to existing docs" section of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Fix four factual errors in the requirements doc and the player source comments,
|
||||
all found while investigating backend unification. Each claims something the code
|
||||
does not do. Small change, but they are actively misleading: two of them assert a
|
||||
feature is implemented when it is implemented nowhere, and one cites an upstream
|
||||
blocker that no longer exists.
|
||||
|
||||
Documentation and comments only — no behaviour change.
|
||||
|
||||
## Motivation
|
||||
|
||||
These errors compound. DR-034 reads "Done (Linux only)", so a future session
|
||||
planning Android parity would reasonably assume crossfade exists on Linux and
|
||||
only needs porting — when in fact it is unimplemented everywhere *and*
|
||||
architecturally blocked on the engine it supposedly runs on. Likewise the
|
||||
tauri#10152 comment has been discouraging work on Android native video since the
|
||||
upstream capability shipped in September 2024.
|
||||
|
||||
## The corrections
|
||||
|
||||
### 1. DR-034 status is wrong
|
||||
|
||||
`requirements.md` line ~196:
|
||||
|
||||
```
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||
```
|
||||
|
||||
The code:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/player/mpv_backend.rs, in set_audio_settings
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
```
|
||||
|
||||
That is the entire crossfade implementation. `crossfade_duration` is plumbed
|
||||
through `AudioSettings` and clamped to 0–12s, but no backend ever acts on it.
|
||||
|
||||
**Change to:** `Not implemented (blocked on MPV — see playback-backend-unification.md)`
|
||||
|
||||
Worth stating *why* in the requirements entry, because it is not a scheduling
|
||||
gap: mpv's audio chain is single-stream, and FFmpeg's `acrossfade` is an `N→A`
|
||||
filter needing two inputs. Real crossfade requires two libmpv instances with
|
||||
manually ramped volumes. Upstream declined the feature (mpv#4512).
|
||||
|
||||
### 1b. UR-031 status is wrong for the same reason
|
||||
|
||||
`requirements.md` line ~44:
|
||||
|
||||
```
|
||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||
```
|
||||
|
||||
Same error one level up: the *user* requirement is also marked done. Since no
|
||||
backend implements crossfade, UR-031 is not satisfied on any platform.
|
||||
|
||||
**Change to:** `Not implemented (blocked — see DR-034)`
|
||||
|
||||
Note line ~517 of the same file (`UR-031 (Crossfade), UR-032 (Gapless),
|
||||
UR-033 (Normalization) only work on Linux`) inherits the error — crossfade works
|
||||
nowhere, so it should read UR-032/UR-033 only.
|
||||
|
||||
### 2. Parity matrix crossfade row is wrong
|
||||
|
||||
```
|
||||
| Crossfade | ✅ | ❌ | Gap |
|
||||
```
|
||||
|
||||
**Change to** `| Crossfade | ❌ | ❌ | Not implemented |` — it is not a
|
||||
platform-parity gap, it is an unbuilt feature.
|
||||
|
||||
### 3. Parity matrix is missing the equalizer
|
||||
|
||||
The matrix lists crossfade, gapless, and normalization but omits the EQ, which
|
||||
has the same Linux-only shape and the same root cause (`ExoPlayerBackend` not
|
||||
overriding `set_audio_settings`). `build_af_filter` and `eq_filter_entries` exist
|
||||
only in `mpv_backend.rs`; there is no equalizer code in the Android tree.
|
||||
|
||||
**Add:** `| Equalizer (10-band) | ✅ | ❌ | Gap |`
|
||||
|
||||
### 4. `nativeAdapter.ts` cites a stale blocker
|
||||
|
||||
`src/lib/player/adapters/nativeAdapter.ts:11-14` states native Android video is
|
||||
blocked upstream by tauri#10152 (transparent webview / SurfaceView compositing).
|
||||
|
||||
tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature request*
|
||||
that `WebviewWindowBuilder::transparent` was desktop-only — not a report that
|
||||
compositing is broken. The capability shipped in tauri commit `27d01834`
|
||||
(2024-09-02), which moved `transparent()` into the cross-platform impl block with
|
||||
only the tao call `#[cfg(desktop)]`-fenced. It landed as a clippy cleanup, so the
|
||||
issue was never closed. Separately, the black/white-screen bug (tauri#8381,
|
||||
tauri#9408) was a broken JNI signature for `setBackgroundColor`, fixed in wry
|
||||
0.39.4; we ship wry 0.55.x.
|
||||
|
||||
**Change to:** a comment stating the adapter is currently unreachable because
|
||||
`createAdapter` hardcodes the HTML5 kind, that transparency is no longer an
|
||||
upstream blocker, and that
|
||||
[android-native-video-spike.md](android-native-video-spike.md) tracks whether
|
||||
SurfaceView compositing actually works. Be explicit that *nobody has
|
||||
demonstrated* SurfaceView-behind-WebView on Tauri Android — nothing upstream
|
||||
blocks it, and nothing upstream proves it.
|
||||
|
||||
### 5. Platform capability is signalled three incompatible ways
|
||||
|
||||
Not a doc error — a real inconsistency found during the same investigation, worth
|
||||
recording here even though fixing it needs its own change.
|
||||
|
||||
Which backend a platform uses is currently expressed three ways:
|
||||
|
||||
1. Rust `#[cfg]` gates in `player/mod.rs` and `create_player_backend` — the truth.
|
||||
2. The `useHtml5Element` / `VideoBackend` value from `get_player_status` — which
|
||||
the frontend discards (see the spike spec).
|
||||
3. **Frontend user-agent sniffing** in `src/lib/services/webviewAudio.ts:30-41`:
|
||||
|
||||
```ts
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isAndroid = ua.includes("android");
|
||||
const isLinux = ua.includes("linux") && !isAndroid;
|
||||
return !isAndroid && !isLinux;
|
||||
```
|
||||
|
||||
The comment says it is "matching the Rust cfg gate" — i.e. the frontend
|
||||
re-derives a backend decision from the user-agent string and hopes it stays in
|
||||
sync. That is the frontend deciding *which backend exists*, which is domain
|
||||
knowledge, not presentation. It also breaks silently the moment a new target is
|
||||
added or a webview's UA changes.
|
||||
|
||||
**This is a boundary leak of the same family the spec-review checklist exists to
|
||||
catch**, even though `check:boundary`'s tripwire (item-type arrays) does not
|
||||
match it. Rust already computes the answer; the frontend should consume it.
|
||||
|
||||
Not fixed by this spec — it is behavioural, not documentation. It should be
|
||||
folded into the spike spec's factory rework, where the same
|
||||
"consume Rust's decision instead of re-deriving it" change is already in scope.
|
||||
|
||||
### Also worth fixing while here
|
||||
|
||||
`requirements.md` IR-004 reads "In Progress (basic playback works, audio settings
|
||||
missing)". That stays accurate until
|
||||
[android-audio-settings-parity.md](android-audio-settings-parity.md) lands, but
|
||||
the "Future Fix" list in the parity issue proposes
|
||||
`ConcatenatingMediaSource` for crossfade — **deprecated in current Media3**. Drop
|
||||
that suggestion; the modern approach is a custom `AudioProcessor`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No logic. Documentation and comments only.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| — | — | No logic introduced or moved by this spec. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Implementing crossfade. This spec only stops claiming it exists.
|
||||
- Implementing Android audio settings — see the parity spec.
|
||||
- Running the Android video spike — see that spec.
|
||||
- Rewriting the architecture docs. `docs/architecture/05-platform-backends.md`
|
||||
should be re-read for the same class of error, but that is a larger pass.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] DR-034 status corrected, with the blocking reason stated.
|
||||
- [ ] UR-031 status corrected (line ~44), and the "only work on Linux" line (~517) no longer lists crossfade.
|
||||
- [ ] Parity matrix: crossfade ❌/❌; equalizer row added.
|
||||
- [ ] `ConcatenatingMediaSource` suggestion removed from the "Future Fix" list.
|
||||
- [ ] `nativeAdapter.ts` comment corrected and pointing at the spike spec.
|
||||
- [ ] `bun run check` and `bun run test` pass (a comment change still touches TS).
|
||||
- [ ] `bun run traces:markdown` re-run if requirement text changed.
|
||||
|
||||
No Rust changes, so the `cargo` gates do not apply.
|
||||
|
||||
## Testing
|
||||
|
||||
None beyond the standard gates — no behaviour changes. Confirm
|
||||
`bun run traces:markdown` regenerates cleanly, since DR-034's row is referenced
|
||||
by the traceability matrix.
|
||||
|
||||
## TRACES
|
||||
|
||||
No code implementing requirements changes; no TRACES comments to add or update.
|
||||
The DR-034 row in `docs/traceability.md` will regenerate with the corrected text.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do **not** silently delete DR-034. The requirement (UR-031 crossfade) is still
|
||||
wanted; it is the *status* that is wrong. Keeping the row with an honest status
|
||||
and a reason is the point.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,258 @@
|
||||
# Spec: Enforce the unified player boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** DR-095 (new); relates to UR-005 and the unified-player-boundary
|
||||
principle in CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md)
|
||||
**UX spec:** n/a — refactor, no user-visible change.
|
||||
**Supersedes / revises:** n/a
|
||||
|
||||
## Summary
|
||||
|
||||
The stated principle is that UI controls playback **only** through
|
||||
`playerController` ([src/lib/player/index.ts](../../src/lib/player/index.ts)),
|
||||
never by calling `commands.player*` directly. There are **52 direct call sites
|
||||
outside** that facade. This spec routes the genuine playback-control calls
|
||||
through the facade, narrows the principle's wording so it stops forbidding
|
||||
things it never meant to forbid, and adds the lint rule that keeps it true —
|
||||
because this rule is the one design principle in the audit with **no automated
|
||||
check at all**, and it is also the one that drifted furthest.
|
||||
|
||||
## Motivation
|
||||
|
||||
Direct `commands.player*` usage outside `src/lib/player/`, by file:
|
||||
|
||||
| File | Sites |
|
||||
|---|---|
|
||||
| [queue.ts](../../src/lib/stores/queue.ts) | 10 |
|
||||
| [player/[id]/+page.svelte](../../src/routes/player/[id]/+page.svelte) | 9 |
|
||||
| [VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte) | 8 |
|
||||
| [settings/+page.svelte](../../src/routes/settings/+page.svelte) | 5 |
|
||||
| [sleepTimer.ts](../../src/lib/stores/sleepTimer.ts) / [auth.ts](../../src/lib/stores/auth.ts) / [autoplay.ts](../../src/lib/api/autoplay.ts) | 4 each |
|
||||
| [preload.ts](../../src/lib/services/preload.ts) | 3 |
|
||||
| [library/[id]](../../src/routes/library/[id]/+page.svelte), [playerEvents.ts](../../src/lib/services/playerEvents.ts), [playbackMode.ts](../../src/lib/stores/playbackMode.ts) | 1–2 each |
|
||||
|
||||
These are **not** equivalent violations, and treating them as one number is why
|
||||
the rule has been easy to ignore. Three distinct groups:
|
||||
|
||||
**(a) Genuine violations — playback control with a facade method that already
|
||||
exists.** `playerStop` ×6, `playerPlayTracks` ×4, `playerSeek` ×2,
|
||||
`playerPlayAlbumTrack` ×2, `playerNext`, `playerPrevious`, `playerSkipTo`,
|
||||
`playerToggleShuffle`, `playerCycleRepeat`, `playerRemoveFromQueue`,
|
||||
`playerMoveInQueue`, `playerAddTrackById`, `playerAddTracksByIds`,
|
||||
`playerSetSubtitleTrack`, `playerPlayItem`. The facade exposes `stop()`,
|
||||
`seek()`, `next()`, `previous()`, `skipTo()`, `toggleShuffle()`,
|
||||
`cycleRepeat()`, `removeFromQueue()`, `moveInQueue()`, `addTrackById()`,
|
||||
`addTracksByIds()`, `setSubtitleTrack()`, `playTracks()`, `playAlbumTrack()`,
|
||||
`playItem()` — every one of these has a facade equivalent that is simply not
|
||||
being called. `queue.ts` is the starkest case: it imports `commands` directly
|
||||
and re-implements ten methods the facade already provides.
|
||||
|
||||
**(b) Playback control with no facade method.** `playerPlayQueue`,
|
||||
`playerGetQueue`, `playerGetStatus`, `playerEnterBackgroundAudio`,
|
||||
`playerExitBackgroundAudio`, `playerSetSleepTimer`, `playerCancelSleepTimer`,
|
||||
`playerPlayNextEpisode`, `playerCancelAutoplayCountdown`. In scope for the
|
||||
principle, but currently *impossible* to comply with — the facade has no surface
|
||||
for them. A rule that cannot be followed is not being broken so much as it is
|
||||
unfinished.
|
||||
|
||||
**(c) Not playback control.** `playerConfigureJellyfin` ×3,
|
||||
`playerDisableJellyfin`, `playerGet/SetAudioSettings`,
|
||||
`playerGet/SetVideoSettings`, `playerGetEqPresets`,
|
||||
`playerGet/SetAutoplaySettings`, `playerGet/SetCacheConfig`,
|
||||
`playerPreloadUpcoming`. These are configuration and lifecycle calls that happen
|
||||
to live under the `player_` command prefix. The principle is about *who is
|
||||
authoritative for playback state* — settings CRUD isn't that.
|
||||
|
||||
The audit's read: the rule as written is violated 52 times, which makes real
|
||||
drift indistinguishable from acceptable usage, and that ambiguity is what lets
|
||||
group (a) persist. Note also that the principle **is** well-honoured where it
|
||||
matters most — the read side is clean, with UI reading state exclusively from
|
||||
the facade's re-exported stores. The write side is what drifted.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Frontend-internal refactor. No domain logic moves and nothing new crosses IPC —
|
||||
the same Rust commands are called, through one module instead of many.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Playback command dispatch (adapter routing: native vs HTML5) | Frontend — `src/lib/player/` **only** | Presentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 `<video>` adapter. A caller bypassing it silently skips that routing. |
|
||||
| Playback *authority* (position, pause, rate, track changes) | **Rust / the player** | Unchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction. |
|
||||
| Queue mutation commands | Frontend facade → Rust | Rust owns queue state; the facade is the single call path to it. |
|
||||
| Player settings CRUD (EQ, video, autoplay, cache) | Frontend, **outside** the facade | Configuration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below. |
|
||||
| Backend→frontend event handling | `playerEvents.ts` | Already correct. It is the facade's own plumbing, not a bypassing consumer. |
|
||||
|
||||
No Jellyfin taxonomy is involved, so no boundary-leak risk.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Narrow the principle to what it actually means
|
||||
|
||||
Amend CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md):
|
||||
|
||||
> **Unified player boundary.** UI controls **playback** — transport, queue
|
||||
> mutation, track selection, playback initiation — *only* through
|
||||
> `playerController`. Player **configuration** commands (`player_*_settings`,
|
||||
> `player_configure_jellyfin`, `player_*_cache_config`, `player_preload_upcoming`)
|
||||
> are ordinary IPC and may be called directly from settings surfaces.
|
||||
|
||||
This is a clarification, not a relaxation: it makes group (c) explicitly fine so
|
||||
that a violation count means something. A rule with 52 nominal violations, most
|
||||
of them acceptable, provides no signal.
|
||||
|
||||
### 2. Fill the facade gaps (group b)
|
||||
|
||||
Add to `playerController`, each a thin pass-through preserving current
|
||||
behaviour:
|
||||
|
||||
```ts
|
||||
playQueue, getQueue, getStatus,
|
||||
enterBackgroundAudio, exitBackgroundAudio,
|
||||
setSleepTimer, cancelSleepTimer,
|
||||
playNextEpisode, cancelAutoplayCountdown,
|
||||
```
|
||||
|
||||
Do this **first** — group (a) cannot be fully migrated while callers still need
|
||||
a direct import for a neighbouring call, and a file that imports `commands` for
|
||||
one reason will keep using it for others.
|
||||
|
||||
### 3. Migrate group (a)
|
||||
|
||||
Mechanical: replace `commands.playerX(...)` with `playerController.x(...)`.
|
||||
Highest-value first: `queue.ts` (10 sites, all direct facade equivalents), then
|
||||
`player/[id]/+page.svelte`, `VideoPlayer.svelte`, `sleepTimer.ts`,
|
||||
`playbackMode.ts`, `library/[id]/+page.svelte`.
|
||||
|
||||
Two sites need care rather than substitution:
|
||||
|
||||
- **`playerEvents.ts`** (`playerOnPlaybackEnded`, `playerStop` in the error
|
||||
path). This module *is* the facade's event plumbing — the counterpart to
|
||||
`index.ts`, inside the boundary conceptually though not by directory. Treat
|
||||
`src/lib/services/playerEvents.ts` as **inside** the boundary and exempt it,
|
||||
rather than making it call the facade that calls back into it. Record this in
|
||||
the lint config with the reason.
|
||||
- **`VideoPlayer.svelte`** — registers its own adapter via `setActiveAdapter`.
|
||||
Its `playerStop`/`playerPlayItem` calls interact with adapter lifecycle, and
|
||||
CLAUDE.md's gotcha ("no lifecycle calls after an `await` in `onMount`") applies.
|
||||
Migrate this file **last and on its own**, so an Android seek regression is
|
||||
bisectable to one commit.
|
||||
|
||||
### 4. Add the lint rule (the part that makes it stick)
|
||||
|
||||
The audit's finding was that principles with working checks held up and
|
||||
principles without them drifted. This principle has no check. Add
|
||||
`scripts/check-player-boundary.sh`, wired as `bun run check:player-boundary` and
|
||||
into `test-all.sh`:
|
||||
|
||||
```sh
|
||||
# Playback-control commands that MUST go through the facade.
|
||||
CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'
|
||||
|
||||
# Inside the boundary: the facade and its event plumbing.
|
||||
EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'
|
||||
```
|
||||
|
||||
Flag `commands.$CONTROL` in non-test `src/` files outside `EXEMPT`. Config
|
||||
commands are deliberately absent from the list, matching §1 — so the check
|
||||
encodes the narrowed rule rather than the aspirational one.
|
||||
|
||||
An ESLint `no-restricted-syntax` rule would give better editor feedback, but the
|
||||
project has no ESLint config; a shell check matches the existing
|
||||
`check:boundary` precedent and adds no dependency.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing playback *behaviour* — pure refactor.
|
||||
- The one-directional state principle (audited clean; UI reads from facade
|
||||
stores only).
|
||||
- Moving settings CRUD behind the facade (§1 explicitly carves it out).
|
||||
- Introducing ESLint.
|
||||
- Refactoring `VideoPlayer.svelte`'s 2079 lines generally, beyond its facade
|
||||
call sites.
|
||||
- The `commands.player*` calls **inside** `src/lib/player/` — that is the
|
||||
facade doing its job.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `playerController` exposes the group-(b) methods listed in §2.
|
||||
- [ ] `grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts`
|
||||
returns **only** configuration commands per §1 — no transport, queue, or
|
||||
playback-initiation call.
|
||||
- [ ] `queue.ts` no longer imports `commands` from bindings.
|
||||
- [ ] `bun run check:player-boundary` exists, is wired into `test-all.sh`, and
|
||||
passes.
|
||||
- [ ] The check **fails** when a `commands.playerStop()` is added to a non-exempt
|
||||
file — verify explicitly, as with the other gates in this batch.
|
||||
- [ ] The check does **not** fail on `commands.playerSetAudioSettings()` in
|
||||
`settings/+page.svelte` (the §1 carve-out works).
|
||||
- [ ] CLAUDE.md and `02-svelte-frontend.md` carry the narrowed wording, including
|
||||
the config carve-out and the `playerEvents.ts` exemption with its reason.
|
||||
- [ ] **No behavioural change**: audio and video playback, queue reorder,
|
||||
shuffle/repeat, sleep timer, background audio, and autoplay all behave as
|
||||
before on **both Linux and Android**.
|
||||
- [ ] Android seek and `onMount` lifecycle still correct after the
|
||||
`VideoPlayer.svelte` migration (the known-fragile path).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] Changed code carries `// TRACES:` comments.
|
||||
- [ ] No Rust change, so no `bindings.ts` regeneration.
|
||||
|
||||
## Testing
|
||||
|
||||
**Frontend** (`bun run test`):
|
||||
- Extend the existing facade tests to cover each new group-(b) method: it
|
||||
forwards to the right command with the right arguments, and routes to the
|
||||
active adapter where applicable.
|
||||
- `queue.ts` tests: assert calls land on `playerController`, not `commands`. Mock
|
||||
the facade — a test that mocks `commands` would pass either way and guard
|
||||
nothing.
|
||||
- Keep `tauriIntegration.test.ts` and the other IPC param-naming tests green;
|
||||
they cover the camelCase rule this refactor must not disturb.
|
||||
|
||||
**Manual** (no automated coverage for these paths):
|
||||
- Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer,
|
||||
transcoded video (HLS), background audio enter/exit.
|
||||
- Android: the same, plus lockscreen/MediaSession controls, and **seek after
|
||||
entering the player** — the specific regression CLAUDE.md warns about.
|
||||
|
||||
Because this is a pure refactor, the strongest signal is that no test *changes
|
||||
expectation*. A test needing its assertions rewritten means behaviour moved —
|
||||
investigate rather than update it.
|
||||
|
||||
## TRACES
|
||||
|
||||
Allocate in `requirements.md`:
|
||||
|
||||
- **DR-095** — "UI playback control is routed exclusively through the
|
||||
`playerController` facade (`src/lib/player/`), with `playerEvents.ts` inside
|
||||
the boundary as its event plumbing and player *configuration* commands
|
||||
explicitly outside it; enforced by `scripts/check-player-boundary.sh`."
|
||||
Category: Player. Traces to UR-005. Status: Done on merge.
|
||||
|
||||
```typescript
|
||||
// src/lib/player/index.ts
|
||||
// TRACES: UR-005 | DR-095
|
||||
```
|
||||
|
||||
New facade tests take `@req-test: UT-089` onward (next free UT is **UT-089**;
|
||||
coordinate if landing alongside the sibling specs, which draw from the same
|
||||
pool).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Order matters**: §2 (fill gaps) → §3 (migrate, `VideoPlayer.svelte` last and
|
||||
alone) → §4 (add the check). Adding the check first turns `master` red.
|
||||
- 🔴 **`VideoPlayer.svelte`**: no lifecycle calls after an `await` in `onMount` —
|
||||
it flips to HTML5 mode and breaks Android seek. Do not let a mechanical
|
||||
substitution introduce an `await` before a lifecycle call.
|
||||
- The facade's `requireHandle()` may throw where a raw `commands` call did not.
|
||||
Check each migrated call site's error handling rather than assuming the
|
||||
try/catch still covers the same cases.
|
||||
- `playbackMode.ts` interacts with remote-mode routing (`play_on_session` vs
|
||||
local MPV). Verify remote casting still works after migrating its
|
||||
`playerPlayTracks` call.
|
||||
- This spec is deliberately the *lowest* priority of the audit batch: it is the
|
||||
largest diff and the only one carrying real regression risk, while the
|
||||
traceability gate is a few lines and restores a dead safety net.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Spec: Remove the broken `check-req-coverage.sh`
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** supports DR-093 (see [traceability-gate-repair.md](traceability-gate-repair.md))
|
||||
**UX spec:** n/a — developer tooling.
|
||||
**Supersedes / revises:** n/a
|
||||
|
||||
## Summary
|
||||
|
||||
`scripts/check-req-coverage.sh` is broken, orphaned, and actively misleading: it
|
||||
reports `Total Requirements: 1`, zeros in every category, and then prints
|
||||
**"✨ All requirements have implementations!"**. Nothing references it — not CI,
|
||||
not `package.json`, not the docs. This spec deletes it, with a narrowly-scoped
|
||||
alternative (repair it) documented and rejected below.
|
||||
|
||||
## Motivation
|
||||
|
||||
Running it today produces:
|
||||
|
||||
```
|
||||
Category Breakdown:
|
||||
UR: 0 requirements
|
||||
IR: 0 requirements
|
||||
DR: 0 requirements
|
||||
JA: 0 requirements
|
||||
|
||||
Summary:
|
||||
Total Requirements: 1
|
||||
✅ Fully Implemented: 0 (0%)
|
||||
|
||||
✨ All requirements have implementations!
|
||||
```
|
||||
|
||||
Every number is wrong (the real totals are UR 61, IR 29, DR 89, JA 32), and the
|
||||
concluding message is the *opposite* of a warning — a developer running this to
|
||||
sanity-check coverage is told everything is fine.
|
||||
|
||||
This is worse than having no script. It is a trap, and it sits in `scripts/`
|
||||
next to tools that do work, with nothing marking it as dead.
|
||||
|
||||
Verification that it is genuinely orphaned:
|
||||
|
||||
```console
|
||||
$ grep -rn "check-req-coverage" . --include='*.yml' --include='*.json' \
|
||||
--include='*.sh' --include='*.md' | grep -v node_modules
|
||||
(no output)
|
||||
```
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Developer tooling only; no application logic and nothing crosses the IPC
|
||||
boundary.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Requirement-coverage reporting | Build tooling — `extract-traces.ts` | One tool should own coverage analysis. A second, divergent implementation is how the two answers ("1 requirement" vs "211") came to disagree unnoticed. |
|
||||
|
||||
## Design
|
||||
|
||||
**Delete `scripts/check-req-coverage.sh`.**
|
||||
|
||||
Coverage reporting is owned by [scripts/extract-traces.ts](../../scripts/extract-traces.ts),
|
||||
which is correct, is what CI runs, and gains a first-class local coverage mode
|
||||
in [traceability-gate-repair.md](traceability-gate-repair.md):
|
||||
|
||||
```bash
|
||||
bun run traces:coverage # the supported way to check coverage locally
|
||||
```
|
||||
|
||||
Then check the sibling scripts for the same rot. `scripts/` also contains
|
||||
`check-test-coverage.sh` and `find-req-implementations.sh`, neither of which is
|
||||
referenced from `package.json`. An unreferenced script is never run and so rots
|
||||
silently — that is the actual failure mode being fixed here, and fixing only the
|
||||
one instance found by audit leaves the others to be rediscovered later.
|
||||
|
||||
### Findings (investigation, 2026-07)
|
||||
|
||||
All three scripts turned out to share a **single root cause**, and all three are
|
||||
deleted:
|
||||
|
||||
| Script | Defect |
|
||||
|---|---|
|
||||
| `check-req-coverage.sh` | Reads `README.md`, which has held **zero** requirement rows since they moved to `docs/requirements.md` → `total_reqs=1`, every category 0, "✨ All requirements have implementations!" Also greps `src-tauri/` unscoped. |
|
||||
| `check-test-coverage.sh` | Greps `src-tauri/` unscoped — including **40 GB** of `target/` build artifacts. Hangs indefinitely; produces no output at all. |
|
||||
| `find-req-implementations.sh` | Same unscoped `src-tauri/` grep. Same hang. |
|
||||
|
||||
So none of them were subtly wrong — two could never terminate, and the third
|
||||
inverted its own conclusion.
|
||||
|
||||
They were nonetheless *salvageable*: scoping the greps to `src-tauri/src` and
|
||||
repointing at `docs/requirements.md` would be a few lines, and the `@req:` /
|
||||
`@req-test:` tags they read are still present in the tree (**146** and **76**
|
||||
occurrences).
|
||||
|
||||
**Decision: delete all three anyway.** The tags are an undocumented parallel
|
||||
convention — `@req:` appears in no doc, and CLAUDE.md describes only `TRACES:`.
|
||||
Repairing the scripts would re-establish a second traceability system to keep in
|
||||
sync with the first, which is the same two-sources-of-truth condition that let
|
||||
"1 requirement" and "211 requirements" coexist unnoticed. `TRACES:` plus the
|
||||
repaired coverage engine ([traceability-gate-repair.md](traceability-gate-repair.md))
|
||||
already cover this ground.
|
||||
|
||||
The existing `@req:` / `@req-test:` comments are left in place: they are
|
||||
harmless as prose, several encode genuinely useful test intent, and stripping
|
||||
222 comments across the tree is a large diff with no functional gain. They are
|
||||
simply no longer read by any tool.
|
||||
|
||||
### Alternative considered: repair rather than delete
|
||||
|
||||
Rejected. The script's output format duplicates what `traces:markdown` already
|
||||
generates, it has no tests, no caller, and no documented purpose distinct from
|
||||
`extract-traces.ts`. Repairing it recreates the two-sources-of-truth condition
|
||||
that produced the contradiction. If a shell-based coverage check is ever wanted,
|
||||
it should shell out to `traces:json` and `jq` rather than re-parse
|
||||
`requirements.md` independently.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The CI workflow denominators — [traceability-gate-repair.md](traceability-gate-repair.md).
|
||||
- Any change to `extract-traces.ts`'s output (that spec owns it).
|
||||
- Auditing scripts that *are* referenced from `package.json` — they run
|
||||
regularly and would fail visibly.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `scripts/check-req-coverage.sh` no longer exists.
|
||||
- [ ] `grep -rn "check-req-coverage" .` (excluding `node_modules` and this spec)
|
||||
returns nothing — no dangling reference in CI, docs, or `package.json`.
|
||||
- [ ] `scripts/check-test-coverage.sh` and `find-req-implementations.sh` have each
|
||||
been run and either wired into `package.json` or deleted; the decision and
|
||||
reason are recorded in `scripts/README.md`. **Outcome: all three deleted —
|
||||
see Findings.**
|
||||
- [ ] `scripts/README.md` documents `bun run traces:coverage` as the supported
|
||||
way to check requirement coverage locally.
|
||||
- [ ] `bun run test:all` passes (confirms nothing invoked the deleted script).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit tests — this is a deletion. Verification is the grep in the acceptance
|
||||
criteria plus a green `bun run test:all`, which exercises the script paths that
|
||||
actually run.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new requirement. The deletion is covered by **DR-093**
|
||||
([traceability-gate-repair.md](traceability-gate-repair.md)), which establishes
|
||||
`extract-traces.ts` as the single owner of coverage reporting. Note the removal
|
||||
in that DR's text when both land.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- Land this **after** or alongside [traceability-gate-repair.md](traceability-gate-repair.md),
|
||||
so `bun run traces:coverage` exists before the broken script is removed and
|
||||
developers are never left without a coverage command.
|
||||
- Check `docs/traceability-ci.md` and `docs/traces-quick-ref.md` for prose
|
||||
references to the deleted script; the grep above covers `.md`, but read the
|
||||
surrounding sentence rather than deleting the line mechanically.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Spec: Land the scoped-search boundary fix (implementation)
|
||||
|
||||
**Status:** Stage 1 Implemented — Stage 2 (result-side grouping) outstanding
|
||||
**Requirements:** UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs)
|
||||
**UX spec:** n/a — zero user-visible change is the point (see Acceptance criteria).
|
||||
**Supersedes / revises:** implements [scoped-search-boundary.md](scoped-search-boundary.md),
|
||||
which specified this fix but was never built. That spec remains the **design
|
||||
authority**; this one is the delivery plan and status correction.
|
||||
|
||||
## Summary
|
||||
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) diagnosed a domain-taxonomy
|
||||
leak, specified the fix in full detail, and became the justification for the
|
||||
project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the
|
||||
spec-review checklist. **The fix was never implemented.** The leak it describes
|
||||
is still live in `main`. This spec exists to close that gap and to correct the
|
||||
record — the codebase currently enforces a rule against a violation it still
|
||||
contains.
|
||||
|
||||
## Motivation
|
||||
|
||||
The mapping the rule forbids is present and in use:
|
||||
|
||||
```ts
|
||||
// src/lib/utils/searchScope.ts:29-32
|
||||
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is not dead code. [library.ts:262](../../src/lib/stores/library.ts#L262)
|
||||
calls `scopeItemTypes(scope)` and puts the result straight into
|
||||
`options.includeItemTypes`. Meanwhile there is **no `SearchScope` anywhere in
|
||||
`src-tauri/`**:
|
||||
|
||||
```console
|
||||
$ grep -rn "SearchScope" src-tauri/src --include='*.rs'
|
||||
(no output)
|
||||
```
|
||||
|
||||
Three things make this the highest-value item found in the design-principles
|
||||
audit:
|
||||
|
||||
1. **The rule's own founding incident is unremediated.** CLAUDE.md cites this
|
||||
spec as "the incident this rule came from." A rule whose originating
|
||||
violation is still shipping is not credible.
|
||||
2. **The tripwire cannot see it.** `bun run check:boundary` passes — it greps for
|
||||
a multi-type array literal *at the query site*, and this one is assigned to a
|
||||
named const and dereferenced elsewhere. Broadening the tripwire is specified
|
||||
separately in [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md);
|
||||
note that hardening it **without** landing this fix would turn `master` red.
|
||||
3. **The spec's own acceptance criterion fails today.** "Adding a hypothetical
|
||||
new type to a scope requires editing only Rust" — adding a type to the Music
|
||||
scope right now requires editing `searchScope.ts`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Unchanged from [scoped-search-boundary.md](scoped-search-boundary.md) §Design;
|
||||
restated so this spec is reviewable on its own.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Scope → Jellyfin item types (`music` → `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | **Rust** | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed. |
|
||||
| Result item → search group bucketing | **Rust** | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout. |
|
||||
| `All` sends no filter at all (≠ union of enumerated types) | **Rust** | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies. |
|
||||
| Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side. |
|
||||
| `resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is. |
|
||||
| Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum. |
|
||||
| `GROUP_SCOPE` (which group belongs to which scope) | **Delete** | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to *gone*. |
|
||||
|
||||
The `SearchScope` and `SearchGroupId` **types** come to the frontend from
|
||||
generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing
|
||||
what item types it expands to is.
|
||||
|
||||
## Design
|
||||
|
||||
**Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Design as
|
||||
written** — `SearchScope` enum + `item_types()` in `repository/types.rs`,
|
||||
`SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
||||
scope-wins precedence, `All` → `None` → no filter. It is not restated here;
|
||||
duplicating it would create two drifting copies of the same design.
|
||||
|
||||
This spec adds only the delivery sequencing that the original left implicit.
|
||||
|
||||
### Staging: land it in two reviewable pieces
|
||||
|
||||
The original bundles the query side and the result side into one change. That is
|
||||
a large diff touching Rust types, `bindings.ts`, the store, and a component, with
|
||||
the `search-event` dual-payload hazard in the middle. Split it:
|
||||
|
||||
**Stage 1 — query side (closes the leak).**
|
||||
`SearchScope` enum, `SearchOptions.scope`, command resolves scope →
|
||||
`include_item_types` in Rust, `library.ts` sends `{ scope }`, delete
|
||||
`SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.
|
||||
|
||||
After Stage 1 the actual boundary violation is gone and
|
||||
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md) can land safely.
|
||||
|
||||
**Stage 2 — result side.** `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
||||
Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk,
|
||||
`GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.
|
||||
|
||||
Both stages are required for the original spec's acceptance criteria to pass;
|
||||
Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. **Stage 1 is not a
|
||||
stopping point** — it is a review boundary. Do not mark the parent spec
|
||||
Implemented until Stage 2 lands.
|
||||
|
||||
### Stage 1 — delivered (July 2026)
|
||||
|
||||
- `SearchScope` enum + `item_types()` in [repository/types.rs](../../src-tauri/src/repository/types.rs);
|
||||
`All` → `None` → no filter.
|
||||
- `SearchOptions.scope` with `resolve_scope()`; scope wins over
|
||||
`include_item_types`, which stays for the non-search `get_items` callers.
|
||||
- `repository_search` resolves the scope **once, before** the cache/server split,
|
||||
so both phases filter identically.
|
||||
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now
|
||||
re-exports `SearchScope` from the generated bindings instead of a hand-written
|
||||
union.
|
||||
- [library.ts](../../src/lib/stores/library.ts) sends `{ scope }`.
|
||||
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the
|
||||
*opaque scope* is sent rather than an item-type list.
|
||||
|
||||
Verified: adding `"AudioBook"` to the Music scope changed **zero** files under
|
||||
`src/` — the criterion that failed before this work.
|
||||
|
||||
**Stage 2 remains open**: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side
|
||||
bucketing, single-type-per-group) are still in `searchScope.ts`, and both search
|
||||
payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.
|
||||
|
||||
### 🔴 The `search-event` dual payload (Stage 2)
|
||||
|
||||
The original flags this as "the single largest part of the change and the
|
||||
easiest to half-do." Restating because it is the one thing that silently breaks:
|
||||
search resolves **twice** — the command returns instant cache results, then the
|
||||
merged cache+server union arrives via `search-event`. Both payloads must carry
|
||||
`GroupedSearchResult`. Convert one and the UI flickers between shapes as server
|
||||
results land.
|
||||
|
||||
Write the failing test for the *event* payload first — the command return is the
|
||||
obvious half, the event is the half that gets forgotten.
|
||||
|
||||
### Note on `SearchOptions.scope` and specta
|
||||
|
||||
`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with
|
||||
`skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>`
|
||||
following that pattern so `All`/absent omits the key. Regenerate `bindings.ts`
|
||||
— `SearchOptions` there is currently
|
||||
`{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never
|
||||
hand-edit it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning anything in [scoped-search-boundary.md](scoped-search-boundary.md).
|
||||
If implementation shows the design wrong, revise **that** spec, don't fork it.
|
||||
- Online/offline `include_item_types` **filtering** — already correct; only the
|
||||
source of the type list moves.
|
||||
- Ranking within or across groups (DR-090 territory).
|
||||
- Chip UX, scope persistence, group-order persistence — unchanged.
|
||||
- The two lesser type-set sites in `DownloadedBrowse.svelte` and
|
||||
`GenericMediaListPage.svelte`, handled in
|
||||
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md).
|
||||
- Broadening the tripwire itself — same sibling spec.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Inherits every criterion from [scoped-search-boundary.md](scoped-search-boundary.md)
|
||||
§Acceptance criteria. Additionally:
|
||||
|
||||
- [ ] `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches —
|
||||
the enum exists in Rust (it does not today).
|
||||
- [ ] `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts`
|
||||
returns nothing.
|
||||
- [ ] `grep -rn "scopeItemTypes" src/` returns nothing — including the
|
||||
`library.ts` import and call site.
|
||||
- [ ] `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not
|
||||
hand-edited.
|
||||
- [ ] **Behaviour is byte-identical for the user**: same scoping, same groups,
|
||||
same order, same empty-group omission, offline included. This spec is a
|
||||
pure refactor — any visible change is a defect.
|
||||
- [ ] `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by
|
||||
inspection).
|
||||
- [ ] Adding a type to the Music scope requires editing **only** Rust —
|
||||
demonstrate by making the edit and confirming no `src/` file changes.
|
||||
- [ ] `scoped-search-boundary.md` status flips to **Implemented**, and
|
||||
`scoped-search.md`'s "frontend only, no Rust changes" framing gets a
|
||||
banner pointing at the corrected design.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] Changed code carries `// TRACES:` comments (IDs below).
|
||||
|
||||
## Testing
|
||||
|
||||
Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Testing. Emphases:
|
||||
|
||||
**Rust** (`cargo test`):
|
||||
- `SearchScope::item_types()` per scope; `All` → `None`.
|
||||
- Scope resolution happens **before** the online/offline split, so both paths
|
||||
get the same filter — a regression here is invisible until someone searches
|
||||
offline.
|
||||
- `scope` set + `include_item_types` set → scope wins (the documented
|
||||
precedence; assert it rather than trusting the doc).
|
||||
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped;
|
||||
canonical group order; **the `search-event` payload is the grouped shape**.
|
||||
|
||||
**Frontend** (`bun run test`):
|
||||
- `resolveSearchScope()` tests in `searchScope.test.ts` must pass **unchanged** —
|
||||
they cover the part that is not moving, and are the regression net proving the
|
||||
refactor didn't disturb routing.
|
||||
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
|
||||
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type`
|
||||
inspection in the implementation.
|
||||
|
||||
**Offline parity:** run a scoped search with the server unreachable and confirm
|
||||
identical grouping. The offline repository path honours `include_item_types`
|
||||
independently, and this is the case most likely to be missed.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new requirement IDs — this implements existing ones. Retag as the code moves:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/repository/types.rs
|
||||
/// TRACES: UR-049 | DR-063
|
||||
pub enum SearchScope { … }
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/searchScope.ts — keep the file header; it retains
|
||||
// resolveSearchScope + group-order presentation logic.
|
||||
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
||||
```
|
||||
|
||||
Update DR-063's text in `requirements.md` to state that scope expansion is owned
|
||||
by Rust, so the requirement stops describing the leaked design. New Rust tests
|
||||
take `@req-test: UT-089` onward (next free UT is **UT-089**).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Read [scoped-search-boundary.md](scoped-search-boundary.md) first.** This
|
||||
spec is deliberately thin on design; that one is the authority.
|
||||
- Sequence with the sibling specs: **Stage 1 here → then
|
||||
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md)**. Hardening
|
||||
the tripwire first turns `master` red on a known-unfixed violation.
|
||||
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look
|
||||
before starting — understanding why the fix stalled may surface a constraint
|
||||
the spec didn't record.
|
||||
- The user-visible-change count for this spec is zero. If QA reports a
|
||||
difference in search results, that is a bug in the refactor, not an
|
||||
improvement.
|
||||
@@ -8,8 +8,14 @@
|
||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||
> changes. Read the boundary spec before touching search code.
|
||||
>
|
||||
> **Progress:** the scope→item-type mapping now lives in Rust
|
||||
> (`SearchScope::item_types()`); the frontend sends an opaque scope. Result-side
|
||||
> bucketing (`GROUP_ITEM_TYPES`) is still frontend-side — see
|
||||
> [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
|
||||
> §Stage 2.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**Status:** Implemented (boundary revision: query side done, result side pending)
|
||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||
(see [requirements.md](../requirements.md)).
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Spec: Repair the traceability coverage gate
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** DR-093 → supports the traceability practice described in CLAUDE.md
|
||||
**UX spec:** n/a — developer tooling, no user-facing surface.
|
||||
**Supersedes / revises:** n/a
|
||||
|
||||
## Summary
|
||||
|
||||
The CI traceability gate has been passing unconditionally for an unknown length
|
||||
of time because it divides traced-requirement counts by **hardcoded denominators
|
||||
that no longer match [requirements.md](../requirements.md)**. It currently
|
||||
reports **158% overall coverage** (and `JA 24 / 3 = 800%`), so the 50% threshold
|
||||
is mathematically unreachable and the job cannot fail. This spec makes the gate
|
||||
derive its denominators from `requirements.md` at run time, so it reports the
|
||||
real number (**85%** today) and can actually fail again.
|
||||
|
||||
## Motivation
|
||||
|
||||
`.gitea/workflows/traceability-check.yml` hardcodes `UR/39, IR/24, DR/48, JA/3`
|
||||
and `TOTAL_REQS=114`. The real counts are **UR 61, IR 29, DR 89, JA 32 — 211
|
||||
total**. Requirements were added over time; the divisors were never updated.
|
||||
|
||||
The consequence is not a cosmetic reporting bug. The gate is the *only*
|
||||
automated defence for the traceability practice, and it is dead:
|
||||
|
||||
```
|
||||
CI today: 181 / 114 = 158% → threshold 50% can never trip
|
||||
Reality: 181 / 211 = 85% → healthy, but unguarded
|
||||
```
|
||||
|
||||
Coverage could collapse to 30% and CI would still print a green
|
||||
"✅ Coverage is acceptable". An audit of the design principles found that every
|
||||
principle with a *working* automated check is in good shape, and the ones that
|
||||
drifted are exactly the ones whose checks were broken or too narrow — this is
|
||||
the clearest instance.
|
||||
|
||||
A second, related defect is handled in a sibling spec: `scripts/check-req-coverage.sh`
|
||||
is separately broken and orphaned (see
|
||||
[req-coverage-script-removal.md](req-coverage-script-removal.md)).
|
||||
|
||||
## Layer assignment
|
||||
|
||||
This spec touches only CI/build tooling — no application logic crosses the
|
||||
Rust/Svelte boundary. The table is filled in for completeness.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Counting requirement IDs defined in `requirements.md` | Build tooling (`scripts/`) | Neither runtime layer; it is repo metadata analysis. Belongs beside `extract-traces.ts`, not in the workflow YAML, so it is runnable and testable locally. |
|
||||
| Counting *traced* requirement IDs | Build tooling — existing `extract-traces.ts` | Already implemented and correct; this spec consumes it rather than duplicating it. |
|
||||
| Threshold policy (the 50% number) | CI workflow | Deployment policy, not analysis. Keeping it in YAML lets it be tuned without touching the script. |
|
||||
|
||||
No frontend or Rust logic is added, so no taxonomy leak is possible.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Denominators come from `requirements.md`, not literals
|
||||
|
||||
`requirements.md` defines requirements in markdown tables with a stable leading
|
||||
cell, e.g.:
|
||||
|
||||
```
|
||||
| DR-001 | Player state machine (idle, loading, …) | Player | UR-005 | Done |
|
||||
| UR-002 | Access media when online or offline | High | Done |
|
||||
```
|
||||
|
||||
Extend [scripts/extract-traces.ts](../../scripts/extract-traces.ts) to also emit
|
||||
the *defined* counts, so one tool owns both sides of the fraction and CI does no
|
||||
arithmetic on stale literals. Add a `defined` key to the JSON report:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"byType": { "UR": [...], "IR": [...], "DR": [...], "JA": [...] }, // traced (existing)
|
||||
"defined": { "UR": 61, "IR": 29, "DR": 89, "JA": 32 }, // NEW
|
||||
"coverage": { "covered": 181, "total": 211, "percent": 85 }, // NEW
|
||||
"requirements": { ... }, // existing
|
||||
"totalTraces": 318, "totalFiles": …, "timestamp": "…" // existing
|
||||
}
|
||||
```
|
||||
|
||||
Parsing rule for a *defined* requirement: a line in `docs/requirements.md`
|
||||
matching `^\|\s*(UR|IR|DR|JA)-\d{3}\s*\|` — the ID must be the table's first
|
||||
cell. This deliberately does **not** count IDs mentioned in the `Traces To`
|
||||
column or in prose, which is why a naive `grep -o` over the whole file
|
||||
overcounts.
|
||||
|
||||
`defined` counts IDs that exist in the spec; `byType` counts IDs that appear in
|
||||
a `TRACES:` comment somewhere in the source. Coverage is
|
||||
`|byType ∩ defined| / |defined|`.
|
||||
|
||||
> **Intersection, not raw length.** A `TRACES:` comment naming an ID that
|
||||
> `requirements.md` does not define (a typo, or a requirement later deleted)
|
||||
> must **not** inflate the numerator — that is how a ratio exceeds 100% in the
|
||||
> first place. Such IDs are reported separately as `orphaned` so they get fixed
|
||||
> rather than silently counted or silently dropped.
|
||||
|
||||
```jsonc
|
||||
"orphaned": ["DR-097"] // traced in code but not defined in requirements.md
|
||||
```
|
||||
|
||||
### 2. The workflow consumes the computed number
|
||||
|
||||
Replace the arithmetic in `.gitea/workflows/traceability-check.yml` (lines
|
||||
46–76) with reads of the precomputed fields:
|
||||
|
||||
```sh
|
||||
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
||||
COVERED=$(jq '.coverage.covered' traces-report.json)
|
||||
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
||||
|
||||
for T in UR IR DR JA; do
|
||||
TRACED=$(jq --arg t "$T" '.byType[$t] | length' traces-report.json)
|
||||
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
||||
echo " $T: $TRACED / $DEFINED"
|
||||
done
|
||||
|
||||
MIN_THRESHOLD=50
|
||||
[ "$COVERAGE" -lt "$MIN_THRESHOLD" ] && { echo "❌ …"; exit 1; }
|
||||
```
|
||||
|
||||
No hardcoded denominator survives anywhere in the workflow.
|
||||
|
||||
### 3. A self-check so this cannot silently rot again
|
||||
|
||||
The root cause was a number that drifted with nothing watching it. Add a
|
||||
guard that fails the job on an arithmetically impossible result:
|
||||
|
||||
```sh
|
||||
if [ "$COVERAGE" -gt 100 ]; then
|
||||
echo "❌ Coverage > 100% — the gate is miscomputing; orphaned IDs: $(jq -c '.orphaned' traces-report.json)"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
A >100% reading is now a hard failure rather than a green tick.
|
||||
|
||||
### 4. Local parity
|
||||
|
||||
Add a script so the gate is runnable outside CI:
|
||||
|
||||
```jsonc
|
||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage"
|
||||
```
|
||||
|
||||
Prints the same table CI prints and exits non-zero below threshold.
|
||||
|
||||
### Threshold
|
||||
|
||||
Keep `MIN_THRESHOLD=50` in this spec. Real coverage is 85%, so raising the bar
|
||||
is tempting, but doing it in the same change that repairs the gate conflates
|
||||
"restore the safety net" with "tighten the policy" — if the build then fails, it
|
||||
is ambiguous which change caused it. Ratcheting is deliberately deferred to
|
||||
follow-up work once the honest number has been observed on `master` for a few
|
||||
builds.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Raising `MIN_THRESHOLD` above 50 (see above).
|
||||
- Fixing/removing `scripts/check-req-coverage.sh` — [req-coverage-script-removal.md](req-coverage-script-removal.md).
|
||||
- Adding TRACES comments to raise the actual coverage number.
|
||||
- Changing the `TRACES:` comment format or the extractor's parsing of it.
|
||||
- The PR "modified files missing TRACES" step (lines 78–126), which is advisory
|
||||
by design and stays advisory.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `bun run traces:json` emits `defined`, `coverage`, and `orphaned` keys.
|
||||
- [ ] `coverage.total` equals the count of requirement IDs defined in
|
||||
`requirements.md` (**211** at time of writing), not a literal.
|
||||
- [ ] `coverage.percent` reports **85** (±1 for rounding) on the current tree —
|
||||
i.e. the honest number, not 158.
|
||||
- [ ] No hardcoded requirement denominator (`39`, `24`, `48`, `3`, `114`) remains
|
||||
in `.gitea/workflows/traceability-check.yml`. Verify:
|
||||
`grep -nE '/ *(39|24|48|3|114)\b' .gitea/workflows/traceability-check.yml`
|
||||
returns nothing.
|
||||
- [ ] Adding a new requirement row to `requirements.md` **lowers** reported
|
||||
coverage until it is traced (proves the denominator is live).
|
||||
- [ ] A `TRACES:` comment naming an undefined ID appears in `orphaned` and does
|
||||
**not** raise `coverage.percent`.
|
||||
- [ ] The job fails if coverage is forced below 50% (test by temporarily raising
|
||||
`MIN_THRESHOLD` to 99 locally) — proving the gate can fail again.
|
||||
- [ ] The job fails if coverage computes >100%.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] No Rust types changed, so no `bindings.ts` regeneration needed.
|
||||
|
||||
## Testing
|
||||
|
||||
`extract-traces.ts` currently has no test coverage. Add
|
||||
`scripts/extract-traces.test.ts` (vitest) over fixture strings rather than the
|
||||
live `requirements.md`, so the tests do not change meaning as requirements are
|
||||
added:
|
||||
|
||||
- **UT:** counts a well-formed table row as a defined requirement.
|
||||
- **UT:** does **not** count an ID appearing only in the `Traces To` column or
|
||||
in prose — the specific overcounting bug this parse rule avoids.
|
||||
- **UT:** coverage is the intersection — a traced-but-undefined ID lands in
|
||||
`orphaned` and does not inflate the numerator.
|
||||
- **UT:** coverage of an empty trace set is 0%, not a divide-by-zero.
|
||||
- **UT:** all-traced fixture reports exactly 100%, never above.
|
||||
|
||||
CI behaviour is verified by the acceptance criteria above (the forced-failure
|
||||
check is the important one — a gate nobody has watched fail is not known to
|
||||
work).
|
||||
|
||||
## TRACES
|
||||
|
||||
Allocate in `requirements.md`:
|
||||
|
||||
- **DR-093** — "Traceability coverage gate derives requirement denominators from
|
||||
`requirements.md` at run time (not hardcoded literals), computes coverage as
|
||||
the intersection of traced and defined IDs, reports IDs traced but undefined
|
||||
as orphaned, and fails on an impossible >100% result." Category: Tooling.
|
||||
Status: Done on merge.
|
||||
|
||||
Tag:
|
||||
|
||||
```typescript
|
||||
// scripts/extract-traces.ts
|
||||
// TRACES: | DR-093
|
||||
```
|
||||
|
||||
Tests carry `@req-test: UT-089 …` onward (next free UT is **UT-089**).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — run `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Do not add tooling to the CI image for this.** `jq` and `bun` are already in
|
||||
`jellytau-builder`; this spec needs nothing else. Installing a system package
|
||||
in a workflow step violates the hard CI rule in CLAUDE.md.
|
||||
- Keep `traces:json`'s existing keys intact — `release-notes.ts` and
|
||||
`traces:markdown` consume the same report, and the CI workflow uploads it as
|
||||
an artifact. This is an additive change.
|
||||
- The `head -50 docs/traceability.md` and artifact-upload steps are unaffected.
|
||||
- Expect the first green build after this change to print a *lower* number than
|
||||
before (85% vs 158%). That is the fix working, not a regression.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Spec: Windows native audio backend
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; new IR-030
|
||||
**UX spec:** n/a — Settings › Audio already renders the controls
|
||||
**Supersedes / revises:** acts on the "audio can unify, video cannot" conclusion in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Give Windows a real native audio backend instead of the current webview
|
||||
`<audio>` shim. Windows is the only platform where audio playback has no decoder
|
||||
of its own: `WebviewAudioBackend` hands a URL to a frontend `<audio>` element and
|
||||
relays transport commands. It cannot set volume, cannot apply any audio setting,
|
||||
and reports state only via DOM events.
|
||||
|
||||
Audio needs no rendering surface, so **none of the webview-compositing problems
|
||||
that block unified video apply here.** This is the cleanest available win.
|
||||
|
||||
## Motivation
|
||||
|
||||
`WebviewAudioBackend` was a deliberate stopgap ("audio-only playback for
|
||||
platforms without a native audio backend"), and it works — but it has a hard
|
||||
functional gap. From `webview_audio_backend.rs`:
|
||||
|
||||
```rust
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
// ...stores locally only; there is no ControlCommand action for volume
|
||||
}
|
||||
```
|
||||
|
||||
So volume changes never reach the element; the frontend has to observe the player
|
||||
store and apply volume itself. `set_audio_settings` likewise stores values that
|
||||
nothing consumes — EQ, normalization, and gapless are all inert on Windows.
|
||||
|
||||
Meanwhile the backend-unification investigation established that a native *audio*
|
||||
engine is unproblematic on Windows specifically: `tauri-plugin-libmpv` lists
|
||||
Windows as its **fully tested** platform (in contrast to Linux, where embedding
|
||||
is broken — but that is a *video surface* problem, which audio does not have).
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly. |
|
||||
| Applying `AudioSettings` (EQ/normalize/gapless) | Rust | Same `AudioSettings` contract as MPV/ExoPlayer; band layout and presets stay canonical in `settings.rs`. |
|
||||
| Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it. |
|
||||
| Volume | Rust | Currently broken precisely because it is split across the boundary. |
|
||||
| Rendering the player UI | Frontend | Unchanged. |
|
||||
|
||||
The strongest argument for this change is the third row. CLAUDE.md states
|
||||
playback state is one-directional with the player authoritative; on Windows that
|
||||
is currently false, and the `player_report_*` round-trip exists to paper over it.
|
||||
|
||||
## Design
|
||||
|
||||
### Engine choice
|
||||
|
||||
Two viable options; **libmpv is recommended** for consistency with the Linux
|
||||
audio backend.
|
||||
|
||||
| | libmpv | GStreamer |
|
||||
|---|---|---|
|
||||
| Windows status | ✅ `tauri-plugin-libmpv` reports fully tested | ✅ works, but… |
|
||||
| Rust bindings | `libmpv2` 6.0.0, active | `gstreamer-rs` 0.25.x, excellent |
|
||||
| Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ `gstreamer-sys` uses pkg-config, fights `cargo-xwin` |
|
||||
| Code reuse | ✅ `MpvBackend` logic is directly reusable | ❌ a second engine to learn |
|
||||
| Crossfade capable | ❌ single-stream chain | ✅ `audiomixer` |
|
||||
|
||||
libmpv wins on reuse: `MpvBackend`'s `set_audio_settings` — the `af` lavfi graph
|
||||
built by `build_af_filter`, `eq_filter_entries`, `normalize_filter_entry` — is
|
||||
platform-independent and would apply unchanged.
|
||||
|
||||
The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally
|
||||
cannot do. If crossfade becomes a priority, revisit; it would then argue for
|
||||
GStreamer on *both* Linux and Windows, which is a much larger change.
|
||||
|
||||
### Structure
|
||||
|
||||
Rename the cfg gate so `MpvBackend` is no longer Linux-only:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/player/mod.rs
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod mpv_backend;
|
||||
```
|
||||
|
||||
`MpvBackend::new` needs one platform-specific branch: `detect_audio_system()`
|
||||
currently probes `pactl`/`pw-cli`/`/proc/asound/cards` to pick an `ao`. On
|
||||
Windows the equivalent is `wasapi` (mpv's default), so the detection is a
|
||||
`#[cfg]` returning `"wasapi"` — no probing needed.
|
||||
|
||||
Everything else — the event loop, the 250ms position thread, the seek-suppression
|
||||
window, the `af` filter graph — is unchanged.
|
||||
|
||||
`WebviewAudioBackend` stays for other targets (macOS and anything else hitting
|
||||
the `not(any(...))` arm) and as the fallback if libmpv fails to initialize. The
|
||||
existing `emit_backend_init_failed` path already handles that gracefully.
|
||||
|
||||
### Build
|
||||
|
||||
`libmpv2-sys` is well-suited to cross-compilation: no pkg-config, vendored
|
||||
headers, pregenerated bindings (no libclang). It emits `cargo:rustc-link-lib=mpv`
|
||||
unconditionally, so the build must supply a linkable import library for
|
||||
`x86_64-pc-windows-msvc`.
|
||||
|
||||
Keep the `build_libmpv` feature **off** — its Unix path shells out to mpv-build
|
||||
and explicitly rejects cross-compilation.
|
||||
|
||||
🔴 Per CLAUDE.md, the prebuilt libmpv **must be added to the builder image**
|
||||
(`Dockerfile.builder` → rebuild + push via `scripts/build-builder-image.sh`), not
|
||||
installed at CI job time. `libmpv-2.dll` must also be bundled into the NSIS
|
||||
installer via `tauri.conf.json`'s resources.
|
||||
|
||||
### Verified build mechanics
|
||||
|
||||
The cross-compile path was tested hands-on from Linux (July 2026), not inferred:
|
||||
|
||||
- Neither shinchiro nor zhongfly ships an `mpv.def` or MSVC `mpv.lib` — only a
|
||||
MinGW `libmpv.dll.a`. (Several online sources claim otherwise; they are wrong.)
|
||||
- An MSVC-style import lib can be generated locally with LLVM tools only:
|
||||
`llvm-readobj --coff-exports libmpv-2.dll` → synthesize `mpv.def` →
|
||||
`llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a
|
||||
byte-identical result.
|
||||
- A real `lld-link` link against that import lib **succeeds**, and the resulting
|
||||
import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link`
|
||||
is the linker `cargo-xwin` uses, so this is the load-bearing step.
|
||||
- Linking directly against the shipped MinGW `libmpv.dll.a` **also** succeeds, so
|
||||
def-generation may be skippable — but that relies on lld's GNU-archive
|
||||
tolerance rather than a documented contract. Keep `llvm-dlltool` as the
|
||||
fallback.
|
||||
- MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64
|
||||
Windows calling convention is platform-defined. The upstream note that MSVC
|
||||
cannot *build* mpv is frequently misread as "MSVC cannot *link* libmpv" — that
|
||||
is not what it says.
|
||||
- 🔴 Never free/realloc across the DLL boundary — use `mpv_free`.
|
||||
|
||||
Build wiring is ordinary: `cargo:rustc-link-lib=dylib=mpv` plus
|
||||
`cargo:rustc-link-search`. Nothing about libmpv conflicts with `cargo-xwin`.
|
||||
|
||||
### Size and shipping
|
||||
|
||||
Measured uncompressed: **93 MiB** (zhongfly `mpv-dev-lgpl-x86_64`) vs **112 MiB**
|
||||
(shinchiro, full GPL build); ~26–30 MB compressed in the `.7z`.
|
||||
|
||||
**Ship the zhongfly LGPL build** — smaller, and there is no reason to pull the
|
||||
GPL variant in for an audio-only use.
|
||||
|
||||
Import-table inspection confirms **no companion DLLs are needed**: every
|
||||
dependency is a system DLL (`KERNEL32`, `USER32`, `d2d1`, `DWrite`, `OPENGL32`,
|
||||
`vulkan-1`, UCRT `api-ms-win-*`). One file to bundle.
|
||||
|
||||
93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv
|
||||
audio-only, investigate whether a pruned build (no video decoders, no libplacebo)
|
||||
is worth producing for the builder image — but treat that as an optimization,
|
||||
not a blocker.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Windows *video*. Stays in WebView2 + hls.js — it works and has ABR.
|
||||
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
|
||||
- Replacing `WebviewAudioBackend` for macOS.
|
||||
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is emitted (not a crash) if libmpv is unavailable.
|
||||
- [ ] Volume control works from the UI — the current hard gap.
|
||||
- [ ] EQ, normalization, and gapless audibly take effect on Windows.
|
||||
- [ ] Position/state originate in Rust; the `<audio>` element is no longer in the audio path.
|
||||
- [ ] Seek, next/previous, and queue advance work; sleep timer stops playback.
|
||||
- [ ] `libmpv-2.dll` ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
|
||||
- [ ] Builder image carries the Windows libmpv artefacts; **no toolchain install added to any CI step**.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**: the existing `mpv_backend_test.rs` and the `build_af_filter` /
|
||||
`normalize_filter_entry` / `eq_filter_entries` unit tests already cover the
|
||||
filter-graph logic and are platform-independent — they should pass unchanged
|
||||
under a Windows `cargo check`/test. Add a test asserting `detect_audio_system()`
|
||||
returns `wasapi` under `cfg(windows)`.
|
||||
|
||||
**Manual, on Windows**: volume, EQ preset change, normalization toggle, gapless
|
||||
between two tracks, seek, queue advance, sleep timer. Then the packaging test —
|
||||
install the NSIS output on a clean VM and confirm it launches and plays.
|
||||
|
||||
Per CLAUDE.md, the volume gap is a *bug fix*: write a failing test for
|
||||
"`set_volume` reaches the backend" before implementing.
|
||||
|
||||
## TRACES
|
||||
|
||||
- Windows `MpvBackend` construction in `create_player_backend` → `// TRACES: UR-003 | IR-030`
|
||||
- `detect_audio_system` Windows branch → `IR-030`
|
||||
- Existing `set_audio_settings` gains Windows coverage → `UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
||||
- Allocate **IR-030** in `requirements.md` ("libmpv integration for Windows audio playback").
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do this **after** [libmpv2-migration.md](libmpv2-migration.md) — porting the
|
||||
current dead `libmpv` git pin to a second platform would double the migration
|
||||
work.
|
||||
- `libmpv2` has broken its API in every major release (4.0 removed command
|
||||
helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an
|
||||
exact version.
|
||||
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does
|
||||
not need it, and disabling the default `render` feature may shrink the build.
|
||||
- A parallel Claude session may be active — `git diff` first.
|
||||
+26
-12
@@ -43,14 +43,26 @@ Extracts all TRACES comments from:
|
||||
|
||||
### 2. Coverage Thresholds
|
||||
The workflow checks:
|
||||
- **Minimum overall coverage:** 50% (57+ requirements traced)
|
||||
- **Requirements by type:**
|
||||
- UR (User): 23+ of 39
|
||||
- IR (Integration): 5+ of 24
|
||||
- DR (Development): 28+ of 48
|
||||
- JA (Jellyfin API): 0+ of 3
|
||||
- **Minimum overall coverage:** 50%
|
||||
|
||||
If coverage drops below threshold, the workflow **fails** and blocks merge.
|
||||
Denominators are **derived from `docs/requirements.md` at run time** — they are
|
||||
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
|
||||
current per-type breakdown; any number written into this document is a snapshot
|
||||
that will drift.
|
||||
|
||||
> **Why this matters.** The workflow used to divide by frozen literals
|
||||
> (UR/39, IR/24, DR/48, JA/3, total 114) while `requirements.md` had grown past
|
||||
> 200. It reported **158%** coverage, so the 50% threshold was unreachable and
|
||||
> the job could not fail regardless of how far coverage dropped. See
|
||||
> [specs/traceability-gate-repair.md](specs/traceability-gate-repair.md).
|
||||
|
||||
Coverage is the *intersection* of traced and defined IDs: an ID that appears in
|
||||
a `TRACES:` comment but is not defined in `requirements.md` is reported as
|
||||
**orphaned** and does not count toward coverage. UT/IT test identifiers are a
|
||||
separate taxonomy and are excluded entirely.
|
||||
|
||||
The workflow **fails** and blocks merge if coverage drops below 50% — or if it
|
||||
computes above 100%, which can only mean the gate is miscounting.
|
||||
|
||||
### 3. Modified File Checking
|
||||
On pull requests, the workflow:
|
||||
@@ -153,11 +165,13 @@ cat docs/traceability.md
|
||||
## Coverage Goals
|
||||
|
||||
### Current Status
|
||||
- Overall: 51% (56/114)
|
||||
- UR: 59% (23/39)
|
||||
- IR: 21% (5/24)
|
||||
- DR: 58% (28/48)
|
||||
- JA: 0% (0/3)
|
||||
|
||||
Run `bun run traces:coverage` — it prints the live figure and exits non-zero
|
||||
below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
||||
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
||||
made the broken CI arithmetic look plausible for so long.
|
||||
|
||||
As of July 2026 overall coverage is ~86% (182/212).
|
||||
|
||||
### Targets
|
||||
- **Short term** (Sprint): Maintain ≥50% overall
|
||||
|
||||
+2072
-1370
File diff suppressed because it is too large
Load Diff
+42
-5
@@ -346,10 +346,12 @@ flowchart TB
|
||||
**User Interaction:**
|
||||
- **Tap screen:** Controls reappear for 3 seconds
|
||||
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
||||
- **Double tap right side:** Forward 30 seconds (shows animated feedback with "+30" indicator)
|
||||
- **Single tap play/pause is deferred** by the 300 ms double-tap window, so a double tap
|
||||
skips without also toggling pause (UR-061)
|
||||
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
||||
- **Keyboard arrows:** ← rewind 10s, → forward 30s (desktop/external keyboard)
|
||||
- **Keyboard space/K:** Toggle play/pause
|
||||
- **Keyboard F:** Toggle fullscreen
|
||||
- **Pinch:** Zoom (planned)
|
||||
@@ -609,9 +611,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 +700,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
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.2.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -25,11 +25,18 @@
|
||||
"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",
|
||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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
|
||||
+17
-1
@@ -69,13 +69,29 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
|
||||
bun run traces # Generate markdown report
|
||||
bun run traces:json # Generate JSON report
|
||||
bun run traces:markdown # Save to docs/traceability.md
|
||||
bun run traces:coverage # Coverage gate — exits non-zero below 50%
|
||||
```
|
||||
|
||||
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
|
||||
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
|
||||
looking for `TRACES:` comments and generates a comprehensive mapping of:
|
||||
- Which code files implement which requirements
|
||||
- Line numbers and code context
|
||||
- Coverage summary by requirement type (UR, IR, DR, JA)
|
||||
|
||||
**`bun run traces:coverage` is the supported way to check requirement coverage
|
||||
locally** — it runs the same computation CI does. Coverage denominators are
|
||||
derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
|
||||
that appears in a `TRACES:` comment but is not defined in `requirements.md` is
|
||||
reported as *orphaned* and does not count toward coverage (see DR-093).
|
||||
|
||||
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
|
||||
> `find-req-implementations.sh` were deleted in July 2026. They read an
|
||||
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
|
||||
> unscoped (hanging on ~40 GB of `target/` artifacts), and in one case reported
|
||||
> "all requirements implemented" from an empty result set. `extract-traces.ts` is
|
||||
> the single source of truth for requirement coverage. See
|
||||
> [docs/specs/req-coverage-script-removal.md](../docs/specs/req-coverage-script-removal.md).
|
||||
|
||||
Example TRACES comment in code:
|
||||
```typescript
|
||||
// TRACES: UR-005, UR-026 | DR-029
|
||||
|
||||
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
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# Implements DR-094 (see docs/requirements.md).
|
||||
#
|
||||
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
||||
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
||||
@@ -9,18 +11,29 @@
|
||||
#
|
||||
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
||||
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
||||
# targets the one machine-detectable signature of the leak class — a *query* that
|
||||
# names a multi-type category — and defers everything subtler to the human
|
||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
||||
# present.
|
||||
# targets the machine-detectable signature of the leak class and defers
|
||||
# everything subtler to the human spec-review checklist
|
||||
# (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here does not mean the
|
||||
# boundary is respected; it means the crudest violation isn't present.
|
||||
#
|
||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
||||
# and is not matched.
|
||||
# What it flags: an array literal naming two or more Jellyfin item types,
|
||||
# ANYWHERE in src/ — i.e. the frontend deciding that a *category* maps to a *set*
|
||||
# of Jellyfin types, which is domain knowledge the backend should own.
|
||||
# Single-type arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show
|
||||
# movies" and are allowed. Type *inspection* (`item.type === "Audio"`) is display
|
||||
# logic and is not matched.
|
||||
#
|
||||
# 🔴 What it still CANNOT see (do not read a green run as proof):
|
||||
# - a type set built at run time: [...musicTypes, "Playlist"]
|
||||
# - types split across variables: const A = "Audio"; [A, B]
|
||||
# - taxonomy as control flow: switch (t) { case "Audio": … }
|
||||
# t === "Audio" || t === "MusicAlbum"
|
||||
# - an item type absent from ITEM_TYPES below (false negative by design)
|
||||
#
|
||||
# This check was hardened in July 2026 after the audit found it passing on the
|
||||
# very leak it was written for: the original pattern was anchored to
|
||||
# `includeItemTypes:` at the query site, so assigning the same array to a named
|
||||
# const evaded it entirely. See docs/specs/boundary-tripwire-hardening.md (DR-094).
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
@@ -28,7 +41,7 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
|
||||
# Files permitted to contain a multi-type item-type array, with the reason.
|
||||
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
||||
# signal to push taxonomy into Rust, not to keep appending here.
|
||||
ALLOWLIST=(
|
||||
@@ -36,8 +49,31 @@ ALLOWLIST=(
|
||||
# two-type filmography query with no category-configuration behind it. Tracked
|
||||
# as acceptable pending any person-scope work; revisit if it grows.
|
||||
"src/lib/components/library/PersonDetailView.svelte"
|
||||
|
||||
# Grid styling predicate over `config.itemType`, a value the page already
|
||||
# declares about itself. Selects a *look*, issues no query, and would only
|
||||
# change if the UI were redesigned — presentation, not taxonomy-as-policy.
|
||||
"src/lib/components/library/GenericMediaListPage.svelte"
|
||||
|
||||
# "Is this item a container?" predicate for downloads browsing.
|
||||
# BORDERLINE — leans domain: the container set grows when Jellyfin adds a
|
||||
# container type. TODO: replace with a backend-supplied `MediaItem.isContainer`
|
||||
# flag and remove this entry. Tracked in
|
||||
# docs/specs/boundary-tripwire-hardening.md §Out of scope.
|
||||
"src/lib/components/downloads/DownloadedBrowse.svelte"
|
||||
)
|
||||
|
||||
# Hard cap so erosion is caught mechanically rather than by whoever notices.
|
||||
# Deliberately just above the current count: the next exception forces a
|
||||
# conversation instead of a one-line append.
|
||||
MAX_ALLOWLIST=4
|
||||
|
||||
if [[ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]]; then
|
||||
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
||||
echo " Push taxonomy into Rust instead of appending here."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
@@ -46,11 +82,28 @@ is_allowed() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
# Two or more adjacent Jellyfin item-type string literals inside a bracket.
|
||||
#
|
||||
# NOT anchored to `includeItemTypes:` — that was the original rule, and it missed
|
||||
# the real leak: `searchScope.ts` assigned the same array to a named const and
|
||||
# dereferenced it one indirection away from the query, so the grep never saw it
|
||||
# while CI stayed green. Matching the array literal itself catches a const, a
|
||||
# Record value, a function return, and an inline query alike.
|
||||
#
|
||||
# Deliberate limits:
|
||||
# - requires TWO adjacent types, so single-type presentation
|
||||
# (`itemType: "Movie"`) stays legal — the rule targets *category* taxonomy;
|
||||
# - requires string literals, so `item.type === "Audio"` (display inspection)
|
||||
# does not match;
|
||||
# - uses an explicit type list rather than a generic capitalised-word pattern,
|
||||
# so unrelated string arrays (`["High","Low"]`) produce no noise.
|
||||
#
|
||||
# An item type missing from this list is a false *negative*, never a false
|
||||
# positive — the check degrades safely as Jellyfin adds types.
|
||||
ITEM_TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
||||
PATTERN="\[[[:space:]]*\"($ITEM_TYPES)\"[[:space:]]*,[[:space:]]*\"($ITEM_TYPES)\""
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (item-type array literals)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
@@ -69,9 +122,11 @@ done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo ""
|
||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope and let the backend expand it to item types."
|
||||
echo "❌ Frontend boundary violation: an item-type array literal defines a"
|
||||
echo " category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope/enum and let the backend expand it to item types"
|
||||
echo " (see SearchScope::item_types() in src-tauri/src/repository/types.rs)."
|
||||
echo " Assigning the array to a const does not make it presentation."
|
||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Requirements Coverage Checker
|
||||
# Extracts @req tags from codebase and compares with README.md
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
REQUIREMENTS_FILE="README.md"
|
||||
SOURCE_DIRS="src-tauri/ src/"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Requirements Coverage Report"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Extract requirement IDs from README.md (UR-, IR-, DR-, JA-)
|
||||
echo "📊 Scanning requirements from $REQUIREMENTS_FILE..."
|
||||
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" "$REQUIREMENTS_FILE" | \
|
||||
sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | \
|
||||
sort -u)
|
||||
|
||||
total_reqs=$(echo "$requirements" | wc -l)
|
||||
implemented=0
|
||||
partial=0
|
||||
planned=0
|
||||
missing=0
|
||||
|
||||
echo ""
|
||||
echo "Category Breakdown:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
for category in UR IR DR JA; do
|
||||
cat_count=$(echo "$requirements" | grep "^$category-" | wc -l)
|
||||
printf "%-4s %3d requirements\n" "$category:" "$cat_count"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Implementation Status:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
for req in $requirements; do
|
||||
# Count full implementations
|
||||
full_count=$(grep -r "@req: $req" $SOURCE_DIRS 2>/dev/null | grep -v "@req-partial" | grep -v "@req-planned" | wc -l)
|
||||
|
||||
# Count partial implementations
|
||||
partial_count=$(grep -r "@req-partial: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||
|
||||
# Count planned
|
||||
planned_count=$(grep -r "@req-planned: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$full_count" -gt 0 ]; then
|
||||
echo "✅ $req: $full_count implementation(s)"
|
||||
((implemented++))
|
||||
elif [ "$partial_count" -gt 0 ]; then
|
||||
echo "🔶 $req: $partial_count partial implementation(s)"
|
||||
((partial++))
|
||||
elif [ "$planned_count" -gt 0 ]; then
|
||||
echo "📋 $req: Planned (not yet implemented)"
|
||||
((planned++))
|
||||
else
|
||||
echo "❌ $req: No implementation found"
|
||||
((missing++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
printf "Total Requirements: %3d\n" "$total_reqs"
|
||||
printf "✅ Fully Implemented: %3d (%.0f%%)\n" "$implemented" "$(echo "scale=0; $implemented * 100 / $total_reqs" | bc)"
|
||||
printf "🔶 Partially Implemented: %3d (%.0f%%)\n" "$partial" "$(echo "scale=0; $partial * 100 / $total_reqs" | bc)"
|
||||
printf "📋 Planned: %3d (%.0f%%)\n" "$planned" "$(echo "scale=0; $planned * 100 / $total_reqs" | bc)"
|
||||
printf "❌ Missing: %3d (%.0f%%)\n" "$missing" "$(echo "scale=0; $missing * 100 / $total_reqs" | bc)"
|
||||
echo ""
|
||||
|
||||
# Exit code based on missing critical requirements
|
||||
if [ "$missing" -gt 0 ]; then
|
||||
echo "⚠️ Warning: $missing requirements have no implementation"
|
||||
exit 1
|
||||
else
|
||||
echo "✨ All requirements have implementations!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Test Coverage Report
|
||||
# Links test requirements to implementations
|
||||
#
|
||||
|
||||
echo "Test Coverage Report"
|
||||
echo "===================="
|
||||
echo ""
|
||||
|
||||
test_reqs=$(grep -rh "@req-test:" src-tauri/ 2>/dev/null | \
|
||||
sed 's/.*@req-test: \([A-Z][A-Z]-[0-9]*\).*/\1/' | \
|
||||
sort -u)
|
||||
|
||||
total_tests=0
|
||||
covered=0
|
||||
uncovered=0
|
||||
|
||||
for req in $test_reqs; do
|
||||
test_count=$(grep -r "@req-test: $req" src-tauri/ 2>/dev/null | wc -l)
|
||||
impl_count=$(grep -r "@req: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||
|
||||
((total_tests++))
|
||||
|
||||
if [ "$test_count" -gt 0 ] && [ "$impl_count" -gt 0 ]; then
|
||||
echo "✅ $req: $test_count test(s), $impl_count implementation(s)"
|
||||
((covered++))
|
||||
elif [ "$impl_count" -eq 0 ]; then
|
||||
echo "⚠️ $req: $test_count test(s) but no implementation"
|
||||
((uncovered++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
printf "Total Test Requirements: %3d\n" "$total_tests"
|
||||
printf "✅ With Implementation: %3d (%.0f%%)\n" "$covered" "$(echo "scale=0; $covered * 100 / $total_tests" | bc)"
|
||||
printf "⚠️ No Implementation: %3d (%.0f%%)\n" "$uncovered" "$(echo "scale=0; $uncovered * 100 / $total_tests" | bc)"
|
||||
echo ""
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for the traceability coverage computation.
|
||||
*
|
||||
* These run over fixture strings rather than the live docs/requirements.md, so
|
||||
* their meaning does not drift as requirements are added.
|
||||
*
|
||||
* Background: the CI gate divided traced-requirement counts by hardcoded
|
||||
* denominators (UR/39, IR/24, DR/48, JA/3, total 114) that had fallen out of
|
||||
* date, reporting 158% coverage and making the 50% threshold unreachable. These
|
||||
* tests pin the parsing and arithmetic that replace those literals.
|
||||
*
|
||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
||||
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
|
||||
|
||||
describe("countDefinedRequirements", () => {
|
||||
it("counts a well-formed table row as a defined requirement", () => {
|
||||
const md = `
|
||||
| ID | Requirement | Priority | Status |
|
||||
|----|-------------|----------|--------|
|
||||
| UR-001 | Run the app on multiple platforms | High | In Progress |
|
||||
| UR-002 | Access media when online or offline | High | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.UR).toBe(2);
|
||||
expect(defined.DR).toBe(0);
|
||||
});
|
||||
|
||||
it("does not count IDs that appear only in the Traces To column", () => {
|
||||
// The bug this rule avoids: a naive grep for /DR-\d{3}/ over the whole file
|
||||
// counts DR-001 here as "defined", inflating the denominator with IDs that
|
||||
// are merely referenced.
|
||||
const md = `
|
||||
| DR-001 | Player state machine | Player | UR-005 | Done |
|
||||
| DR-002 | MediaItem struct | Player | UR-003, UR-004 | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.DR).toBe(2);
|
||||
// UR-005/UR-003/UR-004 are referenced, never defined here.
|
||||
expect(defined.UR).toBe(0);
|
||||
});
|
||||
|
||||
it("does not count IDs mentioned in prose", () => {
|
||||
const md = `
|
||||
Some prose explaining that UR-005 relates to DR-001 and JA-002.
|
||||
|
||||
| UR-005 | Control media playback | High | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.UR).toBe(1);
|
||||
expect(defined.DR).toBe(0);
|
||||
expect(defined.JA).toBe(0);
|
||||
});
|
||||
|
||||
it("deduplicates an ID listed in both the spec table and the traceability matrix", () => {
|
||||
// requirements.md lists every UR twice: once in §1 (definition) and again in
|
||||
// §3 (traceability matrix), both as a leading table cell. Counting rows
|
||||
// instead of unique IDs double-counts the UR denominator (121 vs 61).
|
||||
const md = `
|
||||
| UR-005 | Control media playback | High | Done |
|
||||
| UR-006 | Browse the library | High | Done |
|
||||
|
||||
### Traceability Matrix
|
||||
|
||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||
| UR-006 | - | DR-012 |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.UR).toBe(2);
|
||||
});
|
||||
|
||||
it("collects the defined ID set, not just counts", () => {
|
||||
const md = `
|
||||
| UR-001 | A | High | Done |
|
||||
| DR-050 | B | Player | UR-001 | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.ids.has("UR-001")).toBe(true);
|
||||
expect(defined.ids.has("DR-050")).toBe(true);
|
||||
expect(defined.ids.has("UR-999")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCoverage", () => {
|
||||
const defined = {
|
||||
UR: 2,
|
||||
IR: 0,
|
||||
DR: 2,
|
||||
JA: 0,
|
||||
total: 4,
|
||||
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
|
||||
};
|
||||
|
||||
it("computes coverage as traced ∩ defined over defined", () => {
|
||||
const traced = ["UR-001", "DR-001"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.covered).toBe(2);
|
||||
expect(cov.total).toBe(4);
|
||||
expect(cov.percent).toBe(50);
|
||||
});
|
||||
|
||||
it("does not let a traced-but-undefined ID inflate the numerator", () => {
|
||||
// This is how a ratio exceeds 100%: a TRACES comment naming a typo'd or
|
||||
// deleted requirement counted as covered.
|
||||
const traced = ["UR-001", "DR-001", "DR-097"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.covered).toBe(2);
|
||||
expect(cov.percent).toBe(50);
|
||||
});
|
||||
|
||||
it("reports traced-but-undefined IDs as orphaned so they get fixed", () => {
|
||||
const traced = ["UR-001", "DR-097", "JA-404"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.orphaned).toEqual(["DR-097", "JA-404"]);
|
||||
});
|
||||
|
||||
it("has no orphans when every traced ID is defined", () => {
|
||||
const cov = computeCoverage(["UR-001", "UR-002"], defined);
|
||||
expect(cov.orphaned).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores UT/IT test IDs entirely — they are a separate taxonomy", () => {
|
||||
// UT/IT are defined in §4 of requirements.md, not among the four
|
||||
// requirement types. Treating them as orphans buries real typos in ~60
|
||||
// lines of noise, and counting them would corrupt the ratio.
|
||||
const cov = computeCoverage(["UR-001", "UT-088", "IT-017"], defined);
|
||||
expect(cov.orphaned).toEqual([]);
|
||||
expect(cov.covered).toBe(1);
|
||||
});
|
||||
|
||||
it("reports 0% rather than dividing by zero for an empty trace set", () => {
|
||||
const cov = computeCoverage([], defined);
|
||||
expect(cov.covered).toBe(0);
|
||||
expect(cov.percent).toBe(0);
|
||||
});
|
||||
|
||||
it("reports 0% rather than NaN when nothing is defined", () => {
|
||||
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() };
|
||||
const cov = computeCoverage([], empty);
|
||||
expect(cov.percent).toBe(0);
|
||||
expect(Number.isNaN(cov.percent)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports exactly 100% when all defined requirements are traced, never above", () => {
|
||||
const traced = ["UR-001", "UR-002", "DR-001", "DR-002"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.percent).toBe(100);
|
||||
});
|
||||
|
||||
it("ignores duplicate traced IDs", () => {
|
||||
const traced = ["UR-001", "UR-001", "UR-001"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.covered).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live requirements.md", () => {
|
||||
it("parses the real file to the counts the CI gate must use", () => {
|
||||
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||
// (total 114) while the real file had grown to 211. Update these numbers
|
||||
// deliberately when requirements are added — that edit is the signal the
|
||||
// denominator is live rather than frozen.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const md = fs.readFileSync(
|
||||
path.resolve(here, "../docs/requirements.md"),
|
||||
"utf-8"
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(61);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(91);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(213);
|
||||
});
|
||||
});
|
||||
+193
-16
@@ -34,11 +34,20 @@ interface TracesData {
|
||||
DR: string[];
|
||||
JA: string[];
|
||||
};
|
||||
/** Requirements *defined* in requirements.md — the coverage denominators. */
|
||||
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
|
||||
coverage?: CoverageResult;
|
||||
}
|
||||
|
||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
||||
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||
const BASE_DIR = path.resolve(import.meta.dir, "..");
|
||||
//
|
||||
// `import.meta.dir` is a Bun extension and is undefined when this module is
|
||||
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
|
||||
// import.meta.url — this file must stay importable for extract-traces.test.ts.
|
||||
const SCRIPT_DIR =
|
||||
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
|
||||
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
|
||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
||||
@@ -50,7 +59,10 @@ function extractRequirementIds(tracesString: string): string[] {
|
||||
|
||||
function getAllSourceFiles(): string[] {
|
||||
const baseDir = BASE_DIR;
|
||||
const patterns = ["src", "src-tauri/src"];
|
||||
// `scripts` is scanned too: build tooling implements requirements (e.g.
|
||||
// DR-093, the coverage engine itself) and would otherwise be invisible to the
|
||||
// very matrix it generates.
|
||||
const patterns = ["src", "src-tauri/src", "scripts"];
|
||||
const files: string[] = [];
|
||||
|
||||
function walkDir(dir: string) {
|
||||
@@ -192,6 +204,109 @@ function extractTraces(): TracesData {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coverage: how many *defined* requirements are actually traced.
|
||||
//
|
||||
// The denominators MUST be derived from requirements.md, never hardcoded. The
|
||||
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
|
||||
// total 114) while the real file had grown to 211 requirements, so it reported
|
||||
// 158% coverage and the 50% threshold became unreachable — the gate could not
|
||||
// fail. See docs/specs/traceability-gate-repair.md.
|
||||
//
|
||||
// TRACES: | DR-093
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DefinedRequirements {
|
||||
UR: number;
|
||||
IR: number;
|
||||
DR: number;
|
||||
JA: number;
|
||||
total: number;
|
||||
ids: Set<string>;
|
||||
}
|
||||
|
||||
export interface CoverageResult {
|
||||
covered: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
/** Traced in code but not defined in requirements.md (typo, or deleted req). */
|
||||
orphaned: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A requirement is *defined* only where its ID is the leading cell of a markdown
|
||||
* table row: `| DR-001 | … |`.
|
||||
*
|
||||
* This deliberately ignores IDs in the "Traces To" column and in prose — a
|
||||
* naive scan for /DR-\d{3}/ counts those as definitions and inflates the
|
||||
* denominator. IDs are deduplicated because requirements.md lists each UR twice
|
||||
* (once in §1 as a definition, again in §3's traceability matrix), which would
|
||||
* otherwise double the UR count from 61 to 121.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function countDefinedRequirements(markdown: string): DefinedRequirements {
|
||||
const ids = new Set<string>();
|
||||
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const match = line.match(ROW_ID);
|
||||
if (match) ids.add(`${match[1]}-${match[2]}`);
|
||||
}
|
||||
|
||||
const countOf = (type: string) =>
|
||||
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
|
||||
|
||||
return {
|
||||
UR: countOf("UR"),
|
||||
IR: countOf("IR"),
|
||||
DR: countOf("DR"),
|
||||
JA: countOf("JA"),
|
||||
total: ids.size,
|
||||
ids,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
|
||||
*
|
||||
* Using the raw traced count as the numerator is what lets a ratio exceed 100%:
|
||||
* a TRACES comment naming a requirement that no longer exists would count as
|
||||
* covered. Those IDs are reported as `orphaned` so they get fixed rather than
|
||||
* silently counted or silently dropped.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function computeCoverage(
|
||||
tracedIds: string[],
|
||||
defined: DefinedRequirements
|
||||
): CoverageResult {
|
||||
// Only the four *requirement* types participate in coverage. UT/IT are test
|
||||
// identifiers defined in §4 of requirements.md — a different taxonomy, and
|
||||
// flagging them as orphans would bury real typos in ~60 lines of noise.
|
||||
const isRequirement = (id: string) => /^(UR|IR|DR|JA)-\d{3}$/.test(id);
|
||||
|
||||
const traced = new Set(tracedIds.filter(isRequirement));
|
||||
const covered = [...traced].filter((id) => defined.ids.has(id));
|
||||
const orphaned = [...traced].filter((id) => !defined.ids.has(id)).sort();
|
||||
|
||||
return {
|
||||
covered: covered.length,
|
||||
total: defined.total,
|
||||
percent:
|
||||
defined.total === 0
|
||||
? 0
|
||||
: Math.round((covered.length / defined.total) * 100),
|
||||
orphaned,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read requirements.md from the repo and count what it defines. */
|
||||
export function readDefinedRequirements(): DefinedRequirements {
|
||||
const reqPath = path.join(BASE_DIR, "docs", "requirements.md");
|
||||
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
||||
}
|
||||
|
||||
function generateMarkdown(data: TracesData): string {
|
||||
let md = `# Code Traceability Matrix
|
||||
|
||||
@@ -265,21 +380,83 @@ function generateJson(data: TracesData): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = Bun.argv.slice(2);
|
||||
const format = args.includes("--format")
|
||||
? args[args.indexOf("--format") + 1]
|
||||
: "markdown";
|
||||
/**
|
||||
* Human-readable coverage report; exits non-zero below the threshold so this is
|
||||
* runnable as a local gate (`bun run traces:coverage`), not just in CI.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
function reportCoverage(data: TracesData, minThreshold: number): number {
|
||||
const defined = data.defined!;
|
||||
const cov = data.coverage!;
|
||||
|
||||
console.error("🔍 Extracting TRACES from codebase...");
|
||||
const data = extractTraces();
|
||||
const definedIds = readDefinedRequirements().ids;
|
||||
|
||||
if (format === "json") {
|
||||
console.log(generateJson(data));
|
||||
} else {
|
||||
console.log(generateMarkdown(data));
|
||||
console.log("📋 Requirement coverage (traced / defined):");
|
||||
for (const type of ["UR", "IR", "DR", "JA"] as const) {
|
||||
const traced = data.byType[type].filter((id) => definedIds.has(id)).length;
|
||||
console.log(` ${type}: ${traced} / ${defined[type]}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(`📈 Overall: ${cov.covered} / ${cov.total} (${cov.percent}%)`);
|
||||
|
||||
if (cov.orphaned.length > 0) {
|
||||
console.log("");
|
||||
console.log(
|
||||
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
|
||||
);
|
||||
console.log(" Fix the TRACES comment or add the requirement.");
|
||||
}
|
||||
|
||||
// A ratio above 100% means the computation is broken (the condition that hid
|
||||
// the stale-denominator bug for so long). Fail loudly rather than report it.
|
||||
if (cov.percent > 100) {
|
||||
console.log("");
|
||||
console.log(`❌ Coverage > 100% — the gate is miscomputing.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (cov.percent < minThreshold) {
|
||||
console.log("");
|
||||
console.log(`❌ Coverage (${cov.percent}%) is below minimum (${minThreshold}%)`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`✅ Coverage is acceptable (${cov.percent}% >= ${minThreshold}%)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
||||
);
|
||||
// Main — guarded so this module stays importable from extract-traces.test.ts.
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const format = args.includes("--format")
|
||||
? args[args.indexOf("--format") + 1]
|
||||
: "markdown";
|
||||
|
||||
console.error("🔍 Extracting TRACES from codebase...");
|
||||
const data = extractTraces();
|
||||
|
||||
const defined = readDefinedRequirements();
|
||||
const allTraced = Object.keys(data.requirements);
|
||||
data.defined = {
|
||||
UR: defined.UR,
|
||||
IR: defined.IR,
|
||||
DR: defined.DR,
|
||||
JA: defined.JA,
|
||||
total: defined.total,
|
||||
};
|
||||
data.coverage = computeCoverage(allTraced, defined);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(generateJson(data));
|
||||
} else if (format === "coverage") {
|
||||
process.exit(reportCoverage(data, 50));
|
||||
} else {
|
||||
console.log(generateMarkdown(data));
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Find all files implementing a specific requirement
|
||||
#
|
||||
# Usage: ./find-req-implementations.sh UR-004
|
||||
#
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <REQUIREMENT_ID>"
|
||||
echo "Example: $0 UR-004"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REQ_ID=$1
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Implementations of $REQ_ID"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Full implementations
|
||||
echo "Full Implementations:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||
grep -v "@req-partial" | \
|
||||
grep -v "@req-planned" | \
|
||||
sed 's/src-tauri\/src\///' | \
|
||||
sed 's/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
|
||||
# Partial implementations
|
||||
echo "Partial Implementations:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req-partial: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||
sed 's/src-tauri\/src\///' | \
|
||||
sed 's/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
|
||||
# Planned
|
||||
echo "Planned Implementations:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req-planned: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||
sed 's/src-tauri\/src\///' | \
|
||||
sed 's/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
|
||||
# Tests
|
||||
echo "Test Cases:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req-test: $REQ_ID" src-tauri/ 2>/dev/null | \
|
||||
sed 's/src-tauri\/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
+8
-1
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
|
||||
echo ""
|
||||
|
||||
echo "📦 Running frontend tests..."
|
||||
bun run test
|
||||
bun run test --run
|
||||
|
||||
echo ""
|
||||
echo "🦀 Running Rust tests..."
|
||||
@@ -15,5 +15,12 @@ cd src-tauri
|
||||
cargo test
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "🚧 Checking architectural gates..."
|
||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||
bun run check:boundary
|
||||
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
|
||||
bun run traces:coverage
|
||||
|
||||
echo ""
|
||||
echo "✅ All tests passed!"
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var configAttempts = 0
|
||||
private val maxConfigAttempts = 10
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
|
||||
/**
|
||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
|
||||
*/
|
||||
private var mediaWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* The WebView the @JavascriptInterface bridges have been injected into.
|
||||
*
|
||||
* addJavascriptInterface must run once per WebView instance: re-injecting
|
||||
* over an already-loaded page hands JS a stale proxy whose methods are gone.
|
||||
* Compared by identity so a genuinely new WebView still gets its bridges.
|
||||
*/
|
||||
private var bridgesInstalledOn: WebView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
|
||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||
mediaWebView = webView
|
||||
|
||||
// Add JavaScript interface for audio focus control
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
@JavascriptInterface
|
||||
fun requestAudioFocus() {
|
||||
handler.post { this@MainActivity.requestAudioFocus() }
|
||||
}
|
||||
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||
//
|
||||
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
||||
// every onResume (plus each WebView re-find), so this used to re-inject
|
||||
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
||||
// injected objects at page-load time; re-injecting over a live page
|
||||
// leaves JS holding a stale proxy. The object stays truthy while its
|
||||
// methods vanish, which surfaced as a flood of
|
||||
// "WebView: Unknown object" chromium errors and, in JS,
|
||||
// "TypeError: setEnabled is not a function".
|
||||
//
|
||||
// The visible bug: the background-audio toggle turned blue but never
|
||||
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
||||
// dispatched 'jellytau-background', and a locked screen killed audio
|
||||
// instantly (UR-040). Audio focus and PiP broke the same way.
|
||||
//
|
||||
// The settings/WebChromeClient work below is idempotent and must keep
|
||||
// running on resume; only the bridge injection is one-shot.
|
||||
if (webView === bridgesInstalledOn) {
|
||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||
configureWebViewSettings(webView)
|
||||
return
|
||||
}
|
||||
bridgesInstalledOn = webView
|
||||
|
||||
@JavascriptInterface
|
||||
fun abandonAudioFocus() {
|
||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
||||
}
|
||||
}, "AndroidAudioFocus")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
||||
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
|
||||
// requests from the WebView competed with Chromium's own
|
||||
// AudioFocusDelegate and with ExoPlayer, and the resulting
|
||||
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
|
||||
// in configureWebViewSettings().
|
||||
|
||||
// Add JavaScript interface for picture-in-picture control.
|
||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
|
||||
backgroundAudioEnabled = enabled
|
||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||
}
|
||||
|
||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
configureWebViewSettings(webView)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebView settings, chrome client and the video-unmute script.
|
||||
*
|
||||
* Split out from the bridge injection because this half is idempotent and
|
||||
* must re-run on every resume, whereas addJavascriptInterface must not.
|
||||
*/
|
||||
private fun configureWebViewSettings(webView: WebView) {
|
||||
try {
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
|
||||
super.onHideCustomView()
|
||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
|
||||
*
|
||||
* Without this the frontend is invisible to `adb logcat`, which makes
|
||||
* diagnosing anything that spans the JS/native boundary (the
|
||||
* background-audio handoff in particular) guesswork.
|
||||
*/
|
||||
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
|
||||
android.util.Log.d(
|
||||
"JellyTauWeb",
|
||||
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||
|
||||
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
|
||||
video.volume = 1.0;
|
||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||
|
||||
// Add event listeners to manage audio focus
|
||||
video.addEventListener('play', function() {
|
||||
console.log('[Android] Video play event - requesting audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.requestAudioFocus();
|
||||
}
|
||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
||||
});
|
||||
|
||||
video.addEventListener('pause', function() {
|
||||
console.log('[Android] Video pause event - abandoning audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.abandonAudioFocus();
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('ended', function() {
|
||||
console.log('[Android] Video ended event - abandoning audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.abandonAudioFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: deliberately no audio-focus calls here.
|
||||
//
|
||||
// WebView already manages audio focus for <video> through
|
||||
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
|
||||
// again from MainActivity made two requesters compete inside one
|
||||
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
|
||||
// (~45ms), whose handler paused playback - so arming background
|
||||
// audio, or simply pressing play, paused the video in a loop.
|
||||
//
|
||||
// ExoPlayer is the third potential owner and stays authoritative
|
||||
// for native playback (JellyTauPlayer manages its own focus).
|
||||
// Leave focus to whichever engine is actually rendering.
|
||||
video.addEventListener('volumechange', function() {
|
||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||
});
|
||||
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener { focusChange ->
|
||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||
}
|
||||
.build()
|
||||
|
||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = audioManager.requestAudioFocus(
|
||||
{ focusChange ->
|
||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||
},
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let {
|
||||
audioManager.abandonAudioFocusRequest(it)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,53 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** Position update interval in milliseconds */
|
||||
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
||||
|
||||
/** AudioEffect priority. Positive = higher priority than the default. */
|
||||
private const val EFFECT_PRIORITY = 1000
|
||||
|
||||
/**
|
||||
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
|
||||
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
|
||||
* is only the lookup table used to map those gains onto whatever bands
|
||||
* the device's equalizer actually has.
|
||||
*/
|
||||
private val CANONICAL_BAND_CENTRES_HZ =
|
||||
intArrayOf(31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000)
|
||||
|
||||
/**
|
||||
* Map canonical band gains onto a device's band centres by nearest
|
||||
* centre frequency.
|
||||
*
|
||||
* Pure function so it can be unit-tested without a device — device band
|
||||
* counts vary (commonly 5) and getting this wrong silently mis-shapes the
|
||||
* EQ curve rather than failing.
|
||||
*
|
||||
* TRACES: UR-027 | DR-030
|
||||
*/
|
||||
@JvmStatic
|
||||
fun resampleBands(
|
||||
canonicalGains: FloatArray,
|
||||
canonicalCentresHz: IntArray,
|
||||
deviceCentresHz: IntArray
|
||||
): FloatArray {
|
||||
if (canonicalGains.isEmpty() || deviceCentresHz.isEmpty()) {
|
||||
return FloatArray(deviceCentresHz.size)
|
||||
}
|
||||
val usable = minOf(canonicalGains.size, canonicalCentresHz.size)
|
||||
return FloatArray(deviceCentresHz.size) { d ->
|
||||
val target = deviceCentresHz[d]
|
||||
var nearest = 0
|
||||
var bestDelta = Int.MAX_VALUE
|
||||
for (c in 0 until usable) {
|
||||
val delta = kotlin.math.abs(canonicalCentresHz[c] - target)
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta
|
||||
nearest = c
|
||||
}
|
||||
}
|
||||
canonicalGains[nearest]
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance for JNI access */
|
||||
@Volatile
|
||||
private var instance: JellyTauPlayer? = null
|
||||
@@ -135,6 +182,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var positionUpdateJob: Job? = null
|
||||
|
||||
/** Graphic EQ bound to the current audio session, or null if not attached. */
|
||||
private var equalizer: android.media.audiofx.Equalizer? = null
|
||||
|
||||
/** Loudness/normalization effect bound to the current audio session. */
|
||||
private var loudnessEnhancer: android.media.audiofx.LoudnessEnhancer? = null
|
||||
|
||||
/**
|
||||
* Last settings pushed from Rust, replayed when the audio session is rebuilt.
|
||||
* Held as the raw payload so re-application needs no second parse contract.
|
||||
*/
|
||||
private var lastAudioSettings: org.json.JSONObject? = null
|
||||
|
||||
/** Current media ID being played */
|
||||
private var currentMediaId: String? = null
|
||||
|
||||
@@ -334,6 +393,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
|
||||
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
|
||||
// effects bound to the old session are dead. Re-attach, or the EQ
|
||||
// silently stops applying mid-queue.
|
||||
releaseAudioEffects()
|
||||
lastAudioSettings?.let { applyAudioEffects(it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -416,6 +480,133 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply audio settings pushed from Rust as JSON.
|
||||
*
|
||||
* Rust owns *what* the values are (band layout, preset curves, normalization
|
||||
* presets); this owns *when* the Android AudioEffect objects exist, since
|
||||
* that needs the live audio session id and must survive a sink rebuild.
|
||||
*
|
||||
* Posted to the main handler rather than run inline: AudioEffect construction
|
||||
* from a player callback can re-enter the player and deadlock.
|
||||
*
|
||||
* TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
*/
|
||||
fun setAudioSettings(json: String) {
|
||||
mainHandler.post {
|
||||
try {
|
||||
val settings = org.json.JSONObject(json)
|
||||
lastAudioSettings = settings
|
||||
|
||||
// Gapless: ExoPlayer is gapless by default for compatible
|
||||
// formats, so honouring the setting means disabling it when off.
|
||||
exoPlayer.pauseAtEndOfMediaItems = !settings.optBoolean("gaplessPlayback", true)
|
||||
|
||||
applyAudioEffects(settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Failed to apply audio settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach/update the EQ and loudness effects for the current audio session. */
|
||||
private fun applyAudioEffects(settings: org.json.JSONObject) {
|
||||
val sessionId = exoPlayer.audioSessionId
|
||||
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
|
||||
// No sink yet; onAudioSessionIdChanged will re-drive this.
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
applyEqualizer(sessionId, settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Equalizer unavailable on this device", e)
|
||||
}
|
||||
|
||||
try {
|
||||
applyNormalization(sessionId, settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "LoudnessEnhancer unavailable on this device", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEqualizer(sessionId: Int, settings: org.json.JSONObject) {
|
||||
val enabled = settings.optBoolean("equalizerEnabled", false)
|
||||
|
||||
if (!enabled) {
|
||||
equalizer?.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
val eq = equalizer ?: android.media.audiofx.Equalizer(EFFECT_PRIORITY, sessionId).also {
|
||||
equalizer = it
|
||||
}
|
||||
|
||||
val bandsJson = settings.optJSONArray("equalizerBands")
|
||||
val canonicalGains = FloatArray(bandsJson?.length() ?: 0) { i ->
|
||||
bandsJson!!.optDouble(i, 0.0).toFloat()
|
||||
}
|
||||
if (canonicalGains.isEmpty()) {
|
||||
eq.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// The device's band count/centres are device-dependent (commonly 5) and
|
||||
// will not match our canonical 10-band ISO layout, so resample.
|
||||
val deviceBandCount = eq.numberOfBands.toInt()
|
||||
val deviceCentresHz = IntArray(deviceBandCount) { i ->
|
||||
eq.getCenterFreq(i.toShort()) / 1000 // device reports milliHertz
|
||||
}
|
||||
val levelRange = eq.bandLevelRange // millibels, [min, max]
|
||||
|
||||
val resampled = resampleBands(canonicalGains, CANONICAL_BAND_CENTRES_HZ, deviceCentresHz)
|
||||
|
||||
for (i in 0 until deviceBandCount) {
|
||||
val millibels = (resampled[i] * 100f)
|
||||
.coerceIn(levelRange[0].toFloat(), levelRange[1].toFloat())
|
||||
eq.setBandLevel(i.toShort(), millibels.toInt().toShort())
|
||||
}
|
||||
eq.enabled = true
|
||||
}
|
||||
|
||||
private fun applyNormalization(sessionId: Int, settings: org.json.JSONObject) {
|
||||
val enabled = settings.optBoolean("normalizeVolume", false)
|
||||
|
||||
if (!enabled) {
|
||||
loudnessEnhancer?.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
val enhancer = loudnessEnhancer
|
||||
?: android.media.audiofx.LoudnessEnhancer(sessionId).also { loudnessEnhancer = it }
|
||||
|
||||
// Approximate parity with the Linux dynaudnorm path: LoudnessEnhancer is
|
||||
// a gain stage, not a true EBU R128 normalizer, so these are relative
|
||||
// offsets preserving the Loud > Normal > Quiet ordering.
|
||||
val targetGainMb = when (settings.optString("volumeLevel", "normal")) {
|
||||
"loud" -> 600
|
||||
"quiet" -> -600
|
||||
else -> 0
|
||||
}
|
||||
enhancer.setTargetGain(targetGainMb)
|
||||
enhancer.enabled = true
|
||||
}
|
||||
|
||||
private fun releaseAudioEffects() {
|
||||
try {
|
||||
equalizer?.release()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("JellyTauPlayer", "Equalizer release failed", e)
|
||||
}
|
||||
try {
|
||||
loudnessEnhancer?.release()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("JellyTauPlayer", "LoudnessEnhancer release failed", e)
|
||||
}
|
||||
equalizer = null
|
||||
loudnessEnhancer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current playback position in seconds.
|
||||
*/
|
||||
@@ -756,6 +947,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
coroutineScope.cancel()
|
||||
releaseAudioEffects()
|
||||
exoPlayer.release()
|
||||
instance = null
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::rank_search_results;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
@@ -394,7 +395,12 @@ pub struct SearchUpdateEvent {
|
||||
pub result: SearchResult,
|
||||
}
|
||||
|
||||
/// Search for items
|
||||
/// Search for items.
|
||||
///
|
||||
/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
|
||||
/// dispatching, so scope taxonomy stays in Rust.
|
||||
///
|
||||
/// TRACES: UR-049, UR-050 | DR-063
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_search(
|
||||
@@ -407,9 +413,19 @@ pub async fn repository_search(
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
// Expand the opaque scope into item types HERE — once, before the cache and
|
||||
// server paths diverge — so both phases filter identically. Doing it later
|
||||
// (or in only one path) makes offline results disagree with online ones.
|
||||
// The frontend sends `scope` and never names a Jellyfin item type for
|
||||
// search; see docs/specs/scoped-search-boundary.md.
|
||||
let options = options.map(|mut o| {
|
||||
o.resolve_scope();
|
||||
o
|
||||
});
|
||||
|
||||
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||
// UI can render immediately while the server is still being queried.
|
||||
let cache_result = repo
|
||||
let mut cache_result = repo
|
||||
.search_cache_only(&query, options.clone())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -420,6 +436,12 @@ pub async fn repository_search(
|
||||
}
|
||||
});
|
||||
|
||||
// Neither backend orders by *where* the query matched, so a mid-word hit
|
||||
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
|
||||
// Both phases are ranked with the same rules so the list does not reshuffle
|
||||
// when the server results land.
|
||||
rank_search_results(&mut cache_result.items, &query);
|
||||
|
||||
// Phase 2: query the live server in the background, merge with the cache,
|
||||
// and push the union to the frontend via a `search-event`. Tagged with
|
||||
// `request_id` so the frontend can discard results from superseded queries.
|
||||
@@ -428,7 +450,11 @@ pub async fn repository_search(
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match repo_bg.search_server_only(&query, options).await {
|
||||
Ok(server_result) => {
|
||||
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let mut merged =
|
||||
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
// Rank the union, not each half: a server-only prefix match must
|
||||
// be able to outrank a cached mid-word one.
|
||||
rank_search_results(&mut merged.items, &query);
|
||||
let event = SearchUpdateEvent {
|
||||
request_id,
|
||||
result: merged,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
pub mod from_jellyfin;
|
||||
pub mod media;
|
||||
pub mod search_rank;
|
||||
|
||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||
pub use media::{MediaKind, StreamKind};
|
||||
pub use search_rank::rank_search_results;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Relevance ranking for search results.
|
||||
//!
|
||||
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
|
||||
//! in an order that ignores *where* in the name the query matched: a server
|
||||
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
|
||||
//! the query "parks". Neither backend is going to change, so the app imposes its
|
||||
//! own ordering on the union.
|
||||
//!
|
||||
//! Ranking is domain logic, not presentation: it encodes what a "better match"
|
||||
//! means and which media kinds outrank which. The frontend only renders the
|
||||
//! order it is given.
|
||||
//!
|
||||
//! Two rules, in priority order:
|
||||
//!
|
||||
//! 1. **Match position** — a prefix match beats a word-start match, which beats
|
||||
//! a mid-word substring match. This is what makes "parks" find
|
||||
//! "Parks and Recreation" before "Sparks of Love".
|
||||
//! 2. **Kind** — containers before their contents at equal match quality, so a
|
||||
//! series outranks its own episodes.
|
||||
//!
|
||||
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
|
||||
//! `rank`) still breaks ties it was never overruled on.
|
||||
|
||||
use crate::domain::MediaKind;
|
||||
use crate::repository::types::MediaItem;
|
||||
|
||||
/// How well a query matched an item's name — better matches sort first.
|
||||
///
|
||||
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
|
||||
/// comparison for free, so adding a tier in the right position is all it takes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum MatchQuality {
|
||||
/// The name starts with the query — "parks" in "Parks and Recreation".
|
||||
Prefix,
|
||||
/// Some later *word* starts with the query — "recreation" in "Parks and
|
||||
/// Recreation". Still a deliberate hit: users type whole words.
|
||||
WordStart,
|
||||
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
|
||||
/// that still counts as a match.
|
||||
Substring,
|
||||
/// No match on the name at all. The backend returned it for some other
|
||||
/// reason (overview, artist, album), so it is kept but sorted last.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Rank of a media kind when match quality ties — lower sorts first.
|
||||
///
|
||||
/// Containers outrank the items they contain: searching a show's name should
|
||||
/// surface the show, not an arbitrary episode of it. Within a tier the order is
|
||||
/// arbitrary but stable, and equal ranks fall through to input order.
|
||||
fn kind_rank(kind: MediaKind) -> u8 {
|
||||
match kind {
|
||||
// Top-level containers a user is most likely to be looking for.
|
||||
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
|
||||
// Sub-containers and standalone collections.
|
||||
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
|
||||
// Leaves — an episode/track is a match *inside* something bigger.
|
||||
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
|
||||
2
|
||||
}
|
||||
// Peripheral matches.
|
||||
MediaKind::Person | MediaKind::Other => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify how `query` matches `name`, case-insensitively.
|
||||
///
|
||||
/// Both sides are trimmed and lowercased; an empty query matches everything
|
||||
/// equally (`Prefix`), which leaves the input order untouched.
|
||||
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
|
||||
let query = query.trim().to_lowercase();
|
||||
if query.is_empty() {
|
||||
return MatchQuality::Prefix;
|
||||
}
|
||||
let name = name.trim().to_lowercase();
|
||||
|
||||
let Some(index) = name.find(&query) else {
|
||||
return MatchQuality::None;
|
||||
};
|
||||
|
||||
if index == 0 {
|
||||
return MatchQuality::Prefix;
|
||||
}
|
||||
|
||||
// A word start is any match preceded by a non-alphanumeric character, so
|
||||
// "the-office" and "The Office" behave the same. Indexing back one char is
|
||||
// safe on the byte index `find` returned only via `char_indices`, since a
|
||||
// multi-byte char would panic on a raw slice.
|
||||
let preceded_by_boundary = name[..index]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| !c.is_alphanumeric());
|
||||
|
||||
if preceded_by_boundary {
|
||||
MatchQuality::WordStart
|
||||
} else {
|
||||
MatchQuality::Substring
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort search results by relevance to `query`, in place.
|
||||
///
|
||||
/// Stable, so items the rules rank equally keep the order the backend supplied
|
||||
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
|
||||
///
|
||||
/// TRACES: UR-060 | DR-090
|
||||
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
|
||||
// An empty query carries no relevance signal, so there is nothing to rank
|
||||
// by — reordering on kind alone would shuffle the backend's own ordering
|
||||
// for no reason.
|
||||
if query.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(name: &str, kind: MediaKind) -> MediaItem {
|
||||
let mut item = MediaItem::default();
|
||||
item.id = format!("id-{}-{:?}", name, kind);
|
||||
item.name = name.to_string();
|
||||
item.kind = kind;
|
||||
item
|
||||
}
|
||||
|
||||
fn names(items: &[MediaItem]) -> Vec<&str> {
|
||||
items.iter().map(|i| i.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// UT-085: a prefix match outranks a mid-word substring match.
|
||||
#[test]
|
||||
fn prefix_match_beats_midword_substring() {
|
||||
assert_eq!(
|
||||
match_quality("Parks and Recreation", "parks"),
|
||||
MatchQuality::Prefix
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Sparks of Love", "parks"),
|
||||
MatchQuality::Substring
|
||||
);
|
||||
assert!(MatchQuality::Prefix < MatchQuality::Substring);
|
||||
}
|
||||
|
||||
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
|
||||
#[test]
|
||||
fn ranks_prefix_match_before_substring_match() {
|
||||
let mut items = vec![
|
||||
item("Sparks of Love", MediaKind::Series),
|
||||
item("Parks and Recreation", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Parks and Recreation", "Sparks of Love"]
|
||||
);
|
||||
}
|
||||
|
||||
/// A match at a later word start beats a mid-word one but loses to a prefix.
|
||||
#[test]
|
||||
fn word_start_ranks_between_prefix_and_substring() {
|
||||
assert_eq!(
|
||||
match_quality("The Office", "office"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
|
||||
|
||||
let mut items = vec![
|
||||
item("Bofficer", MediaKind::Series),
|
||||
item("The Office", MediaKind::Series),
|
||||
item("Office Space", MediaKind::Movie),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "office");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Office Space", "The Office", "Bofficer"]
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-086: at equal match quality a series outranks an episode.
|
||||
#[test]
|
||||
fn series_ranks_before_episode_at_equal_match_quality() {
|
||||
let mut items = vec![
|
||||
item("Parks and Recreation S01E01", MediaKind::Episode),
|
||||
item("Parks and Recreation", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Albums outrank their tracks for the same reason series outrank episodes.
|
||||
#[test]
|
||||
fn album_ranks_before_track_at_equal_match_quality() {
|
||||
let mut items = vec![
|
||||
item("Rumours", MediaKind::Track),
|
||||
item("Rumours", MediaKind::Album),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "rumours");
|
||||
|
||||
assert_eq!(items[0].kind, MediaKind::Album);
|
||||
}
|
||||
|
||||
/// Match quality dominates kind: a better-matching episode beats a
|
||||
/// worse-matching series, so kind never drags an irrelevant show to the top.
|
||||
#[test]
|
||||
fn match_quality_outranks_kind() {
|
||||
let mut items = vec![
|
||||
item("Sparks of Love", MediaKind::Series),
|
||||
item("Parks Cleanup", MediaKind::Episode),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
|
||||
}
|
||||
|
||||
/// Items the backend returned for a non-name reason (overview, artist) are
|
||||
/// kept, but sort below everything that actually matched the name.
|
||||
#[test]
|
||||
fn non_matching_names_sort_last_without_being_dropped() {
|
||||
let mut items = vec![
|
||||
item("Unrelated Documentary", MediaKind::Movie),
|
||||
item("Parks and Recreation", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Parks and Recreation", "Unrelated Documentary"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Ranking is stable: equally-ranked items keep the backend's order, so the
|
||||
/// FTS/server relevance signal still breaks ties.
|
||||
#[test]
|
||||
fn equal_rank_preserves_input_order() {
|
||||
let mut items = vec![
|
||||
item("Parks A", MediaKind::Series),
|
||||
item("Parks B", MediaKind::Series),
|
||||
item("Parks C", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
|
||||
}
|
||||
|
||||
/// Case and surrounding whitespace never change the tier.
|
||||
#[test]
|
||||
fn matching_is_case_and_whitespace_insensitive() {
|
||||
assert_eq!(
|
||||
match_quality("PARKS AND RECREATION", " parks "),
|
||||
MatchQuality::Prefix
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Parks and Recreation", "PARKS"),
|
||||
MatchQuality::Prefix
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty query leaves the order alone rather than reshuffling on kind.
|
||||
#[test]
|
||||
fn empty_query_preserves_input_order() {
|
||||
let mut items = vec![
|
||||
item("Zebra", MediaKind::Episode),
|
||||
item("Apple", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "");
|
||||
|
||||
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
|
||||
}
|
||||
|
||||
/// A multi-byte name must not panic when the match is mid-string — the
|
||||
/// boundary check walks chars rather than slicing raw bytes.
|
||||
#[test]
|
||||
fn handles_multibyte_names_without_panicking() {
|
||||
assert_eq!(
|
||||
match_quality("Pokémon Journeys", "journeys"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Café Parks", "parks"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
|
||||
#[test]
|
||||
fn punctuation_counts_as_a_word_boundary() {
|
||||
assert_eq!(
|
||||
match_quality("The-Office", "office"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Show: Parks", "parks"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
@@ -148,6 +149,10 @@ struct ExoPlayerState {
|
||||
volume: f32,
|
||||
is_loaded: bool,
|
||||
current_media: Option<MediaItem>,
|
||||
/// Last applied audio settings. Unlike the fields above (which JNI callbacks
|
||||
/// push *in*), this is commanded *out* — audio settings are never reported
|
||||
/// by the player, so this is the authoritative copy for `audio_settings()`.
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
impl ExoPlayerState {
|
||||
@@ -159,6 +164,7 @@ impl ExoPlayerState {
|
||||
volume: 1.0,
|
||||
is_loaded: false,
|
||||
current_media: None,
|
||||
audio_settings: AudioSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,6 +535,56 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
self.shared_state.lock_safe().volume
|
||||
}
|
||||
|
||||
/// Apply audio settings to ExoPlayer (equalizer, normalization, gapless).
|
||||
///
|
||||
/// Sent as JSON rather than a wide JNI signature so new fields do not change
|
||||
/// the method signature — the same approach `load()` uses for subtitles. The
|
||||
/// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio
|
||||
/// session); the canonical band layout and preset curves stay in Rust.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let json = audio_settings_jni_payload(settings).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e))
|
||||
})?;
|
||||
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let json_jstring = env.new_string(&json).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create settings string: {}", e))
|
||||
})?;
|
||||
|
||||
env.call_method(
|
||||
&self.player_ref,
|
||||
"setAudioSettings",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&json_jstring)],
|
||||
)
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e))
|
||||
})?;
|
||||
|
||||
// Store the sanitised form so audio_settings() reflects what was applied,
|
||||
// not what was requested.
|
||||
self.shared_state.lock_safe().audio_settings = settings
|
||||
.clone()
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.shared_state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
@@ -858,12 +914,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.
|
||||
|
||||
+279
-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,
|
||||
@@ -235,6 +243,23 @@ impl PlayerController {
|
||||
self.end_reason.lock_safe().take()
|
||||
}
|
||||
|
||||
/// Record that playback is being stopped by an expiring sleep timer.
|
||||
///
|
||||
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
||||
/// Android), which lands in `on_playback_ended`. Without an end reason that
|
||||
/// reads as a natural finish and autoplay advances — defeating the timer.
|
||||
/// `UserStop` is the honest label: the stop was user-initiated, just via the
|
||||
/// timer they set rather than the stop button.
|
||||
///
|
||||
/// Takes the shared slot rather than `&self` so the sleep-timer thread —
|
||||
/// which owns clones, not the controller — records it the same way.
|
||||
///
|
||||
/// TRACES: UR-023, UR-026 | DR-029
|
||||
fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
|
||||
log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
|
||||
*end_reason.lock_safe() = Some(EndReason::UserStop);
|
||||
}
|
||||
|
||||
/// Increment autoplay episode counter. Returns true if limit is reached.
|
||||
fn increment_autoplay_count(&self) -> bool {
|
||||
let max = self.autoplay_settings.lock_safe().max_episodes;
|
||||
@@ -650,6 +675,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.
|
||||
@@ -741,6 +784,7 @@ impl PlayerController {
|
||||
let sleep_timer = self.sleep_timer.clone();
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
let backend = self.backend.clone();
|
||||
let end_reason = self.end_reason.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
@@ -757,6 +801,14 @@ impl PlayerController {
|
||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||
timer.cancel();
|
||||
|
||||
// Mark the stop *before* it reaches the backend. Stopping
|
||||
// makes the native player fire its ended callback, and
|
||||
// cancelling the timer above means on_playback_ended can no
|
||||
// longer tell this apart from a natural end — without this
|
||||
// it would show the next-episode popup / autoplay right
|
||||
// after the sleep timer fired.
|
||||
Self::note_sleep_timer_stop(&end_reason);
|
||||
|
||||
// Emit cancelled state
|
||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||
@@ -955,9 +1007,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 +1027,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 +1090,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 +1254,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
|
||||
@@ -1931,6 +2066,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A time-based sleep timer that fires mid-episode must not let the ended
|
||||
/// callback fall through to autoplay.
|
||||
///
|
||||
/// The timer thread stops the backend directly, which makes ExoPlayer emit
|
||||
/// its ended callback. That callback races the thread's own `timer.cancel()`:
|
||||
/// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
|
||||
/// so the timer branch is skipped and the episode path runs — showing a
|
||||
/// next-episode popup (or advancing) after the user's sleep timer expired.
|
||||
#[tokio::test]
|
||||
async fn test_expired_time_sleep_timer_stops_without_autoplay() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let items = create_test_items(3);
|
||||
controller.play_queue(items, 0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
// Arm a time-based timer that is already due, then let the real timer
|
||||
// thread (started in the constructor, 1s tick) observe the expiry and
|
||||
// run its stop path. Driving the actual thread is the point: the bug was
|
||||
// that this path stopped the backend without recording an end reason.
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
|
||||
|
||||
// Wait for the timer thread to process the expiry (tick is 1s).
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !controller.sleep_timer.lock_safe().is_active() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!controller.sleep_timer.lock_safe().is_active(),
|
||||
"Timer thread should have expired and cancelled the sleep timer"
|
||||
);
|
||||
|
||||
// The backend stop above makes the native player fire its ended callback.
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::Stop),
|
||||
"Expected Stop after an expired time-based sleep timer, got {:?}",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_queue_stops() {
|
||||
let controller = PlayerController::default();
|
||||
@@ -2303,6 +2483,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 +2684,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.
|
||||
|
||||
@@ -294,6 +294,53 @@ pub struct GetItemsOptions {
|
||||
pub genres: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// An opaque search scope the frontend selects; Rust owns what it *means*.
|
||||
///
|
||||
/// The expansion table below is Jellyfin domain vocabulary: it changes when
|
||||
/// Jellyfin adds or renames an item type, never when the UI is redesigned. It
|
||||
/// previously lived in the frontend (`searchScope.ts`), which is the boundary
|
||||
/// leak documented in docs/specs/scoped-search-boundary.md. The frontend now
|
||||
/// sends the enum and never names an item type in connection with search.
|
||||
///
|
||||
/// TRACES: UR-049 | DR-063
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope {
|
||||
All,
|
||||
Music,
|
||||
Movies,
|
||||
Tv,
|
||||
}
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or `None` for `All`.
|
||||
///
|
||||
/// `All` returns `None` rather than the union of every listed type on
|
||||
/// purpose: an explicit `includeItemTypes` list filters out anything not
|
||||
/// named in it, so a union would silently drop People, folders and any type
|
||||
/// nobody enumerated. Callers must omit the filter entirely on `None`.
|
||||
///
|
||||
/// TRACES: UR-049 | DR-063
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(
|
||||
["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
),
|
||||
SearchScope::Movies => Some(vec!["Movie".to_string()]),
|
||||
SearchScope::Tv => Some(
|
||||
["Series", "Episode"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for search queries
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -304,6 +351,28 @@ pub struct SearchOptions {
|
||||
pub include_item_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_term: Option<String>,
|
||||
/// Opaque scope selected by the UI. When set it **wins** over
|
||||
/// `include_item_types`, which remains for the non-search `get_items`
|
||||
/// callers that legitimately request a single concrete type.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<SearchScope>,
|
||||
}
|
||||
|
||||
impl SearchOptions {
|
||||
/// Expand `scope` into `include_item_types` in place.
|
||||
///
|
||||
/// Call this once, in the search command, *before* dispatching to the
|
||||
/// cache and server paths — both already honour `include_item_types`, and
|
||||
/// resolving in one place keeps online and offline results identical.
|
||||
///
|
||||
/// TRACES: UR-049 | DR-063
|
||||
pub fn resolve_scope(&mut self) {
|
||||
if let Some(scope) = self.scope {
|
||||
// `All` yields None, which clears the filter — the correct
|
||||
// behaviour, not an omission.
|
||||
self.include_item_types = scope.item_types();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback information
|
||||
@@ -455,6 +524,131 @@ impl MeaningfulContent for PlaylistCreatedResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod search_scope_tests {
|
||||
use super::*;
|
||||
|
||||
/// Music expands to the four Jellyfin types that make up the category.
|
||||
///
|
||||
/// This table is the domain vocabulary that used to live in the frontend
|
||||
/// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
|
||||
/// docs/specs/scoped-search-boundary.md was written about.
|
||||
///
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn music_scope_expands_to_music_item_types() {
|
||||
assert_eq!(
|
||||
SearchScope::Music.item_types(),
|
||||
Some(vec![
|
||||
"MusicAlbum".to_string(),
|
||||
"MusicArtist".to_string(),
|
||||
"Audio".to_string(),
|
||||
"Playlist".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn movies_scope_expands_to_movie_only() {
|
||||
assert_eq!(
|
||||
SearchScope::Movies.item_types(),
|
||||
Some(vec!["Movie".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn tv_scope_expands_to_series_and_episode() {
|
||||
assert_eq!(
|
||||
SearchScope::Tv.item_types(),
|
||||
Some(vec!["Series".to_string(), "Episode".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// `All` must send NO filter — not the union of the other scopes.
|
||||
///
|
||||
/// Sending a union would silently drop every type nobody enumerated
|
||||
/// (Person, folders, …), which an explicit `includeItemTypes` list filters
|
||||
/// out. This is why `item_types()` returns Option rather than Vec.
|
||||
///
|
||||
/// @req-test: UT-090 - All scope sends no item-type filter
|
||||
#[test]
|
||||
fn all_scope_sends_no_filter() {
|
||||
assert_eq!(SearchScope::All.item_types(), None);
|
||||
}
|
||||
|
||||
/// Scope wins over an explicitly supplied include_item_types.
|
||||
///
|
||||
/// @req-test: UT-091 - Scope takes precedence over include_item_types
|
||||
#[test]
|
||||
fn resolve_scope_overrides_include_item_types() {
|
||||
let mut options = SearchOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string()]),
|
||||
scope: Some(SearchScope::Music),
|
||||
..Default::default()
|
||||
};
|
||||
options.resolve_scope();
|
||||
|
||||
assert_eq!(
|
||||
options.include_item_types,
|
||||
Some(vec![
|
||||
"MusicAlbum".to_string(),
|
||||
"MusicArtist".to_string(),
|
||||
"Audio".to_string(),
|
||||
"Playlist".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// `All` clears any include_item_types so no filter reaches the query.
|
||||
///
|
||||
/// @req-test: UT-090 - All scope sends no item-type filter
|
||||
#[test]
|
||||
fn resolve_all_scope_clears_include_item_types() {
|
||||
let mut options = SearchOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string()]),
|
||||
scope: Some(SearchScope::All),
|
||||
..Default::default()
|
||||
};
|
||||
options.resolve_scope();
|
||||
|
||||
assert_eq!(options.include_item_types, None);
|
||||
}
|
||||
|
||||
/// With no scope set, include_item_types passes through untouched — the
|
||||
/// non-search `getItems` callers rely on this.
|
||||
///
|
||||
/// @req-test: UT-091 - Scope takes precedence over include_item_types
|
||||
#[test]
|
||||
fn resolve_without_scope_preserves_include_item_types() {
|
||||
let mut options = SearchOptions {
|
||||
include_item_types: Some(vec!["MusicAlbum".to_string()]),
|
||||
scope: None,
|
||||
..Default::default()
|
||||
};
|
||||
options.resolve_scope();
|
||||
|
||||
assert_eq!(
|
||||
options.include_item_types,
|
||||
Some(vec!["MusicAlbum".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// The frontend sends the enum as camelCase over IPC.
|
||||
///
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn scope_deserializes_from_camel_case() {
|
||||
let options: SearchOptions =
|
||||
serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
|
||||
assert!(matches!(options.scope, Some(SearchScope::Music)));
|
||||
|
||||
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
|
||||
assert!(matches!(all.scope, Some(SearchScope::All)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+250
-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
|
||||
@@ -90,10 +179,80 @@ impl VideoSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `AudioSettings` into the JSON payload handed to the Android player
|
||||
/// over JNI.
|
||||
///
|
||||
/// Sanitises first (crossfade clamped, band vector normalised) so a malformed
|
||||
/// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI
|
||||
/// signature so that adding a field does not change the method signature — the
|
||||
/// same approach `load()` already uses for subtitles.
|
||||
///
|
||||
/// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the
|
||||
/// Kotlin side matches on those literals. Both are pinned by tests.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
|
||||
let sanitised = settings
|
||||
.clone()
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
serde_json::to_string(&sanitised)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The JNI payload must sanitise before serialising: an over-long crossfade
|
||||
/// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len().
|
||||
/// Sending raw values would let a malformed vector reach the Kotlin parser.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1
|
||||
#[test]
|
||||
fn test_audio_settings_jni_payload_is_sanitised() {
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 30.0,
|
||||
equalizer_bands: vec![20.0, -30.0],
|
||||
..AudioSettings::default()
|
||||
};
|
||||
|
||||
let json = audio_settings_jni_payload(&settings).expect("serialises");
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
||||
|
||||
assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
|
||||
|
||||
let bands = v["equalizerBands"].as_array().expect("bands array");
|
||||
assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
|
||||
assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
|
||||
assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
|
||||
}
|
||||
|
||||
/// The Kotlin side parses these exact keys. camelCase is what serde emits
|
||||
/// for AudioSettings; a rename here silently breaks the Android parser,
|
||||
/// which is why the contract is pinned by a test rather than by convention.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2
|
||||
#[test]
|
||||
fn test_audio_settings_jni_payload_key_contract() {
|
||||
let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
||||
|
||||
for key in [
|
||||
"crossfadeDuration",
|
||||
"gaplessPlayback",
|
||||
"normalizeVolume",
|
||||
"volumeLevel",
|
||||
"equalizerEnabled",
|
||||
"equalizerBands",
|
||||
] {
|
||||
assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
|
||||
}
|
||||
|
||||
// VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on
|
||||
// these literals.
|
||||
assert_eq!(v["volumeLevel"], "normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_settings() {
|
||||
let settings = AudioSettings::default();
|
||||
@@ -101,6 +260,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 +382,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.2.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",
|
||||
|
||||
+78
-5
@@ -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 });
|
||||
},
|
||||
@@ -1280,7 +1290,12 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
|
||||
return await TAURI_INVOKE("repository_get_genres", { handle, parentId });
|
||||
},
|
||||
/**
|
||||
* Search for items
|
||||
* Search for items.
|
||||
*
|
||||
* Resolves `SearchOptions::scope` into concrete Jellyfin item types before
|
||||
* dispatching, so scope taxonomy stays in Rust.
|
||||
*
|
||||
* TRACES: UR-049, UR-050 | DR-063
|
||||
*/
|
||||
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
|
||||
@@ -1570,7 +1585,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 +1770,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 +2060,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 +2399,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
|
||||
*
|
||||
@@ -2467,11 +2522,29 @@ failed: number }
|
||||
/**
|
||||
* Options for search queries
|
||||
*/
|
||||
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null }
|
||||
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null;
|
||||
/**
|
||||
* Opaque scope selected by the UI. When set it **wins** over
|
||||
* `include_item_types`, which remains for the non-search `get_items`
|
||||
* callers that legitimately request a single concrete type.
|
||||
*/
|
||||
scope?: SearchScope | null }
|
||||
/**
|
||||
* Search result with pagination
|
||||
*/
|
||||
export type SearchResult = { items: MediaItem[]; totalRecordCount: number }
|
||||
/**
|
||||
* An opaque search scope the frontend selects; Rust owns what it *means*.
|
||||
*
|
||||
* The expansion table below is Jellyfin domain vocabulary: it changes when
|
||||
* Jellyfin adds or renames an item type, never when the UI is redesigned. It
|
||||
* previously lived in the frontend (`searchScope.ts`), which is the boundary
|
||||
* leak documented in docs/specs/scoped-search-boundary.md. The frontend now
|
||||
* sends the enum and never names an item type in connection with search.
|
||||
*
|
||||
* TRACES: UR-049 | DR-063
|
||||
*/
|
||||
export type SearchScope = "all" | "music" | "movies" | "tv"
|
||||
/**
|
||||
* Security status info
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -12,6 +12,7 @@
|
||||
import SleepTimerModal from "./SleepTimerModal.svelte";
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { videoFitClass } from "./videoFit";
|
||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||
import { playbackPosition } from "$lib/stores/player";
|
||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||
@@ -20,11 +21,19 @@
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isBackgroundAudioSupported,
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
type TapFeedback,
|
||||
} from "./tapGestures";
|
||||
import {
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
subscribeAppForegrounded,
|
||||
} from "$lib/utils/backgroundAudio";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
initialHandoffState,
|
||||
@@ -101,11 +110,14 @@
|
||||
let touchStartX = $state(0);
|
||||
let touchStartY = $state(0);
|
||||
let touchStartTime = $state(0);
|
||||
let lastTapTime = $state(0);
|
||||
let tapGestures = createTapGestureState();
|
||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let brightness = $state(1); // 0-2, default 1
|
||||
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
|
||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target of a skip already requested but not yet reported back by the player,
|
||||
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||
let pendingSeekTarget: number | null = null;
|
||||
let swipeGestureActive = $state(false);
|
||||
|
||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||
@@ -702,6 +714,16 @@
|
||||
if (debugLogInterval) {
|
||||
clearInterval(debugLogInterval);
|
||||
}
|
||||
// A deferred single tap must not fire play/pause after teardown.
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
tapGestures.cancel();
|
||||
if (doubleTapFeedbackTimeout) {
|
||||
clearTimeout(doubleTapFeedbackTimeout);
|
||||
doubleTapFeedbackTimeout = null;
|
||||
}
|
||||
|
||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||
for (const unlisten of nativeUnlisteners) {
|
||||
@@ -1178,16 +1200,26 @@
|
||||
// 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 };
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
if (!armed) {
|
||||
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
||||
}
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
}
|
||||
|
||||
@@ -1227,6 +1259,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,
|
||||
);
|
||||
@@ -1331,7 +1367,16 @@
|
||||
async function seekRelative(seconds: number) {
|
||||
isSeeking = true;
|
||||
|
||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||
// The facade seeks by absolute position, so resolve the delta here —
|
||||
// chaining off a still-in-flight target so rapid double taps accumulate
|
||||
// instead of all resolving against the same not-yet-updated position.
|
||||
const newTime = resolveSeekTarget({
|
||||
delta: seconds,
|
||||
reportedPosition: currentTime,
|
||||
duration,
|
||||
pendingTarget: pendingSeekTarget,
|
||||
});
|
||||
pendingSeekTarget = newTime;
|
||||
|
||||
console.log("[VideoPlayer] Relative seek:", {
|
||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||
@@ -1347,7 +1392,12 @@
|
||||
}
|
||||
} as unknown as Event;
|
||||
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
try {
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
} finally {
|
||||
// The player is authoritative again from here on.
|
||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -1364,10 +1414,10 @@
|
||||
}
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
seekRelative(-10);
|
||||
seekRelative(SEEK_BACKWARD_SECONDS);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
seekRelative(10);
|
||||
seekRelative(SEEK_FORWARD_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1378,25 +1428,31 @@
|
||||
touchStartY = touch.clientY;
|
||||
touchStartTime = Date.now();
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastTap = now - lastTapTime;
|
||||
const outcome = registerTap(tapGestures, {
|
||||
x: touch.clientX,
|
||||
screenWidth: window.innerWidth,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
// Double tap detection (within 300ms)
|
||||
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(touch.clientX);
|
||||
lastTapTime = 0; // Reset to prevent triple-tap
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
} else {
|
||||
lastTapTime = now;
|
||||
// Set timeout to clear if no second tap
|
||||
tapTimeout = setTimeout(() => {
|
||||
lastTapTime = 0;
|
||||
}, 300);
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
|
||||
if (outcome.action === "seek") {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single tap so far: defer play/pause until the double-tap window closes,
|
||||
// so a double tap seeks without also toggling pause.
|
||||
tapTimeout = setTimeout(() => {
|
||||
tapTimeout = null;
|
||||
if (tapGestures.resolvePending(Date.now())) {
|
||||
togglePlayPause();
|
||||
}
|
||||
}, outcome.pendingAfterMs);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
@@ -1411,6 +1467,13 @@
|
||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||
swipeGestureActive = true;
|
||||
|
||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
||||
tapGestures.cancel();
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
|
||||
// Brightness control on vertical swipe
|
||||
swipeType = "brightness";
|
||||
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
||||
@@ -1427,19 +1490,21 @@
|
||||
swipeType = null;
|
||||
}
|
||||
|
||||
function handleDoubleTap(x: number) {
|
||||
const screenWidth = window.innerWidth;
|
||||
const isLeftSide = x < screenWidth / 2;
|
||||
/**
|
||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
||||
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
||||
* so the compatibility click that follows a tap must be ignored here —
|
||||
* otherwise it pauses on the first tap of a double tap.
|
||||
*/
|
||||
function handleVideoClick(e: MouseEvent) {
|
||||
// A click synthesized from a touch reports no pointer movement detail.
|
||||
if (e.detail === 0 || tapTimeout !== null) return;
|
||||
togglePlayPause();
|
||||
}
|
||||
|
||||
if (isLeftSide) {
|
||||
// Double tap left: rewind 10 seconds
|
||||
seekRelative(-10);
|
||||
showDoubleTapFeedback = "left";
|
||||
} else {
|
||||
// Double tap right: forward 10 seconds
|
||||
seekRelative(10);
|
||||
showDoubleTapFeedback = "right";
|
||||
}
|
||||
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
|
||||
seekRelative(seekSeconds);
|
||||
showDoubleTapFeedback = feedback;
|
||||
|
||||
// Hide feedback after animation
|
||||
if (doubleTapFeedbackTimeout) {
|
||||
@@ -1585,7 +1650,7 @@
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||
class="max-w-full max-h-full"
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
style="filter: brightness({brightness})"
|
||||
playsinline
|
||||
@@ -1601,7 +1666,7 @@
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={togglePlayPause}
|
||||
onclick={handleVideoClick}
|
||||
>
|
||||
<!-- Temporarily disabled to debug playback issues
|
||||
{#each subtitleTracks() as track}
|
||||
@@ -1654,7 +1719,7 @@
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1665,7 +1730,7 @@
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DOUBLE_TAP_WINDOW_MS,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
} from "./tapGestures";
|
||||
|
||||
const SCREEN_WIDTH = 1000;
|
||||
const LEFT = 100;
|
||||
const RIGHT = 900;
|
||||
|
||||
function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
|
||||
return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
|
||||
}
|
||||
|
||||
/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
|
||||
function asSeek(outcome: ReturnType<typeof tap>) {
|
||||
if (outcome.action !== "seek") {
|
||||
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
describe("tap gesture resolution", () => {
|
||||
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
||||
const state = createTapGestureState();
|
||||
const first = tap(state, RIGHT, 1000);
|
||||
|
||||
// The first tap must NOT immediately toggle play/pause — it may still
|
||||
// become a double tap.
|
||||
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
||||
});
|
||||
|
||||
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
|
||||
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
||||
expect(resolved).toEqual({ action: "togglePlayPause" });
|
||||
});
|
||||
|
||||
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const second = asSeek(tap(state, RIGHT, 1150));
|
||||
|
||||
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||
expect(second.seekSeconds).toBe(30);
|
||||
expect(second.feedback).toBe("right");
|
||||
|
||||
// The deferred single-tap pause must have been cancelled.
|
||||
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("seeks back 10s on a double tap on the left half", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, LEFT, 1000);
|
||||
const second = asSeek(tap(state, LEFT, 1100));
|
||||
|
||||
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||
expect(second.seekSeconds).toBe(-10);
|
||||
expect(second.feedback).toBe("left");
|
||||
});
|
||||
|
||||
it("treats a second tap after the window as a new pending single tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||
|
||||
expect(late.action).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not treat a third tap as another double tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||
|
||||
// Triple tap: the third tap starts a fresh pending tap rather than
|
||||
// seeking again off the consumed second tap.
|
||||
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
||||
});
|
||||
|
||||
it("accumulates repeated double taps on the same side", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const a = asSeek(tap(state, RIGHT, 1100));
|
||||
tap(state, RIGHT, 1200);
|
||||
const b = asSeek(tap(state, RIGHT, 1300));
|
||||
|
||||
expect(a.seekSeconds).toBe(30);
|
||||
expect(b.seekSeconds).toBe(30);
|
||||
});
|
||||
|
||||
it("uses the tap side, so a double tap split across halves follows the second tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, LEFT, 1000);
|
||||
const second = asSeek(tap(state, RIGHT, 1100));
|
||||
|
||||
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||
expect(second.feedback).toBe("right");
|
||||
});
|
||||
|
||||
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
state.cancel();
|
||||
|
||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seek target resolution", () => {
|
||||
const DURATION = 600;
|
||||
|
||||
it("adds the delta to the reported position", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
|
||||
});
|
||||
|
||||
it("clamps to zero when rewinding past the start", () => {
|
||||
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to the duration when skipping past the end", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
||||
});
|
||||
|
||||
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||
// The player has not yet reported the first seek's result, so the
|
||||
// reported position is still the pre-seek value.
|
||||
const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
|
||||
const second = resolveSeekTarget({
|
||||
delta: 30,
|
||||
reportedPosition: 100,
|
||||
duration: DURATION,
|
||||
pendingTarget: first,
|
||||
});
|
||||
|
||||
expect(second).toBe(160);
|
||||
});
|
||||
|
||||
it("ignores a pending target once the player has caught up past it", () => {
|
||||
const target = resolveSeekTarget({
|
||||
delta: 30,
|
||||
reportedPosition: 200,
|
||||
duration: DURATION,
|
||||
pendingTarget: 130,
|
||||
});
|
||||
|
||||
expect(target).toBe(230);
|
||||
});
|
||||
|
||||
it("falls back to the delta alone when duration is unknown", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tap-gesture interpretation for the video player surface.
|
||||
*
|
||||
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
||||
* a tap cannot be classified at the moment it lands, because it may still turn
|
||||
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
||||
* until the double-tap window closes, and cancelled outright if a second tap
|
||||
* arrives — otherwise a double tap both toggles pause and seeks.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||
*/
|
||||
|
||||
/** A second tap within this window makes a double tap. */
|
||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||
|
||||
/** Double tap on the right half: skip forward. */
|
||||
export const SEEK_FORWARD_SECONDS = 30;
|
||||
|
||||
/** Double tap on the left half: skip back. */
|
||||
export const SEEK_BACKWARD_SECONDS = -10;
|
||||
|
||||
export type TapFeedback = "left" | "right";
|
||||
|
||||
export type TapOutcome =
|
||||
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
||||
| { action: "pending"; pendingAfterMs: number }
|
||||
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
||||
|
||||
export interface TapInput {
|
||||
/** Tap x position, viewport pixels. */
|
||||
x: number;
|
||||
screenWidth: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface TapGestureState {
|
||||
/**
|
||||
* Resolve a still-pending single tap. Returns the play/pause action once the
|
||||
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
||||
* became a double tap, or was cancelled).
|
||||
*/
|
||||
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
||||
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
interface InternalState extends TapGestureState {
|
||||
lastTapTime: number;
|
||||
pendingSince: number | null;
|
||||
}
|
||||
|
||||
export function createTapGestureState(): TapGestureState {
|
||||
const state: InternalState = {
|
||||
lastTapTime: 0,
|
||||
pendingSince: null,
|
||||
resolvePending(now: number) {
|
||||
if (state.pendingSince === null) return null;
|
||||
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
||||
state.pendingSince = null;
|
||||
return { action: "togglePlayPause" };
|
||||
},
|
||||
cancel() {
|
||||
state.pendingSince = null;
|
||||
state.lastTapTime = 0;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
||||
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
||||
* the window returns the seek and clears the pending play/pause.
|
||||
*/
|
||||
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||
const s = state as InternalState;
|
||||
const sinceLastTap = input.now - s.lastTapTime;
|
||||
|
||||
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||
// Second tap: cancel the deferred play/pause and seek instead.
|
||||
s.pendingSince = null;
|
||||
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
||||
const isLeftSide = input.x < input.screenWidth / 2;
|
||||
return isLeftSide
|
||||
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
||||
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
||||
}
|
||||
|
||||
s.lastTapTime = input.now;
|
||||
s.pendingSince = input.now;
|
||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||
}
|
||||
|
||||
export interface SeekTargetInput {
|
||||
/** Relative offset in seconds (negative rewinds). */
|
||||
delta: number;
|
||||
/** Latest position reported by the player — the authoritative source. */
|
||||
reportedPosition: number;
|
||||
/** Media duration; 0/unknown disables the upper clamp. */
|
||||
duration: number;
|
||||
/**
|
||||
* Target of a seek already requested but not yet reflected in
|
||||
* `reportedPosition`. Consecutive double taps chain off this so they add up
|
||||
* instead of all resolving against the same stale position.
|
||||
*/
|
||||
pendingTarget?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative skip to the absolute position the facade expects.
|
||||
*
|
||||
* The player facade seeks by absolute position only (the backend picks the seek
|
||||
* strategy), so the delta is applied here — against the pending target when one
|
||||
* is still in flight and still ahead of what the player has reported.
|
||||
*/
|
||||
export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||
|
||||
const base =
|
||||
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||
? pendingTarget
|
||||
: reportedPosition;
|
||||
|
||||
const target = base + delta;
|
||||
if (target < 0) return 0;
|
||||
if (duration > 0 && target > duration) return duration;
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { videoFitClass, fittedVideoSize } from "./videoFit";
|
||||
|
||||
describe("videoFitClass", () => {
|
||||
it("fills the container instead of capping at the source's intrinsic size", () => {
|
||||
const cls = videoFitClass();
|
||||
// max-w/max-h only shrink oversized media; a 480p source would stay a small
|
||||
// box in the middle of a large window.
|
||||
expect(cls).not.toContain("max-w-full");
|
||||
expect(cls).not.toContain("max-h-full");
|
||||
expect(cls).toContain("w-full");
|
||||
expect(cls).toContain("h-full");
|
||||
});
|
||||
|
||||
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
|
||||
const cls = videoFitClass();
|
||||
expect(cls).toContain("object-contain");
|
||||
expect(cls).not.toContain("object-cover");
|
||||
expect(cls).not.toContain("object-fill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fittedVideoSize", () => {
|
||||
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
|
||||
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
|
||||
// staying a 854x480 box in the middle.
|
||||
const size = fittedVideoSize(853.33, 480, 1920, 1080);
|
||||
expect(size.width).toBeCloseTo(1920, 0);
|
||||
expect(size.height).toBeCloseTo(1080, 0);
|
||||
});
|
||||
|
||||
it("fits to the constraining dimension when aspect ratios differ", () => {
|
||||
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
|
||||
const size = fittedVideoSize(640, 480, 1920, 1080);
|
||||
expect(size.height).toBeCloseTo(1080, 0);
|
||||
expect(size.width).toBeCloseTo(1440, 0);
|
||||
expect(size.width).toBeLessThan(1920);
|
||||
});
|
||||
|
||||
it("fits to width when the source is wider than the window", () => {
|
||||
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
|
||||
const size = fittedVideoSize(2560, 1080, 1920, 1080);
|
||||
expect(size.width).toBeCloseTo(1920, 0);
|
||||
expect(size.height).toBeCloseTo(810, 0);
|
||||
expect(size.height).toBeLessThan(1080);
|
||||
});
|
||||
|
||||
it("shrinks oversized media to fit rather than overflowing", () => {
|
||||
const size = fittedVideoSize(3840, 2160, 1280, 720);
|
||||
expect(size.width).toBeCloseTo(1280, 0);
|
||||
expect(size.height).toBeCloseTo(720, 0);
|
||||
});
|
||||
|
||||
it("returns a zero size for unknown intrinsic dimensions", () => {
|
||||
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Sizing rules for the HTML5 <video> element in the full-screen player.
|
||||
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
|
||||
|
||||
/**
|
||||
* Classes applied to the <video> element so it fits the player viewport.
|
||||
*
|
||||
* TRACES: UR-005
|
||||
*
|
||||
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
|
||||
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
|
||||
* intrinsic size - a small box in the middle of a black screen. Filling the
|
||||
* container and letting `object-contain` do the scaling fits the picture to
|
||||
* whichever axis constrains it, in both directions, preserving aspect ratio.
|
||||
*/
|
||||
export function videoFitClass(): string {
|
||||
return "w-full h-full object-contain";
|
||||
}
|
||||
|
||||
export interface FittedSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered size of a video of the given intrinsic dimensions once it has
|
||||
* been fitted into the container - i.e. scaled (up or down) so that it touches
|
||||
* the container on its constraining axis, with the other axis letter/pillar
|
||||
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
|
||||
*/
|
||||
export function fittedVideoSize(
|
||||
intrinsicWidth: number,
|
||||
intrinsicHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): FittedSize {
|
||||
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
containerWidth / intrinsicWidth,
|
||||
containerHeight / intrinsicHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
width: intrinsicWidth * scale,
|
||||
height: intrinsicHeight * scale,
|
||||
};
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
<MediaCard
|
||||
{item}
|
||||
size="medium"
|
||||
showProgress={group.id !== "artists"}
|
||||
showProgress={group.id !== "artists" && group.id !== "people"}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
* hls.js. State reporting is unnecessary here because the native backend emits
|
||||
* events directly — the adapter's job is only to forward control intents.
|
||||
*
|
||||
* NOTE: On current Tauri, native Android video rendering is blocked upstream
|
||||
* (transparent webview / SurfaceView compositing — tauri#10152), so video on
|
||||
* Android currently runs through the HTML5 adapter via the interim override in
|
||||
* the factory. This adapter exists for the audio/native path and for when that
|
||||
* upstream limitation is resolved.
|
||||
* NOTE: This adapter is currently unreachable — `createAdapter()` hardcodes the
|
||||
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
|
||||
*
|
||||
* That override was introduced citing tauri#10152 as an upstream blocker. That
|
||||
* is no longer accurate: #10152 is a stale *feature request* (dead since
|
||||
* 2024-07-01) asking that `transparent` not be desktop-only, and the capability
|
||||
* shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen
|
||||
* bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor,
|
||||
* fixed in wry 0.39.4; we ship wry 0.55.x.
|
||||
*
|
||||
* What is genuinely unproven is SurfaceView-behind-WebView *compositing* on
|
||||
* Tauri Android — nothing upstream blocks it, and nothing upstream demonstrates
|
||||
* it either. docs/specs/android-native-video-spike.md tracks that experiment.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-028
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
+10
-10
@@ -5,7 +5,7 @@ import { writable, derived } from "svelte/store";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||
import type { SearchOptions } from "$lib/api/bindings";
|
||||
import { scopeItemTypes, type SearchScope } from "$lib/utils/searchScope";
|
||||
import type { SearchScope } from "$lib/utils/searchScope";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/**
|
||||
@@ -227,12 +227,13 @@ function createLibraryStore() {
|
||||
/**
|
||||
* Search the library, optionally narrowed to a scope.
|
||||
*
|
||||
* `scope` is additive and defaults to `all`, which sends no
|
||||
* `includeItemTypes` at all — see scopeItemTypes() for why that differs from
|
||||
* listing every type. Both the online and offline repository paths already
|
||||
* honour the filter.
|
||||
* The scope is sent **opaque**; Rust expands it into Jellyfin item types
|
||||
* (`SearchScope::item_types()`) before the cache and server paths diverge, so
|
||||
* online and offline results filter identically. `all` resolves to no filter
|
||||
* at all — not the union of the other scopes, which would drop People and
|
||||
* folders.
|
||||
*
|
||||
* TRACES: UR-049 | DR-065
|
||||
* TRACES: UR-049 | DR-063, DR-065
|
||||
*/
|
||||
async function search(query: string, scope: SearchScope = "all") {
|
||||
// Bump the request id for every call (including clears) so any in-flight
|
||||
@@ -259,10 +260,9 @@ function createLibraryStore() {
|
||||
// Phase 1: the command resolves with instant local-cache results. The
|
||||
// merged (cache + server) union arrives later via the `search-event`
|
||||
// listener above, tagged with this same requestId.
|
||||
const itemTypes = scopeItemTypes(scope);
|
||||
const options: SearchOptions = { limit: 10000 };
|
||||
// Omit the key entirely for the `all` scope rather than sending null.
|
||||
if (itemTypes) options.includeItemTypes = itemTypes;
|
||||
// Send the opaque scope; Rust expands it to item types. The frontend
|
||||
// never names a Jellyfin item type in connection with search.
|
||||
const options: SearchOptions = { limit: 10000, scope };
|
||||
|
||||
const result = await Promise.race([
|
||||
repo.search(query, options, requestId),
|
||||
|
||||
@@ -32,35 +32,43 @@ describe("library.search scoping", () => {
|
||||
library.clearSearch();
|
||||
});
|
||||
|
||||
it("omits includeItemTypes entirely for the default (all) scope", async () => {
|
||||
// The frontend sends the OPAQUE scope and never names a Jellyfin item type.
|
||||
// Expansion (music → MusicAlbum/MusicArtist/Audio/Playlist) is asserted in
|
||||
// Rust — see `search_scope_tests` in src-tauri/src/repository/types.rs.
|
||||
// Asserting item types here would mean the frontend knows the taxonomy again,
|
||||
// which is the leak docs/specs/scoped-search-boundary.md exists to prevent.
|
||||
|
||||
it("sends the default (all) scope and never an item-type list", async () => {
|
||||
await library.search("office");
|
||||
|
||||
const options = searchMock.mock.calls[0][1];
|
||||
expect(options.scope).toBe("all");
|
||||
expect(options).not.toHaveProperty("includeItemTypes");
|
||||
expect(options.limit).toBe(10000);
|
||||
});
|
||||
|
||||
it("forwards music item types when scoped to music", async () => {
|
||||
it("sends the opaque scope when scoped to music", async () => {
|
||||
await library.search("office", "music");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
|
||||
"MusicAlbum",
|
||||
"MusicArtist",
|
||||
"Audio",
|
||||
"Playlist",
|
||||
]);
|
||||
const options = searchMock.mock.calls[0][1];
|
||||
expect(options.scope).toBe("music");
|
||||
expect(options).not.toHaveProperty("includeItemTypes");
|
||||
});
|
||||
|
||||
it("forwards tv item types when scoped to tv", async () => {
|
||||
it("sends the opaque scope when scoped to tv", async () => {
|
||||
await library.search("office", "tv");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
|
||||
const options = searchMock.mock.calls[0][1];
|
||||
expect(options.scope).toBe("tv");
|
||||
expect(options).not.toHaveProperty("includeItemTypes");
|
||||
});
|
||||
|
||||
it("forwards movie item types when scoped to movies", async () => {
|
||||
it("sends the opaque scope when scoped to movies", async () => {
|
||||
await library.search("office", "movies");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
|
||||
const options = searchMock.mock.calls[0][1];
|
||||
expect(options.scope).toBe("movies");
|
||||
expect(options).not.toHaveProperty("includeItemTypes");
|
||||
});
|
||||
|
||||
it("stores results and the query on success", async () => {
|
||||
|
||||
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
|
||||
it("loads a stored order", async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
|
||||
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
|
||||
);
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
"tvShows",
|
||||
"episodes",
|
||||
"shows",
|
||||
"movies",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates a stored `tvShows` from before the group split", async () => {
|
||||
// Upgrading must keep the user's placement of TV, not append the two new
|
||||
// groups at the bottom.
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
"shows",
|
||||
"episodes",
|
||||
"movies",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -59,10 +77,12 @@ describe("searchGroupOrder", () => {
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
"movies",
|
||||
"shows",
|
||||
"episodes",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"tvShows",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
|
||||
|
||||
it("persists a move so the order survives a restart", async () => {
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
// Default is shows, episodes, movies, songs, … — move movies up one.
|
||||
searchGroupOrder.move("movies", -1);
|
||||
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
const expected = [
|
||||
"shows",
|
||||
"movies",
|
||||
"episodes",
|
||||
"songs",
|
||||
"albums",
|
||||
"movies",
|
||||
"artists",
|
||||
"tvShows",
|
||||
]);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
|
||||
"songs",
|
||||
"albums",
|
||||
"movies",
|
||||
"artists",
|
||||
"tvShows",
|
||||
]);
|
||||
"people",
|
||||
];
|
||||
expect(get(searchGroupOrder)).toEqual(expected);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
|
||||
|
||||
// Simulate a fresh app start reading the same storage.
|
||||
vi.resetModules();
|
||||
const reloaded = await import("./searchGroupOrder");
|
||||
expect(get(reloaded.searchGroupOrder)).toEqual([
|
||||
"songs",
|
||||
"albums",
|
||||
"movies",
|
||||
"artists",
|
||||
"tvShows",
|
||||
]);
|
||||
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("persists a drag reorder", async () => {
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
// Drag "albums" (index 4) to the front.
|
||||
searchGroupOrder.reorder(4, 0);
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
"tvShows",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"shows",
|
||||
"episodes",
|
||||
"movies",
|
||||
"songs",
|
||||
"artists",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resets to the shipped default", async () => {
|
||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||
searchGroupOrder.move("tvShows", -1);
|
||||
searchGroupOrder.move("movies", -1);
|
||||
searchGroupOrder.reset();
|
||||
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
||||
});
|
||||
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
|
||||
searchGroupOrder.set(["movies", "podcasts"] as never);
|
||||
expect(get(searchGroupOrder)).toEqual([
|
||||
"movies",
|
||||
"shows",
|
||||
"episodes",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"tvShows",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setBackgroundAudioEnabled } from "./backgroundAudio";
|
||||
|
||||
/**
|
||||
* Bridge-reporting contract for the background-audio toggle.
|
||||
*
|
||||
* TRACES: UR-040 | IR-025, DR-051 | UT-062
|
||||
*
|
||||
* Regression guard for the "screen lock kills video audio" bug: MainActivity
|
||||
* re-ran configureWebViewForMedia() on every onResume, re-calling
|
||||
* addJavascriptInterface over a live page. WebView then served a stale proxy —
|
||||
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
|
||||
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
|
||||
*
|
||||
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
|
||||
* a try/catch returning void, so the UI showed "armed" while native never got
|
||||
* the flag — and onStop's `if (backgroundAudioEnabled)` guard never dispatched
|
||||
* `jellytau-background`. Audio died the instant the screen locked.
|
||||
*
|
||||
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
|
||||
* reached, so a dead bridge can never masquerade as an armed toggle.
|
||||
*/
|
||||
describe("setBackgroundAudioEnabled", () => {
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports success when the bridge is present and the call lands", () => {
|
||||
const setEnabled = vi.fn();
|
||||
window.AndroidBackgroundAudio = { setEnabled };
|
||||
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(true);
|
||||
expect(setEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("reports failure when the bridge object is absent entirely", () => {
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports failure for a stale proxy whose methods are gone", () => {
|
||||
// The exact shape of the bug: object present (so `?.` passes) but the
|
||||
// method is missing after re-injection over a live page.
|
||||
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
|
||||
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports failure when the bridge method throws", () => {
|
||||
window.AndroidBackgroundAudio = {
|
||||
setEnabled: () => {
|
||||
throw new TypeError("e.setEnabled is not a function");
|
||||
},
|
||||
};
|
||||
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("never throws out to the caller — the toggle must not break the player", () => {
|
||||
window.AndroidBackgroundAudio = {
|
||||
setEnabled: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
interface AndroidBackgroundAudioBridge {
|
||||
setEnabled(enabled: boolean): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
|
||||
return window.AndroidBackgroundAudio;
|
||||
}
|
||||
|
||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
||||
export function isBackgroundAudioSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||
* side runs the audio handoff on background instead of entering PiP.
|
||||
*/
|
||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
||||
export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
||||
const b = bridge();
|
||||
if (!b) {
|
||||
// The button is gated on platform(), not on this bridge, so it can render
|
||||
// before/without the bridge existing. Silently no-oping here leaves the UI
|
||||
// showing "armed" while native never learns — and the handoff then never
|
||||
// fires on lock. Report it so callers can retry.
|
||||
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
bridge()?.setEnabled(enabled);
|
||||
b.setEnabled(enabled);
|
||||
console.log("[BgAudio] setEnabled ->", enabled);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +7,8 @@ import {
|
||||
normalizeGroupOrder,
|
||||
reorderGroups,
|
||||
resolveSearchScope,
|
||||
scopeItemTypes,
|
||||
searchRouteUrl,
|
||||
shouldNavigateToSearch,
|
||||
type SearchGroupId,
|
||||
} from "./searchScope";
|
||||
|
||||
@@ -60,25 +61,12 @@ describe("resolveSearchScope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scopeItemTypes", () => {
|
||||
it("omits the key entirely for the all scope", () => {
|
||||
// `all` must send no includeItemTypes — an explicit union would silently
|
||||
// drop types nobody enumerated (Person, folders).
|
||||
expect(scopeItemTypes("all")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps each narrow scope to its item types", () => {
|
||||
expect(scopeItemTypes("music")).toEqual(["MusicAlbum", "MusicArtist", "Audio", "Playlist"]);
|
||||
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
|
||||
expect(scopeItemTypes("tv")).toEqual(["Series", "Episode"]);
|
||||
});
|
||||
|
||||
it("returns a fresh array callers cannot mutate into the table", () => {
|
||||
const first = scopeItemTypes("movies")!;
|
||||
first.push("Series");
|
||||
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
|
||||
});
|
||||
});
|
||||
// NOTE: the former `scopeItemTypes` suite moved to Rust — see
|
||||
// `search_scope_tests` in src-tauri/src/repository/types.rs. The scope →
|
||||
// item-type expansion is domain vocabulary and is no longer reachable from the
|
||||
// frontend, so testing it here would mean re-introducing the leak to test it.
|
||||
// The "fresh array" test is gone because `item_types()` returns an owned Vec,
|
||||
// making the aliasing bug it guarded structurally impossible.
|
||||
|
||||
describe("normalizeGroupOrder", () => {
|
||||
it("returns the default for missing or non-array input", () => {
|
||||
@@ -92,9 +80,11 @@ describe("normalizeGroupOrder", () => {
|
||||
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
||||
"movies",
|
||||
"songs",
|
||||
"shows",
|
||||
"episodes",
|
||||
"albums",
|
||||
"artists",
|
||||
"tvShows",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -103,9 +93,11 @@ describe("normalizeGroupOrder", () => {
|
||||
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
||||
"movies",
|
||||
"songs",
|
||||
"shows",
|
||||
"episodes",
|
||||
"albums",
|
||||
"artists",
|
||||
"tvShows",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -113,34 +105,78 @@ describe("normalizeGroupOrder", () => {
|
||||
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
||||
"songs",
|
||||
"movies",
|
||||
"shows",
|
||||
"episodes",
|
||||
"albums",
|
||||
"artists",
|
||||
"tvShows",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a complete valid order unchanged", () => {
|
||||
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
|
||||
const order: SearchGroupId[] = [
|
||||
"episodes",
|
||||
"shows",
|
||||
"movies",
|
||||
"artists",
|
||||
"albums",
|
||||
"songs",
|
||||
"people",
|
||||
];
|
||||
expect(normalizeGroupOrder(order)).toEqual(order);
|
||||
});
|
||||
|
||||
it("expands a stored `tvShows` into shows + episodes in place", () => {
|
||||
// Migration: the old combined group split, and a user who put TV first
|
||||
// must still get TV first rather than appended at the bottom.
|
||||
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
|
||||
"shows",
|
||||
"episodes",
|
||||
"movies",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupsForScope", () => {
|
||||
it("returns every group in saved order for the all scope", () => {
|
||||
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
|
||||
"movies",
|
||||
"songs",
|
||||
"tvShows",
|
||||
"albums",
|
||||
"artists",
|
||||
]);
|
||||
expect(
|
||||
groupsForScope("all", [
|
||||
"movies",
|
||||
"songs",
|
||||
"shows",
|
||||
"episodes",
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
])
|
||||
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
|
||||
});
|
||||
|
||||
it("keeps only in-scope groups, in saved order", () => {
|
||||
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
|
||||
const order: SearchGroupId[] = [
|
||||
"artists",
|
||||
"movies",
|
||||
"albums",
|
||||
"episodes",
|
||||
"shows",
|
||||
"songs",
|
||||
"people",
|
||||
];
|
||||
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
|
||||
expect(groupsForScope("movies", order)).toEqual(["movies"]);
|
||||
expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
|
||||
expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
|
||||
});
|
||||
|
||||
it("surfaces people only under the all scope", () => {
|
||||
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
|
||||
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
|
||||
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
||||
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
||||
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,13 +192,29 @@ describe("composeSearchGroups", () => {
|
||||
|
||||
it("renders groups in the configured order", () => {
|
||||
const groups = composeSearchGroups(results, "all", [
|
||||
"tvShows",
|
||||
"shows",
|
||||
"episodes",
|
||||
"movies",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
]);
|
||||
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
|
||||
expect(groups.map((g) => g.id)).toEqual([
|
||||
"shows",
|
||||
"episodes",
|
||||
"movies",
|
||||
"songs",
|
||||
"albums",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts shows ahead of episodes by default", () => {
|
||||
// Searching a show's name should surface the show itself first, not an
|
||||
// arbitrary episode of it.
|
||||
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
|
||||
expect(ids).toEqual(["shows", "episodes"]);
|
||||
});
|
||||
|
||||
it("omits empty groups", () => {
|
||||
@@ -177,27 +229,44 @@ describe("composeSearchGroups", () => {
|
||||
"albums",
|
||||
]);
|
||||
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
||||
"tvShows",
|
||||
"shows",
|
||||
"episodes",
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups series and episodes together under tvShows", () => {
|
||||
it("separates series and episodes into their own groups", () => {
|
||||
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
|
||||
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
|
||||
expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
|
||||
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
|
||||
});
|
||||
|
||||
it("surfaces people so an actor search reaches their bio", () => {
|
||||
// Person items were previously returned by the backend and silently dropped.
|
||||
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
||||
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
|
||||
});
|
||||
|
||||
it("ignores item types that belong to no group", () => {
|
||||
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
||||
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
|
||||
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
|
||||
const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
|
||||
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
|
||||
});
|
||||
|
||||
it("narrowing then widening restores the full arrangement", () => {
|
||||
// Scope is a filter over the saved order, never a rewrite of it.
|
||||
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
|
||||
const order: SearchGroupId[] = [
|
||||
"shows",
|
||||
"songs",
|
||||
"movies",
|
||||
"albums",
|
||||
"artists",
|
||||
"episodes",
|
||||
"people",
|
||||
];
|
||||
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
|
||||
composeSearchGroups(results, "music", order);
|
||||
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
|
||||
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
|
||||
expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
|
||||
});
|
||||
|
||||
it("survives a stored order containing an unknown id", () => {
|
||||
@@ -205,7 +274,14 @@ describe("composeSearchGroups", () => {
|
||||
"podcasts",
|
||||
"movies",
|
||||
] as unknown as SearchGroupId[]);
|
||||
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
|
||||
expect(groups.map((g) => g.id)).toEqual([
|
||||
"movies",
|
||||
"shows",
|
||||
"episodes",
|
||||
"songs",
|
||||
"albums",
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles items with a missing type", () => {
|
||||
@@ -219,7 +295,7 @@ describe("composeSearchGroups", () => {
|
||||
});
|
||||
|
||||
describe("moveGroup", () => {
|
||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
|
||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||
|
||||
it("moves a group up", () => {
|
||||
expect(moveGroup(order, "artists", -1)).toEqual([
|
||||
@@ -227,7 +303,7 @@ describe("moveGroup", () => {
|
||||
"artists",
|
||||
"albums",
|
||||
"movies",
|
||||
"tvShows",
|
||||
"shows",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -237,13 +313,13 @@ describe("moveGroup", () => {
|
||||
"songs",
|
||||
"artists",
|
||||
"movies",
|
||||
"tvShows",
|
||||
"shows",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op at the boundaries", () => {
|
||||
expect(moveGroup(order, "songs", -1)).toEqual(order);
|
||||
expect(moveGroup(order, "tvShows", 1)).toEqual(order);
|
||||
expect(moveGroup(order, "shows", 1)).toEqual(order);
|
||||
});
|
||||
|
||||
it("is a no-op for an unknown id", () => {
|
||||
@@ -258,18 +334,18 @@ describe("moveGroup", () => {
|
||||
});
|
||||
|
||||
describe("reorderGroups", () => {
|
||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
|
||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||
|
||||
it("moves an item from one index to another", () => {
|
||||
expect(reorderGroups(order, 0, 4)).toEqual([
|
||||
"albums",
|
||||
"artists",
|
||||
"movies",
|
||||
"tvShows",
|
||||
"shows",
|
||||
"songs",
|
||||
]);
|
||||
expect(reorderGroups(order, 4, 0)).toEqual([
|
||||
"tvShows",
|
||||
"shows",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
@@ -283,3 +359,41 @@ describe("reorderGroups", () => {
|
||||
expect(reorderGroups(order, 0, 9)).toEqual(order);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchRouteUrl", () => {
|
||||
it("encodes the query and the scope", () => {
|
||||
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
|
||||
});
|
||||
|
||||
it("omits the scope key for the default `all` scope", () => {
|
||||
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
|
||||
});
|
||||
|
||||
it("targets bare /search for an empty query so the page shows its empty state", () => {
|
||||
expect(searchRouteUrl("", "all")).toBe("/search");
|
||||
expect(searchRouteUrl(" ", "music")).toBe("/search");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldNavigateToSearch", () => {
|
||||
it("navigates from any library page, which cannot render results itself", () => {
|
||||
// The bug: the header search bar shows on every /library/** route but only
|
||||
// /library rendered $library.searchResults, so typing did nothing on
|
||||
// /library/music, /library/tv, /library/movies and detail pages.
|
||||
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
|
||||
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
|
||||
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
|
||||
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
|
||||
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
|
||||
});
|
||||
|
||||
it("stays put when already on /search, so typing does not re-push history", () => {
|
||||
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
|
||||
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not navigate on an empty query", () => {
|
||||
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
|
||||
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+118
-41
@@ -8,7 +8,11 @@
|
||||
//
|
||||
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
||||
|
||||
export type SearchScope = "all" | "music" | "movies" | "tv";
|
||||
// Sourced from Rust via the generated bindings — the backend owns what a scope
|
||||
// *means* (which Jellyfin item types it covers). Naming an opaque variant is
|
||||
// presentation; knowing its expansion is domain vocabulary and stays in Rust.
|
||||
export type { SearchScope } from "$lib/api/bindings";
|
||||
import type { SearchScope } from "$lib/api/bindings";
|
||||
|
||||
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
|
||||
|
||||
@@ -19,29 +23,11 @@ export const SCOPE_LABELS: Record<SearchScope, string> = {
|
||||
tv: "TV",
|
||||
};
|
||||
|
||||
/**
|
||||
* Jellyfin item types requested for each scope.
|
||||
*
|
||||
* `all` is deliberately absent: sending no `includeItemTypes` is *not* the same
|
||||
* as sending the union of the lists below — types nobody enumerated here
|
||||
* (Person, folders, …) would be filtered out by an explicit list.
|
||||
*/
|
||||
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Item types to send with a scoped search, or `undefined` for the `all` scope
|
||||
* so the caller omits the key entirely.
|
||||
*
|
||||
* TRACES: UR-049 | DR-063
|
||||
*/
|
||||
export function scopeItemTypes(scope: SearchScope): string[] | undefined {
|
||||
if (scope === "all") return undefined;
|
||||
return [...SCOPE_ITEM_TYPES[scope]];
|
||||
}
|
||||
// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
|
||||
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
|
||||
// repository/types.rs); the frontend sends the opaque scope and the backend
|
||||
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
|
||||
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.
|
||||
|
||||
/**
|
||||
* Resolve the scope a search started from a given route should default to.
|
||||
@@ -62,51 +48,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
||||
return "all";
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of the single search surface for a query + scope.
|
||||
*
|
||||
* `/search` is the *only* route that renders results, so every other search
|
||||
* affordance (the desktop header bar) is a navigator to this URL rather than a
|
||||
* second result renderer. The `all` scope is the page's own default, so it is
|
||||
* omitted to keep shared/back-navigated URLs clean.
|
||||
*
|
||||
* TRACES: UR-049 | DR-063
|
||||
*/
|
||||
export function searchRouteUrl(query: string, scope: SearchScope): string {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return "/search";
|
||||
|
||||
const params = new URLSearchParams({ q: trimmed });
|
||||
if (scope !== "all") params.set("scope", scope);
|
||||
// URLSearchParams renders spaces as "+", valid in a query but noisier to
|
||||
// read; %20 is equally valid and matches how the app builds other links.
|
||||
return `/search?${params.toString().replace(/\+/g, "%20")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
|
||||
*
|
||||
* True for every route except `/search` itself: no other page renders
|
||||
* `searchResults`, so a search performed there is invisible. Guarding on
|
||||
* `/search` keeps typing from pushing a history entry per keystroke.
|
||||
*
|
||||
* TRACES: UR-049 | DR-063
|
||||
*/
|
||||
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
|
||||
if (!query.trim()) return false;
|
||||
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
||||
return path !== "/search";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
|
||||
export type SearchGroupId =
|
||||
| "shows"
|
||||
| "episodes"
|
||||
| "movies"
|
||||
| "songs"
|
||||
| "albums"
|
||||
| "artists"
|
||||
| "people";
|
||||
|
||||
/** Shipped default order, per the spec. */
|
||||
/**
|
||||
* Shipped default order.
|
||||
*
|
||||
* TRACES: UR-060 | DR-091
|
||||
*
|
||||
* Containers lead the kinds they contain — a show above its episodes, an album
|
||||
* above nothing (songs are ranked separately) — which matches how people search:
|
||||
* you look for the show, not an arbitrary episode of it. `people` sits last as
|
||||
* a peripheral match; it exists so searching an actor's name reaches their bio
|
||||
* page rather than silently dropping the result.
|
||||
*/
|
||||
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
|
||||
"shows",
|
||||
"episodes",
|
||||
"movies",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"movies",
|
||||
"tvShows",
|
||||
"people",
|
||||
];
|
||||
|
||||
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
||||
shows: "TV Shows",
|
||||
episodes: "Episodes",
|
||||
movies: "Movies",
|
||||
songs: "Songs",
|
||||
albums: "Albums",
|
||||
artists: "Artists",
|
||||
movies: "Movies",
|
||||
tvShows: "TV Shows",
|
||||
people: "People",
|
||||
};
|
||||
|
||||
/** Which scopes each group belongs to (`all` always includes everything). */
|
||||
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
|
||||
/**
|
||||
* Which scopes each group belongs to (`all` always includes everything).
|
||||
*
|
||||
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
|
||||
* it surfaces only under All rather than being forced into one of them.
|
||||
*/
|
||||
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
|
||||
shows: "tv",
|
||||
episodes: "tv",
|
||||
movies: "movies",
|
||||
songs: "music",
|
||||
albums: "music",
|
||||
artists: "music",
|
||||
movies: "movies",
|
||||
tvShows: "tv",
|
||||
people: null,
|
||||
};
|
||||
|
||||
/** Item types that fall into each group. */
|
||||
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
||||
shows: ["Series"],
|
||||
episodes: ["Episode"],
|
||||
movies: ["Movie"],
|
||||
songs: ["Audio"],
|
||||
albums: ["MusicAlbum"],
|
||||
artists: ["MusicArtist"],
|
||||
movies: ["Movie"],
|
||||
tvShows: ["Series", "Episode"],
|
||||
people: ["Person"],
|
||||
};
|
||||
|
||||
export function groupItemTypes(group: SearchGroupId): string[] {
|
||||
return [...GROUP_ITEM_TYPES[group]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored group ids that no longer exist, mapped to the ids that replaced them.
|
||||
*
|
||||
* `tvShows` was one group holding both Series and Episode; it split so a show
|
||||
* can outrank its own episodes. Expanding in place preserves the position the
|
||||
* user chose for it.
|
||||
*
|
||||
* TRACES: UR-060 | DR-091
|
||||
*/
|
||||
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
|
||||
tvShows: ["shows", "episodes"],
|
||||
};
|
||||
|
||||
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
|
||||
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
|
||||
if (known.has(id)) return [id as SearchGroupId];
|
||||
return RETIRED_GROUP_IDS[id] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a stored order into a usable one.
|
||||
*
|
||||
@@ -123,11 +194,15 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
|
||||
|
||||
if (Array.isArray(stored)) {
|
||||
for (const id of stored) {
|
||||
if (typeof id !== "string" || !known.has(id)) continue;
|
||||
const groupId = id as SearchGroupId;
|
||||
if (seen.has(groupId)) continue;
|
||||
seen.add(groupId);
|
||||
order.push(groupId);
|
||||
if (typeof id !== "string") continue;
|
||||
// Retired ids expand in place rather than being dropped, so a user who
|
||||
// dragged the old combined "TV Shows" group to the top keeps TV at the
|
||||
// top instead of having shows/episodes appended to the bottom.
|
||||
for (const groupId of migrateGroupId(id, known)) {
|
||||
if (seen.has(groupId)) continue;
|
||||
seen.add(groupId);
|
||||
order.push(groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +218,8 @@ export function groupsForScope(
|
||||
scope: SearchScope,
|
||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
||||
): SearchGroupId[] {
|
||||
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
|
||||
// only under `all` — the `=== scope` test already excludes it elsewhere.
|
||||
return normalizeGroupOrder(order as SearchGroupId[]).filter(
|
||||
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
||||
);
|
||||
|
||||
@@ -7,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}
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
import { library } from "$lib/stores/library";
|
||||
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||
import Search from "$lib/components/Search.svelte";
|
||||
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
||||
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
|
||||
import {
|
||||
resolveSearchScope,
|
||||
searchRouteUrl,
|
||||
shouldNavigateToSearch,
|
||||
type SearchScope,
|
||||
} from "$lib/utils/searchScope";
|
||||
import AppHeader from "$lib/components/AppHeader.svelte";
|
||||
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||
@@ -48,18 +52,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// The header bar is a *navigator*, not a second results surface: /search is
|
||||
// the only route that renders searchResults, so searching here routes there
|
||||
// with the query + route-derived scope in the URL. Previously this ran
|
||||
// library.search() in place, which was invisible on every /library/** page
|
||||
// except /library itself.
|
||||
// TRACES: UR-049 | DR-063
|
||||
async function handleSearch(query: string) {
|
||||
if (query.trim()) {
|
||||
await library.search(query, searchScope);
|
||||
} else {
|
||||
if (!query.trim()) {
|
||||
library.clearSearch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScopeChange(next: SearchScope) {
|
||||
searchScope = next;
|
||||
if (searchQuery.trim()) {
|
||||
await library.search(searchQuery, next);
|
||||
if (shouldNavigateToSearch($page.url.pathname, query)) {
|
||||
await goto(searchRouteUrl(query, searchScope));
|
||||
// The query now lives in the URL; clear the header input so returning to
|
||||
// a library page does not leave a stale term sitting in the box.
|
||||
searchQuery = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -74,14 +82,12 @@
|
||||
<AppHeader search={librarySearch} />
|
||||
|
||||
{#snippet librarySearch()}
|
||||
<!-- Scope chips live on /search, which owns the results. -->
|
||||
<Search
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
{#if searchQuery.trim()}
|
||||
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user