Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f03dd142 | ||
|
|
514e42fccb | ||
|
|
9b1c9b3c91 | ||
|
|
1780109fb1 | ||
|
|
3a18ad060b | ||
|
|
1968c06172 | ||
|
|
ec8a7610f5 | ||
|
|
93d198ce21 | ||
|
|
2b42b74912 | ||
|
|
7660a33dfc | ||
|
|
3e962a202c | ||
|
|
43ddc5a889 | ||
|
|
772e9ca6d5 | ||
|
|
55fa26377a | ||
|
|
f89b241ad6 | ||
|
|
8b028b6b60 | ||
|
|
dd9d4191f1 | ||
|
|
bacb9ca0bb | ||
|
|
cf9472f04f | ||
|
|
f25deba824 | ||
|
|
8f4f651bac | ||
|
|
c175378f38 | ||
|
|
e083b53ee8 | ||
|
|
8f8433eebe | ||
|
|
6f057ad14a | ||
|
|
bebe13eb62 | ||
|
|
a8adbe25cc | ||
|
|
acf1bb200d | ||
|
|
3fbf6afdbc | ||
|
|
4e6ab017d4 | ||
|
|
027054a200 | ||
|
|
1fa5aa46f9 | ||
|
|
7b8a8f66e5 | ||
|
|
2e479d05b3 | ||
|
|
1992a8187d | ||
|
|
532ffa661a | ||
|
|
2a1f1689b4 | ||
|
|
a2cd9978f0 | ||
|
|
36be192d44 | ||
|
|
acb7e5f221 | ||
|
|
68c8602230 | ||
|
|
2d141e5bf4 | ||
|
|
c58cc0cf46 | ||
|
|
8938e3fdba | ||
|
|
e2c12615c5 | ||
|
|
0b5a3aa176 | ||
|
|
37455bc470 | ||
|
|
a64e1b1fb4 | ||
|
|
1f6977cd01 | ||
|
|
6af7f7dcca | ||
|
|
75014ee00f | ||
|
|
342f95cac1 | ||
|
|
dcee342c47 | ||
|
|
78f5cd9db9 | ||
|
|
0eae81ec59 | ||
|
|
8eae4ae253 | ||
|
|
ef7be645b3 | ||
|
|
b9249f72e9 | ||
|
|
385d2270c9 | ||
|
|
345bd0730c | ||
|
|
e1e50d51e0 | ||
|
|
7d7f27aa10 | ||
|
|
f1d25c4f4d | ||
|
|
ff8f35084b | ||
|
|
4634ed595c | ||
|
|
6836ce79c8 | ||
|
|
2811e1b7ca | ||
|
|
1836615dc0 | ||
|
|
62874564ff | ||
|
|
17a35573a0 |
@@ -49,6 +49,13 @@ jobs:
|
||||
run: |
|
||||
bun install
|
||||
|
||||
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
||||
# multi-type includeItemTypes query defining a category in the frontend).
|
||||
# See scripts/check-frontend-boundary.sh and
|
||||
# docs/specs/scoped-search-boundary.md.
|
||||
- name: Check frontend/backend boundary
|
||||
run: bash scripts/check-frontend-boundary.sh
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
@@ -60,16 +67,24 @@ jobs:
|
||||
cargo test
|
||||
cd ..
|
||||
|
||||
build:
|
||||
name: Build Android APK
|
||||
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
||||
# the full signed release APK is built only on tag pushes by build-release.yml
|
||||
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
||||
# too would duplicate a ~15min build and, without the sync step, produced an
|
||||
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
||||
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
||||
# breakage without linking, bundling, or signing.
|
||||
android-check:
|
||||
name: Android Compile Check
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
NDK_VERSION: 27.0.11902837
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -97,42 +112,13 @@ jobs:
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install
|
||||
run: bun install
|
||||
|
||||
- name: Build frontend
|
||||
run: bun run build
|
||||
|
||||
- name: Ensure Android NDK
|
||||
run: |
|
||||
if [ ! -d "$NDK_HOME" ]; then
|
||||
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
|
||||
fi
|
||||
echo "Using NDK at $NDK_HOME"
|
||||
ls "$NDK_HOME"
|
||||
|
||||
- name: Initialize Android project
|
||||
- name: Cargo check (aarch64-linux-android)
|
||||
run: |
|
||||
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
||||
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
||||
export AR_aarch64_linux_android="$TC/llvm-ar"
|
||||
cd src-tauri
|
||||
echo "" | bunx tauri android init
|
||||
cd ..
|
||||
|
||||
- name: Build Android APK
|
||||
id: build
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
bun run tauri android build --apk true --target aarch64
|
||||
|
||||
# Find the generated APK file
|
||||
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
|
||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-apk
|
||||
path: ${{ steps.build.outputs.artifact }}
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
cargo check --target aarch64-linux-android --lib
|
||||
|
||||
@@ -161,10 +161,10 @@ jobs:
|
||||
|
||||
- name: Set app version from tag
|
||||
run: |
|
||||
REF="${GITHUB_REF#refs/tags/v}"
|
||||
VERSION="${REF#refs/heads/}"
|
||||
# On non-tag runs keep whatever is in tauri.conf.json
|
||||
# On a tag build, the tag is the single source of truth for the
|
||||
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting version to $VERSION"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||
fi
|
||||
@@ -173,6 +173,35 @@ jobs:
|
||||
- name: Initialize Android project
|
||||
run: bun run tauri android init
|
||||
|
||||
- name: Pin a monotonic Android versionCode
|
||||
run: |
|
||||
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
||||
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
||||
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
||||
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
||||
# plain 15 would be a *downgrade* and Android would refuse the update.
|
||||
#
|
||||
# Derive an explicit code that is both monotonic in semver order and
|
||||
# always above the 1000 floor already in the field:
|
||||
# code = 1000 + major*10000 + minor*100 + patch
|
||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||
# Guard against a malformed/missing component so we never emit code 0.
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||
echo "version=$VERSION -> versionCode=$CODE"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
else
|
||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||
fi
|
||||
cat "$PROPS"
|
||||
|
||||
- name: Sync custom Android sources & gradle config
|
||||
run: ./scripts/sync-android-sources.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Publish Documentation
|
||||
|
||||
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
|
||||
# API reference with cargo doc, and force-pushes the combined output to the
|
||||
# orphan `gitea-pages` branch that the Gitea Pages server serves.
|
||||
#
|
||||
# The published matrix is regenerated during the build, so it is never stale.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
# Only one docs publish at a time; a newer push supersedes an in-flight run.
|
||||
group: publish-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish-docs:
|
||||
name: Build & publish docs to gitea-pages
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
set -e
|
||||
MDBOOK_VERSION=v0.4.40
|
||||
URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}"
|
||||
curl -fsSL "$URL" | tar -xz -C /usr/local/bin
|
||||
mdbook --version
|
||||
|
||||
- name: Regenerate traceability matrix (keep published copy current)
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Assemble mdBook sources
|
||||
run: |
|
||||
set -e
|
||||
# mdBook's src is docs/. Drop in the SUMMARY and the generated
|
||||
# intro + API redirect pages (build artifacts, not committed).
|
||||
cp docs-site/SUMMARY.md docs/SUMMARY.md
|
||||
|
||||
cat > docs/README.md <<'EOF'
|
||||
# JellyTau Documentation
|
||||
|
||||
Cross-platform Jellyfin client — business logic in a Rust backend,
|
||||
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
|
||||
|
||||
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
|
||||
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
|
||||
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
|
||||
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
|
||||
|
||||
_This site is published automatically from `master` by the `publish-docs` CI job._
|
||||
EOF
|
||||
|
||||
cat > docs/api-redirect.md <<'EOF'
|
||||
# Rust API Reference
|
||||
|
||||
The full backend API reference is generated by `cargo doc` (rustdoc).
|
||||
|
||||
👉 **[Open the Rust API Reference](api/index.html)**
|
||||
EOF
|
||||
|
||||
- name: Build mdBook site
|
||||
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
|
||||
|
||||
- name: Build Rust API docs (cargo doc)
|
||||
working-directory: src-tauri
|
||||
# --no-deps keeps it to our own crate (fast, focused); document private
|
||||
# items so internal modules/commands appear.
|
||||
run: |
|
||||
cargo doc --no-deps --document-private-items
|
||||
# The backend modules/commands live in the LIB crate (jellytau_lib);
|
||||
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
|
||||
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
|
||||
> target/doc/index.html
|
||||
|
||||
- name: Assemble published output
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p "$GITHUB_WORKSPACE/site/api"
|
||||
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
|
||||
# Disable Jekyll processing on the pages branch.
|
||||
touch "$GITHUB_WORKSPACE/site/.nojekyll"
|
||||
ls -la "$GITHUB_WORKSPACE/site"
|
||||
|
||||
- name: Push to gitea-pages branch
|
||||
env:
|
||||
# PAT preferred; falls back to the auto-provided token (same pattern
|
||||
# as build-release.yml).
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
|
||||
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
|
||||
|
||||
cd "$GITHUB_WORKSPACE/site"
|
||||
git init -q
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git checkout -q -b gitea-pages
|
||||
git add -A
|
||||
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
|
||||
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
|
||||
git commit -q -m "docs: publish site from ${SHORT_SHA}"
|
||||
echo "🚀 Force-pushing to gitea-pages"
|
||||
git push -f "$REMOTE" gitea-pages
|
||||
@@ -25,9 +25,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
name: Requirement Traceability Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
traceability:
|
||||
name: Validate Requirement Traces
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Extract requirement traces
|
||||
run: bun run traces:json > traces.json
|
||||
|
||||
- name: Validate trace format
|
||||
run: |
|
||||
if ! jq empty traces.json 2>/dev/null; then
|
||||
echo "❌ Invalid traces.json format"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Traces JSON is valid"
|
||||
|
||||
- name: Check requirement coverage
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Extract coverage stats
|
||||
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
||||
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
||||
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
||||
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
||||
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
||||
|
||||
echo "## 📊 Requirement Traceability Report"
|
||||
echo ""
|
||||
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
||||
echo ""
|
||||
echo "### Requirements Covered:"
|
||||
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
||||
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
||||
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
||||
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
||||
echo ""
|
||||
|
||||
# Set minimum coverage threshold (50%)
|
||||
TOTAL_REQS=114
|
||||
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
||||
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
||||
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
||||
|
||||
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
||||
echo ""
|
||||
|
||||
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
||||
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
||||
fi
|
||||
|
||||
- name: Check for new untraced code
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Find files modified in this PR/push
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "✅ No source files changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "### Files Changed:"
|
||||
echo "$CHANGED_FILES" | sed 's/^/- /'
|
||||
echo ""
|
||||
|
||||
# Check if changed files have TRACES
|
||||
UNTRACED_FILES=""
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
# Skip test files and generated code
|
||||
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file has TRACES comments
|
||||
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
||||
UNTRACED_FILES+="$file"$'\n'
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ -n "$UNTRACED_FILES" ]; then
|
||||
echo "⚠️ New files without TRACES:"
|
||||
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
||||
echo ""
|
||||
echo "💡 Add TRACES comments to link code to requirements:"
|
||||
echo " // TRACES: UR-001, UR-002 | DR-003"
|
||||
else
|
||||
echo "✅ All changed files have TRACES comments"
|
||||
fi
|
||||
|
||||
- name: Generate traceability report
|
||||
if: always()
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Upload traceability report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: traceability-report
|
||||
path: docs/traceability.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment PR with coverage report
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
||||
|
||||
const urCount = traces.byType.UR.length;
|
||||
const irCount = traces.byType.IR.length;
|
||||
const drCount = traces.byType.DR.length;
|
||||
const jaCount = traces.byType.JA.length;
|
||||
const total = urCount + irCount + drCount + jaCount;
|
||||
const coverage = Math.round((total / 114) * 100);
|
||||
|
||||
const comment = `## 📊 Requirement Traceability Report
|
||||
|
||||
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
||||
|
||||
### By Type:
|
||||
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
||||
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
||||
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
||||
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
||||
|
||||
**Total Traces:** ${traces.totalTraces}
|
||||
|
||||
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
@@ -58,3 +58,9 @@ android-keystore/
|
||||
|
||||
# Local machine-specific Android NDK toolchain paths (do not commit)
|
||||
src-tauri/.cargo/config.toml
|
||||
|
||||
# Docs site build artifacts (generated by the publish-docs CI job into docs/)
|
||||
/docs/SUMMARY.md
|
||||
/docs/README.md
|
||||
/docs/api-redirect.md
|
||||
/docs-site/book/
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# JellyTau
|
||||
|
||||
A cross-platform Jellyfin client. Business logic lives in a Rust backend
|
||||
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
|
||||
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
|
||||
`<video>` for transcoded playback) and **Android** (ExoPlayer).
|
||||
|
||||
Package manager is **bun**.
|
||||
|
||||
## Build / Run / Test
|
||||
|
||||
All routine tasks go through `package.json` scripts and helper scripts in
|
||||
`scripts/`:
|
||||
|
||||
```bash
|
||||
bun install # install deps
|
||||
bun run dev # vite dev server (frontend)
|
||||
bun run tauri dev # run the desktop app
|
||||
|
||||
bun run check # svelte-check (types)
|
||||
bun run test # vitest (frontend unit/integration)
|
||||
bun run test:rust # cargo test (scripts/test-rust.sh)
|
||||
bun run test:all # full suite (scripts/test-all.sh)
|
||||
bun run test:e2e # webdriverio e2e
|
||||
|
||||
# Android — canonical entry points (see scripts/):
|
||||
bun run android:build # debug APK
|
||||
bun run android:build:release # release APK
|
||||
bun run android:deploy # install to connected device
|
||||
bun run android:dev # build + deploy
|
||||
bun run android:logs # logcat
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Before Committing
|
||||
|
||||
- Frontend: `bun run check` and `bun run test` must pass.
|
||||
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
|
||||
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
|
||||
item-type category sets) leaked into the frontend. See below.
|
||||
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
|
||||
comment (see below).
|
||||
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
|
||||
then run `scripts/sync-android-sources.sh` to sync into the `gen/` tree.
|
||||
Never edit the generated `gen/` sources directly.
|
||||
|
||||
## Traceability (TRACES)
|
||||
|
||||
This project practices requirement-driven development: code that implements a
|
||||
requirement is tagged with a `TRACES:` comment linking it to requirement IDs, and
|
||||
an extraction tool builds the traceability matrix. **When you add or change code
|
||||
that implements a requirement, add/update its TRACES comment.** Internal helpers
|
||||
and requirement-less code stay untraced.
|
||||
|
||||
Format — `// TRACES: <URs> | <DRs> | <tests>`, e.g.:
|
||||
|
||||
```rust
|
||||
/// TRACES: UR-005 | DR-001
|
||||
pub enum PlayerState { … }
|
||||
```
|
||||
```typescript
|
||||
// TRACES: UR-005, UR-026 | DR-029
|
||||
export function autoplayNextEpisode() { }
|
||||
```
|
||||
|
||||
ID types: **UR** user requirement, **IR** integration, **DR** development, **JA**
|
||||
Jellyfin API, **UT** unit test, **IT** integration test. Requirements are defined
|
||||
in [docs/requirements.md](docs/requirements.md); the generated matrix is
|
||||
[docs/traceability.md](docs/traceability.md).
|
||||
|
||||
Tooling:
|
||||
|
||||
```bash
|
||||
bun run traces # extract traces (default format)
|
||||
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
|
||||
bun run traces:markdown # regenerate docs/traceability.md
|
||||
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
|
||||
```
|
||||
|
||||
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
|
||||
GitHub. `traceability-check.yml` fails the build if coverage drops below
|
||||
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
|
||||
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
|
||||
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
||||
|
||||
### Traces drive release notes
|
||||
|
||||
Prefer traceability over raw commit subjects when writing release notes for
|
||||
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
|
||||
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
|
||||
release touched.
|
||||
|
||||
```bash
|
||||
bun run release:notes # <latest tag>..HEAD
|
||||
bun run release:notes v0.0.15..HEAD # explicit range
|
||||
```
|
||||
|
||||
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
|
||||
changed files → their `TRACES:` IDs → descriptions in
|
||||
[docs/requirements.md](docs/requirements.md), then groups **UR** into *Features*
|
||||
and **DR/IR** into *Improvements* (deduped, so many commits touching one
|
||||
requirement collapse to one line). It also lists changed files that carry no
|
||||
TRACES so nothing is silently dropped — those still need a manual line. Treat the
|
||||
output as a reviewed draft, not a final changelog.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Rust backend** (`src-tauri/src/`) — all business logic: auth, catalog,
|
||||
sessions, downloads, offline cache, playback control. Commands grouped by
|
||||
domain in `src-tauri/src/commands/` (`auth.rs`, `catalog.rs`, `player/`,
|
||||
`download/`, `offline.rs`, `sessions.rs`, …).
|
||||
- **Svelte frontend** (`src/`) — presentation only. Stores in
|
||||
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
|
||||
`src/lib/components/`.
|
||||
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
|
||||
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
|
||||
ExoPlayer with a foreground media service + `MediaSessionCompat`.
|
||||
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
|
||||
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
|
||||
|
||||
**Read the architecture docs before making structural changes** — they are the
|
||||
canonical, maintained source; this file only summarizes. See
|
||||
[docs/architecture/README.md](docs/architecture/README.md) and:
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
|
||||
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
|
||||
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
|
||||
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
|
||||
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
|
||||
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
|
||||
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
|
||||
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
|
||||
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
||||
|
||||
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
||||
and [docs/build-release.md](docs/build-release.md).
|
||||
|
||||
### Core principles (from the architecture docs)
|
||||
|
||||
- **Playback state is one-directional.** The player (ExoPlayer on Android, MPV on
|
||||
Linux, session poller in remote mode) is the **authoritative source** of state
|
||||
— position, pause, seeking, rate, track changes. The Svelte UI, OS
|
||||
`MediaSession`/lockscreen, and MPRIS are **consumers**; they reflect what the
|
||||
player reports and never determine it.
|
||||
- **Unified player boundary.** UI controls playback *only* through the frontend
|
||||
facade `src/lib/player/index.ts` (`playerController`) — never by calling
|
||||
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
|
||||
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
|
||||
commands, so the controller stays the single source of truth in both native
|
||||
and HTML5 modes.
|
||||
- **Reachability from real traffic.** Server online/offline is derived from the
|
||||
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
|
||||
a side-channel poller. The `/System/Info/Public` probe runs *only while
|
||||
offline*, as a recovery detector.
|
||||
- **Poison-tolerant locking.** Access shared `std::sync` state via the
|
||||
`MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned
|
||||
lock instead of cascading a panic across the player.
|
||||
- **Graceful backend init.** If a native player backend fails to initialize, the
|
||||
app falls back to a no-op backend and emits `backend-init-failed` rather than
|
||||
crashing.
|
||||
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
|
||||
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
|
||||
category like "Music". Send an opaque scope/enum across the boundary and let the
|
||||
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.
|
||||
|
||||
## Writing specs
|
||||
|
||||
New feature specs go in [docs/specs/](docs/specs/). **Start from
|
||||
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
|
||||
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
|
||||
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
|
||||
Before accepting a spec, run it past
|
||||
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
|
||||
a spec around "no Rust changes required" — correct layer placement is the goal,
|
||||
not minimal backend churn.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Rust Backend
|
||||
|
||||
- Use `#[tauri::command]` for all IPC handlers.
|
||||
- Prefer `async` commands for I/O-bound work.
|
||||
- Return `Result<T, String>` from commands (the established convention here).
|
||||
- Use `tauri::State<>` for shared state.
|
||||
- Group related commands in domain modules under `commands/`.
|
||||
- Use official Tauri plugins before writing custom native code.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Use `invoke<T>()` from `@tauri-apps/api/core`, or the tauri-specta bindings.
|
||||
- Define TS types matching the Rust structs; prefer the generated bindings.
|
||||
- Handle IPC errors with try/catch.
|
||||
- Use `@tauri-apps/api/path` for paths (never hardcode).
|
||||
- Use `@tauri-apps/api/event` for backend→frontend events.
|
||||
|
||||
### 🔴 IPC parameter naming (Tauri v2)
|
||||
|
||||
The command **name** must match the Rust function name exactly
|
||||
(`invoke("player_play_queue", …)`). But **parameter names do NOT** — Tauri v2's
|
||||
`#[tauri::command]` macro auto-converts snake_case Rust params to **camelCase**
|
||||
on the frontend:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn cmd(repository_handle: String) { … }
|
||||
```
|
||||
```typescript
|
||||
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted
|
||||
```
|
||||
|
||||
Nested struct fields need `#[serde(rename_all = "camelCase")]`; tagged unions use
|
||||
`#[serde(tag = "type")]` and both sides must match the tag. Note: tauri-specta
|
||||
tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
||||
|
||||
### Events
|
||||
|
||||
- Backend events use **kebab-case** names (`download-event`, `search-event`).
|
||||
- Emit from Rust via `emit(...)`; consume on the frontend via
|
||||
`@tauri-apps/api/event` or the tauri-specta typed event bindings.
|
||||
|
||||
### Security
|
||||
|
||||
- Declare minimum permissions in `src-tauri/capabilities/`.
|
||||
- Keep the CSP restrictive in `tauri.conf.json`.
|
||||
- Validate all inputs in Rust command handlers.
|
||||
- **Never read credentials** (tokens/keys from keyring, env, or stores) without
|
||||
asking the user first.
|
||||
|
||||
## Gotchas (hard-won)
|
||||
|
||||
- **Never call sync/blocking APIs from event callbacks** that can re-enter the
|
||||
player or hold a lock — it deadlocks. On Android, bind a locked
|
||||
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
|
||||
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
|
||||
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
|
||||
(it flips to HTML5 mode and breaks Android seek).
|
||||
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
|
||||
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
|
||||
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
|
||||
Don't loop `startDownload` from the frontend.
|
||||
- **Parallel Claude sessions**: the user may run concurrent sessions. Unexpected
|
||||
file changes may be another session — check `git diff` before "repairing".
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Rust
|
||||
cd src-tauri && cargo test
|
||||
cd src-tauri && cargo test test_name # single test
|
||||
|
||||
# Frontend
|
||||
bun run test
|
||||
bun run test:coverage
|
||||
|
||||
# Tauri IPC param-naming integration tests (guard the camelCase rule):
|
||||
bun run test -- tauriIntegration.test.ts
|
||||
```
|
||||
@@ -1,4 +1,7 @@
|
||||
# JellyTau
|
||||
<h1 align="center">
|
||||
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
||||
JellyTau
|
||||
</h1>
|
||||
|
||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@wdio/cli": "^9.5.0",
|
||||
"@wdio/local-runner": "^9.5.0",
|
||||
@@ -30,7 +31,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest": ">=1.0.0 <5.0.0",
|
||||
"webdriverio": "^9.5.0",
|
||||
},
|
||||
},
|
||||
@@ -46,10 +47,18 @@
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
|
||||
@@ -348,6 +357,8 @@
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
|
||||
@@ -362,7 +373,7 @@
|
||||
|
||||
"@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
"@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="],
|
||||
|
||||
@@ -422,6 +433,8 @@
|
||||
|
||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
|
||||
@@ -498,6 +511,8 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
@@ -686,6 +701,8 @@
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
@@ -738,6 +755,12 @@
|
||||
|
||||
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
@@ -756,7 +779,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
@@ -828,6 +851,10 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -1020,7 +1047,7 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
|
||||
|
||||
@@ -1042,7 +1069,7 @@
|
||||
|
||||
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="],
|
||||
|
||||
@@ -1068,7 +1095,7 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
|
||||
|
||||
@@ -1166,6 +1193,10 @@
|
||||
|
||||
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -1190,10 +1221,24 @@
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/ui/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
|
||||
|
||||
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -1260,6 +1305,8 @@
|
||||
|
||||
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
|
||||
|
||||
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
@@ -1298,6 +1345,12 @@
|
||||
|
||||
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
|
||||
|
||||
"vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
@@ -1328,7 +1381,7 @@
|
||||
|
||||
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
"@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
@@ -1338,34 +1391,24 @@
|
||||
|
||||
"jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -1432,8 +1475,6 @@
|
||||
|
||||
"wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Summary
|
||||
|
||||
[Introduction](README.md)
|
||||
|
||||
# Requirements & Traceability
|
||||
|
||||
- [Requirements Specification](requirements.md)
|
||||
- [Traceability Matrix](traceability.md)
|
||||
- [Traceability CI](traceability-ci.md)
|
||||
- [Traces Quick Reference](traces-quick-ref.md)
|
||||
|
||||
# Architecture
|
||||
|
||||
- [Overview](architecture/README.md)
|
||||
- [Rust Backend](architecture/01-rust-backend.md)
|
||||
- [Svelte Frontend](architecture/02-svelte-frontend.md)
|
||||
- [Data Flow](architecture/03-data-flow.md)
|
||||
- [Type Sync & Threading](architecture/04-type-sync-and-threading.md)
|
||||
- [Platform Backends](architecture/05-platform-backends.md)
|
||||
- [Downloads & Offline](architecture/06-downloads-and-offline.md)
|
||||
- [Connectivity](architecture/07-connectivity.md)
|
||||
- [Database Design](architecture/08-database-design.md)
|
||||
- [Security](architecture/09-security.md)
|
||||
|
||||
# UX & Specs
|
||||
|
||||
- [UX Flows](ux-flows.md)
|
||||
- [Video Background Audio](specs/video-background-audio.md)
|
||||
|
||||
# Build & Release
|
||||
|
||||
- [Build & Release](build-release.md)
|
||||
- [Release Checklist](release-checklist.md)
|
||||
- [Docker](build/docker.md)
|
||||
- [Builder Image](build/build-builder-image.md)
|
||||
|
||||
---
|
||||
|
||||
[Rust API Reference (rustdoc)](api-redirect.md)
|
||||
@@ -0,0 +1,25 @@
|
||||
# mdBook config for the published JellyTau documentation site.
|
||||
# The book's `src` is the repo `docs/` directory (see [build] below); this file
|
||||
# and SUMMARY.md live in docs-site/ to avoid cluttering docs/. The publish-docs
|
||||
# CI job copies SUMMARY.md into docs/ at build time, renders, and pushes the
|
||||
# result (plus the rustdoc API under /api/) to the orphan `gitea-pages` branch.
|
||||
[book]
|
||||
title = "JellyTau Documentation"
|
||||
description = "Requirements, traceability, and architecture for the JellyTau Jellyfin client."
|
||||
authors = ["Duncan Tourolle"]
|
||||
language = "en"
|
||||
# Sources live in the repo docs/ dir (one level up from this book root).
|
||||
src = "../docs"
|
||||
|
||||
[output.html]
|
||||
default-theme = "navy"
|
||||
preferred-dark-theme = "navy"
|
||||
git-repository-url = "https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||
edit-url-template = "https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/{path}"
|
||||
|
||||
[output.html.fold]
|
||||
enable = true
|
||||
level = 1
|
||||
|
||||
[output.html.search]
|
||||
enable = true
|
||||
@@ -51,14 +51,19 @@ graph TD
|
||||
|
||||
**View Enforcement:**
|
||||
|
||||
Ordinal content (where position carries meaning) is always a list. Everything
|
||||
else honours the user's persisted grid/list preference — see
|
||||
[ux-flows.md §5A.2](../ux-flows.md).
|
||||
|
||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||
|--------------|-----------|----------------|----------------|
|
||||
| Tracks | List (forced) | No | `TrackList` |
|
||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
||||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Artists | User preference | Yes | `LibraryGrid` |
|
||||
| Albums | User preference | Yes | `LibraryGrid` |
|
||||
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||
|
||||
**TrackList Component:**
|
||||
|
||||
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
|
||||
/>
|
||||
```
|
||||
|
||||
**LibraryGrid forceGrid Prop:**
|
||||
**LibraryGrid view mode:**
|
||||
|
||||
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
||||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||||
prop controls whether the toggle buttons appear in the page header; the grid
|
||||
itself always follows the stored preference.
|
||||
|
||||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||||
preference. No caller ever passed it, so it was removed — pages that were
|
||||
documented as "forced grid" have in practice always honoured the toggle.
|
||||
|
||||
## Playback Reporting Service
|
||||
|
||||
|
||||
@@ -90,6 +90,50 @@ flowchart LR
|
||||
|
||||
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||
|
||||
## HTML5 Video Adapter (webview-rendered video)
|
||||
|
||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||
`src-tauri/src/commands/player/timers.rs`
|
||||
|
||||
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||||
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||
the real player, living outside Rust's reach.
|
||||
|
||||
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
||||
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
||||
authority:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Webview["Webview"]
|
||||
Video["HTML5 <video> / HLS.js"]
|
||||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
||||
end
|
||||
subgraph Backend["Rust"]
|
||||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
||||
Controller["PlayerController"]
|
||||
Emitter["TauriEventEmitter"]
|
||||
end
|
||||
subgraph Frontend["Frontend"]
|
||||
Events["playerEvents.ts"]
|
||||
Store["player store"]
|
||||
end
|
||||
|
||||
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
||||
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
||||
another event source feeding the existing pipeline.
|
||||
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
||||
60fps RAF loop.
|
||||
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
||||
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
||||
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
||||
("frontend only displays state and invokes commands") for the video path.
|
||||
|
||||
## MpvBackend (Linux)
|
||||
|
||||
**Location**: `src-tauri/src/player/mpv/`
|
||||
|
||||
@@ -13,6 +13,7 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
|
||||
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
||||
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
||||
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
||||
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
|
||||
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
||||
- **Cache-First**: Parallel queries with intelligent fallback.
|
||||
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
|
||||
@@ -166,6 +167,9 @@ src/lib/
|
||||
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
||||
│ ├── client.ts # JellyfinClient (helper for streaming)
|
||||
│ └── sessions.ts # SessionsApi (remote session control)
|
||||
├── player/ # Unified player boundary (frontend)
|
||||
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
||||
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
|
||||
├── services/
|
||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||
|
||||
|
After Width: | Height: | Size: 142 KiB |
@@ -50,6 +50,24 @@ For a narrative overview of the system design, see
|
||||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
||||
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
||||
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
||||
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
|
||||
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
|
||||
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
|
||||
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
|
||||
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done |
|
||||
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
||||
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
||||
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
||||
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
|
||||
| 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-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-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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,6 +103,11 @@ External system integrations and platform-specific implementations.
|
||||
| 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 |
|
||||
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
|
||||
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -123,6 +146,7 @@ API endpoints and data contracts required for Jellyfin integration.
|
||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
||||
|
||||
### 2.3 Development Requirements
|
||||
|
||||
@@ -180,6 +204,39 @@ Internal architecture, components, and application logic.
|
||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
||||
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
||||
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
||||
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
|
||||
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
|
||||
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
|
||||
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
|
||||
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
|
||||
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
|
||||
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done |
|
||||
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
||||
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
||||
| 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-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-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 |
|
||||
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
|
||||
| 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-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-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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +255,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||
| UR-010 | IR-012, IR-021 | DR-037 |
|
||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||
| UR-012 | IR-009, IR-014 | - |
|
||||
| UR-013 | IR-013 | DR-017 |
|
||||
@@ -228,6 +285,24 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
|
||||
---
|
||||
|
||||
@@ -295,6 +370,14 @@ Internal architecture, components, and application logic.
|
||||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
||||
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
||||
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
||||
| 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-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-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 |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -312,6 +395,9 @@ Internal architecture, components, and application logic.
|
||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Spec review checklist
|
||||
|
||||
Run a spec past this before accepting it. It exists because JellyTau's
|
||||
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
||||
rule lived in the architecture docs, but nothing forced a spec author to check a
|
||||
new design against it, and a "minimal-change" spec quietly leaked domain
|
||||
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
||||
This checklist is the human gate. The CI check
|
||||
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
||||
signature — it does **not** replace this.
|
||||
|
||||
Copy the boxes into the review comment (or the PR) and tick them.
|
||||
|
||||
## Boundary (the one that bites)
|
||||
|
||||
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
||||
*logic*, not files. A spec without this section is not ready to review.
|
||||
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
||||
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
||||
query-shaping rules, business rules, reachability/sync policy. If the
|
||||
frontend names a *set* of item types to define a category, that is a leak —
|
||||
it belongs behind an opaque enum the backend expands.
|
||||
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
||||
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
||||
the decision of its value.
|
||||
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
||||
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
||||
The goal is *correct layer placement*; sometimes that is more Rust work.
|
||||
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
||||
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
||||
frontend. Borderline defaults to Rust.
|
||||
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
||||
is **not** over-corrected into the backend. The rule targets category
|
||||
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
||||
list page.
|
||||
|
||||
## IPC contract
|
||||
|
||||
- [ ] Anything crossing the boundary has its wire shape specified.
|
||||
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
||||
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
||||
sides; events are kebab-case. (CLAUDE.md §IPC,
|
||||
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
||||
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
||||
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
||||
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
||||
|
||||
## Requirements & traceability
|
||||
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
||||
pointing here — no two specs silently contradicting.
|
||||
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
||||
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
||||
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
||||
|
||||
---
|
||||
|
||||
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
||||
assignment first. Every other section can be negotiated; that one is the whole
|
||||
reason this file exists.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Spec: <feature name>
|
||||
|
||||
<!--
|
||||
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
||||
comments as you go. The section that matters most for this project is
|
||||
"Layer assignment" — read its comment before writing it.
|
||||
|
||||
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
||||
-->
|
||||
|
||||
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
|
||||
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
|
||||
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
|
||||
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
||||
|
||||
## Motivation
|
||||
|
||||
<!-- Why now. The problem being solved. -->
|
||||
|
||||
## Layer assignment
|
||||
|
||||
<!--
|
||||
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
||||
do NOT reframe it as "how little backend work can we get away with."
|
||||
|
||||
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
||||
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
||||
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
||||
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
||||
navigation, view/order preferences, input handling.
|
||||
|
||||
For each distinct piece of *logic* this feature introduces, put it in the table
|
||||
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
||||
"the backend already accepts this parameter" are NOT reasons to place logic in
|
||||
the frontend — the backend accepting a parameter does not make deciding that
|
||||
parameter's value a presentation concern.
|
||||
|
||||
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
||||
Jellyfin changed its API, added an item type, or altered a business rule? If
|
||||
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
||||
yes (and only yes), it is presentation → frontend.
|
||||
|
||||
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
||||
means as a set of Jellyfin types) in the frontend because the backend already
|
||||
accepted an includeItemTypes filter. That was a boundary leak; see
|
||||
scoped-search-boundary.md. This section exists to catch that class of mistake
|
||||
at spec time, not in review three features later.
|
||||
-->
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
||||
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
||||
|
||||
<!--
|
||||
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
||||
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
||||
-->
|
||||
|
||||
## Design
|
||||
|
||||
<!--
|
||||
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
||||
- Command NAME must match the Rust fn name exactly.
|
||||
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
||||
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
||||
- Events are kebab-case.
|
||||
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
||||
|
||||
Regenerate bindings.ts from Rust types; never hand-edit it.
|
||||
-->
|
||||
|
||||
## Out of scope
|
||||
|
||||
<!-- What this spec deliberately does NOT do. -->
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<!-- Checkable statements. Include the standard gates: -->
|
||||
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
||||
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
||||
|
||||
## TRACES
|
||||
|
||||
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
<!--
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
||||
- Anything else non-obvious.
|
||||
-->
|
||||
@@ -0,0 +1,164 @@
|
||||
# Spec: Account menu and global chrome availability
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend only. No Rust changes required.
|
||||
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
|
||||
[requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §1.2–1.4](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Account actions — Settings, Downloads, Display preferences, Sign out — are
|
||||
currently reachable **only from `/library/*`**. Move them into a single shared
|
||||
account menu anchored to the user's name, and make that menu available on every
|
||||
authenticated non-immersive screen.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user sitting on the home screen cannot open Settings or sign out. The bottom
|
||||
nav offers Home / Search / Library only, and the header that hosts those actions
|
||||
belongs to the library layout. The user has to guess that account actions live
|
||||
*inside* Library — an unrelated section — and navigate there first.
|
||||
|
||||
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
|
||||
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
|
||||
same two actions are found two different ways.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The header is not global.** It is defined in
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
|
||||
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
|
||||
all.
|
||||
|
||||
2. **`routeOwnsLayout`** in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
|
||||
`/library`, `/player/`, `/login` — those routes own their own full-height
|
||||
flex column. Everything else renders into the root scroller with the root's
|
||||
`BottomUi` below it.
|
||||
|
||||
3. **Bottom nav is Home / Search / Library only**
|
||||
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
|
||||
or account entry.
|
||||
|
||||
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
|
||||
Settings or Sign out.
|
||||
|
||||
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
|
||||
trigger.
|
||||
|
||||
6. **The mobile overflow menu already has the right contents** (Downloads,
|
||||
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
|
||||
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
|
||||
|
||||
7. **`viewMode` is already a persisted store** in
|
||||
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
|
||||
`localStorage`). The Display setting is a second view onto it — **no new
|
||||
state, no migration.**
|
||||
|
||||
## Design
|
||||
|
||||
### `AccountMenu` component (DR-075)
|
||||
|
||||
One component used by both breakpoints. Contents in fixed order:
|
||||
|
||||
```
|
||||
Signed in as <name> ← identity block, not interactive
|
||||
<server host>
|
||||
────────────────────────
|
||||
Downloads
|
||||
Settings
|
||||
Display ← grid/list preference
|
||||
────────────────────────
|
||||
Sign out ← destructive, last, after a divider
|
||||
```
|
||||
|
||||
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
|
||||
horizontal space is tight, the avatar (or initial) alone is acceptable; the
|
||||
name shows inside the open menu regardless.
|
||||
- **Same items, same order, both platforms.**
|
||||
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
|
||||
and focus return to the trigger on close.
|
||||
- Menu items are real links/buttons — keyboard reachable, correct roles,
|
||||
`aria-expanded` on the trigger.
|
||||
|
||||
"Display" may either navigate to the Settings Display section or expose the
|
||||
grid/list choice inline. Prefer navigating — it keeps one source of truth for
|
||||
preferences and avoids a nested control inside a dropdown.
|
||||
|
||||
### Global chrome (DR-076)
|
||||
|
||||
Make the header — and therefore the account menu — available on `/`, `/search`,
|
||||
and `/downloads`.
|
||||
|
||||
The cleanest route is to lift the header out of the library layout into a shared
|
||||
component rendered by the root layout, with the library layout consuming the
|
||||
same component rather than defining its own. **Do not duplicate the markup into
|
||||
each route.**
|
||||
|
||||
Constraints that must survive the change:
|
||||
|
||||
- `/player/*` and `/login` stay chrome-free.
|
||||
- `/settings` already owns its layout; it needs no account menu (the user is
|
||||
already there), but must not double up on chrome.
|
||||
- The root layout's flex/scroller structure is deliberate — the comments in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
|
||||
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
|
||||
own column. Preserve the scroll containment; a regression here reintroduces
|
||||
the "last row hidden behind the nav" bug called out in those comments.
|
||||
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
|
||||
`showBottomNav`) must be unchanged.
|
||||
|
||||
### Display section in Settings (DR-077)
|
||||
|
||||
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
|
||||
with the grid/list control bound to the existing `viewMode` store via
|
||||
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
|
||||
controls drive the same store, so they stay in sync for free.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the Settings page or reorganising its existing sections.
|
||||
- Multi-server / account switching (UR-047) — the identity block displays the
|
||||
active server but offers no switcher.
|
||||
- Changing the bottom nav's three destinations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
|
||||
without first navigating into Library.
|
||||
- [ ] Desktop and mobile show the same account menu items in the same order.
|
||||
- [ ] The username/avatar opens the menu; it is a real button with
|
||||
`aria-expanded`.
|
||||
- [ ] Sign out is last, after a divider, and still logs out + resets library
|
||||
state + redirects as it does today.
|
||||
- [ ] `/player/*` and `/login` remain chrome-free.
|
||||
- [ ] Settings has a Display section that changes grid/list, and the change is
|
||||
immediately reflected by the library page-header toggle (same store).
|
||||
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
|
||||
visibility on any route.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
|
||||
`/downloads` (now true) and `/player/*`, `/login` (still false).
|
||||
- `AccountMenu`: renders the documented items in order; trigger toggles
|
||||
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
|
||||
logout handler.
|
||||
- Display setting: writes through to the `viewMode` store and persists.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu` → `UR-054 | DR-075`,
|
||||
shell/header changes → `UR-054 | DR-076`, Settings Display section →
|
||||
`UR-054, UR-029 | DR-077`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §1.2–1.4](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The layout shell is subtle and the existing comments record real bugs that
|
||||
were fixed there. Read them before restructuring.
|
||||
- Another session may be active in this repo, including in
|
||||
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
|
||||
unexpected changes, and expect to coordinate on that file.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Spec: Downloads as a browsable offline library
|
||||
|
||||
**Status:** Draft — ready to implement
|
||||
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
|
||||
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the flat Active/Completed download list with two views under
|
||||
`/downloads`:
|
||||
|
||||
1. **Downloaded** (default) — the library, filtered to what's on the device,
|
||||
using the *same* browse screens as online (grids, cards, detail pages).
|
||||
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
|
||||
showing only in-flight transfers.
|
||||
|
||||
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
|
||||
pages, a device total, and the remove confirmation.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user who downloaded three seasons and two albums sees ~70 individual transfer
|
||||
rows today, with no grouping and no reuse of the library UI. "What do I have
|
||||
offline" and "what is downloading" are different questions crammed into one flat
|
||||
list. Browsing offline should feel exactly like browsing online.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The offline repository is already a browsable tree.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
|
||||
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
|
||||
least one downloaded child. `get_libraries`, `get_item`, and `search` all
|
||||
filter to downloaded content via CTEs. This is the data source for
|
||||
Downloaded; **do not build a new query layer.**
|
||||
|
||||
2. **The client cannot reach it independently.**
|
||||
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` →
|
||||
`repositoryGetItems` always goes through the **hybrid** repository
|
||||
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
|
||||
server. There is no "offline only" browse path exposed. This is the one real
|
||||
backend gap (DR-082).
|
||||
|
||||
3. **Downloads page is a flat two-tab list.**
|
||||
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
|
||||
Completed tabs, one `DownloadItem` row per transfer, no browsing.
|
||||
|
||||
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
|
||||
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
|
||||
given. Downloaded browse is those components with an offline-scoped source.
|
||||
|
||||
5. **A related fallthrough bug is already tracked** (DR-080, another session):
|
||||
`HybridRepository::get_items` treats an empty offline result as a cache miss
|
||||
and falls through to the server. The offline-only browse path (DR-082) must
|
||||
**not** share that behaviour — an empty result there is authoritative "nothing
|
||||
downloaded here."
|
||||
|
||||
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
|
||||
Do not surface them as manual controls; do not loop `startDownload` from the
|
||||
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
|
||||
|
||||
## Design
|
||||
|
||||
### View split (DR-081)
|
||||
|
||||
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
|
||||
default. Transfers shows a count/badge only while transfers are active.
|
||||
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
|
||||
does not start downloads.
|
||||
|
||||
### Offline-scoped browse source (DR-082, DR-083)
|
||||
|
||||
Add an explicit offline-only browse path so Downloaded never merges server
|
||||
results and never depends on reachability. Two viable shapes — pick per the
|
||||
codebase, do not do both:
|
||||
|
||||
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
|
||||
`_libraries`) that calls the offline repository directly, with a matching
|
||||
client method; or
|
||||
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
|
||||
that bypasses the hybrid merge and the empty→fallthrough behaviour.
|
||||
|
||||
Either way: an empty result is authoritative (do **not** reuse the DR-080
|
||||
fallthrough), and the path is available while the server is reachable (a user
|
||||
online still wants to browse their downloads).
|
||||
|
||||
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
|
||||
source. Omit libraries and containers with no downloaded content. Badge
|
||||
partially- vs fully-downloaded containers. Play uses the local file; remove is
|
||||
available at item / album / season / series level and removes a container from
|
||||
the browse when its last downloaded child goes.
|
||||
|
||||
### Transfers view (DR-084)
|
||||
|
||||
The existing list, filtered to in-flight rows only: downloading (with progress),
|
||||
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
|
||||
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
|
||||
this view — they appear in Downloaded. Empty state points at the library.
|
||||
|
||||
### Disk usage (DR-085, UR-056)
|
||||
|
||||
- **Source the bytes from the download manager** — it writes the files and can
|
||||
stat them. Aggregate to album/season/series subtotals and a device total.
|
||||
This is display + aggregation, **not** new tracking.
|
||||
- **Format once, consistently.** One shared formatter, human units, 2–3
|
||||
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
|
||||
it everywhere.
|
||||
- **Surface it in familiar places:** a secondary size label on the card and
|
||||
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
|
||||
that reconciles with the listed sum; a reclaim figure in the remove
|
||||
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
|
||||
- Sort/filter by size is a nice-to-have, not required for v1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
|
||||
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
|
||||
that governs the *online offline-fallback* library; this is the dedicated
|
||||
Downloads surface. They should be consistent but are separate work.
|
||||
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
|
||||
on that behaviour here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
|
||||
- [ ] Downloaded lists only libraries/containers with downloaded content, using
|
||||
the same grids/cards/detail pages as online browsing.
|
||||
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
|
||||
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
|
||||
through to the server.
|
||||
- [ ] Play from Downloaded plays the local file.
|
||||
- [ ] Remove works at item/album/season/series level and updates the browse.
|
||||
- [ ] Transfers shows only in-flight rows with working controls; finished
|
||||
transfers move to Downloaded.
|
||||
- [ ] Each downloaded item/container shows its on-disk size; a device total is
|
||||
shown and reconciles with the sum; remove states the reclaim amount.
|
||||
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
|
||||
`cargo clippy` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Repository client: the offline-only browse path returns downloaded content and
|
||||
its containers, and an empty result does **not** trigger server fallthrough.
|
||||
- Downloaded view: libraries/containers with no downloads are omitted;
|
||||
partial/full container badging.
|
||||
- Transfers: only in-flight statuses render; a completed transfer disappears.
|
||||
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
|
||||
total reconciles with listed items.
|
||||
- If a Rust command is added, add the tauri IPC param-naming coverage per
|
||||
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
|
||||
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
|
||||
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The offline repository already does the hard part. The main work is a clean
|
||||
offline-only client path and reusing the library components — resist
|
||||
rebuilding browse UI.
|
||||
- Another session is active in downloads/offline/connectivity code (DR-074,
|
||||
DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
|
||||
and the repository layer; check `git diff` before repairing unexpected changes.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Spec: Remove Jellyfin-specific models from the frontend
|
||||
|
||||
> **Implementation status (branch `frontend-domain-model`, worktree
|
||||
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
||||
> classification* and *time units* no longer speak Jellyfin:
|
||||
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
||||
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
||||
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
||||
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
||||
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
||||
> `formatDuration` takes ms; progress bars are unit-consistent.
|
||||
> - User-facing type badge → `kindLabel()`.
|
||||
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
||||
>
|
||||
> **Deferred (tracked, not done):**
|
||||
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
||||
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
||||
> round-trip). Catalog `MediaItem` already has `imageId`.
|
||||
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
||||
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
||||
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
||||
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
||||
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
||||
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
||||
|
||||
**Status:** Partially implemented (catalog surface); see banner.
|
||||
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
||||
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
||||
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
||||
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
||||
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
||||
architecture/boundary migration.
|
||||
**Supersedes / revises:** none. Extends the boundary work started in
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
||||
*whole media model*.
|
||||
|
||||
## Summary
|
||||
|
||||
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
||||
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
||||
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
||||
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
||||
comparisons across 23 files** and two frontend utility modules
|
||||
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
||||
field conversion in the presentation layer.
|
||||
|
||||
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
||||
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
||||
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
||||
names — remains in `src/`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two concrete problems, one strategic:
|
||||
|
||||
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
||||
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
||||
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
||||
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
||||
is domain knowledge in the wrong layer, 36 files deep.
|
||||
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
||||
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
||||
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
||||
should have already resolved.
|
||||
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
||||
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
||||
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
||||
shut.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
||||
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
||||
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
||||
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
||||
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
||||
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
||||
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
||||
|
||||
**Borderline calls, resolved:**
|
||||
|
||||
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
||||
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
||||
subset of the new domain model, not a special case.
|
||||
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
||||
playback context, sessions) are **already domain-neutral** — they are *our*
|
||||
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
||||
`item_type`. Only the Jellyfin item-type strings move.
|
||||
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
||||
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
||||
self-contained — deferred to a late phase, not phase 1.
|
||||
|
||||
## Design
|
||||
|
||||
### Single canonical model, one location, isolated mappings
|
||||
|
||||
The domain model is defined **once**, in a dedicated top-level Rust module
|
||||
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
||||
whole app:
|
||||
|
||||
```
|
||||
src-tauri/src/domain/
|
||||
media.rs canonical MediaItem, MediaKind, and the other media types
|
||||
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
||||
mod.rs re-exports
|
||||
| tauri-specta (export_typescript_bindings test)
|
||||
v
|
||||
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
||||
```
|
||||
|
||||
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
||||
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
||||
projection of it. There is no second hand-written copy in either language, so it
|
||||
cannot drift — "shared between frontend and backend" is realized by generation,
|
||||
not duplication.
|
||||
- **Mappings live beside the model, never in consumers.** All provider translation
|
||||
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
||||
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
||||
touches the domain type. Adding a second provider later means a new
|
||||
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
||||
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
||||
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
||||
repository-specific.
|
||||
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
||||
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
||||
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
||||
translation instead of copying `item_type` through.
|
||||
|
||||
### The domain model (Rust)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
Track, Album, Artist, Playlist, // music
|
||||
Movie, Series, Season, Episode, // video
|
||||
Person, // cast/crew
|
||||
Channel, Folder, // containers/live
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: MediaKind, // was: type: String
|
||||
pub is_folder: bool,
|
||||
pub server_id: String,
|
||||
|
||||
// Times in milliseconds — NEVER ticks.
|
||||
pub duration_ms: Option<i64>, // was: run_time_ticks
|
||||
|
||||
// Image as a resolved identifier the frontend turns into a URL via the
|
||||
// existing image command — no raw Jellyfin tag semantics leak.
|
||||
pub image_id: Option<String>, // was: primary_image_tag
|
||||
pub backdrop_image_ids: Option<Vec<String>>,
|
||||
|
||||
pub overview: Option<String>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub production_year: Option<i32>,
|
||||
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
||||
pub community_rating: Option<f64>,
|
||||
pub official_rating: Option<String>,
|
||||
|
||||
// Relationships — already neutral, kept.
|
||||
pub album_id: Option<String>, pub album_name: Option<String>,
|
||||
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
pub series_id: Option<String>, pub series_name: Option<String>,
|
||||
pub season_id: Option<String>, pub season_name: Option<String>,
|
||||
|
||||
// Ordinal position — rename off Jellyfin's index vocabulary.
|
||||
pub track_number: Option<i32>, // was: index_number
|
||||
pub disc_number: Option<i32>, // was: parent_index_number
|
||||
|
||||
pub user_data: Option<UserData>,
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
```
|
||||
|
||||
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
||||
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
||||
private to that module. Its `to_media_item()` — today a near-passthrough that
|
||||
copies `item_type` straight across — is enriched into the single, tested place
|
||||
that:
|
||||
|
||||
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
||||
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
||||
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
||||
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
||||
`Other` variant — **decide at implementation; must not panic.**
|
||||
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
||||
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
||||
|
||||
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
||||
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
||||
|
||||
### 🔴 The `search-event` / dual-payload rule applies again
|
||||
|
||||
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
||||
and any other event payloads — emits the new domain shape. Both sides of a
|
||||
twice-delivered result must match (same rule as
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
||||
event definitions before declaring a phase done.
|
||||
|
||||
### Frontend after
|
||||
|
||||
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
||||
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
||||
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
||||
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
||||
they exist, which are not Jellyfin-specific).
|
||||
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
||||
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
||||
- Assert with the boundary tripwire + a new grep (see acceptance).
|
||||
|
||||
## Phased migration
|
||||
|
||||
This is too large and too collision-prone for one change. Phases are independently
|
||||
shippable, each keeps all tests green, and each is a reviewable PR:
|
||||
|
||||
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
||||
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
||||
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
||||
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
||||
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
||||
wire shape is a superset of today's, so the frontend still compiles and
|
||||
behaves identically. Lands the authority + full mapping unit coverage first,
|
||||
with zero blast radius on the 52 construction sites (they set the old fields;
|
||||
new ones default).
|
||||
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
||||
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
||||
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
||||
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
||||
`bun run check` is the driver.
|
||||
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
||||
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
||||
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
||||
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
||||
outside tests.
|
||||
|
||||
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
||||
- Changing any user-visible behaviour, layout, or copy.
|
||||
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
||||
they carry the fields being renamed — align them in phase 2 only if the compiler
|
||||
demands it.
|
||||
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
||||
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
||||
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
||||
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
||||
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
||||
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
||||
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
||||
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
||||
item type (Rust test with a garbage type string).
|
||||
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
||||
same navigation, offline included.
|
||||
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
||||
critical surface —
|
||||
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
||||
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
||||
- unknown type string → safe fallback, no panic;
|
||||
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
||||
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
||||
|
||||
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
||||
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
||||
deleted with their modules. Add a compose/render test proving `kind`-based
|
||||
branching matches the old `type`-based branching for a representative mix.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
||||
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
||||
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **This is the highest-collision change in the repo's history** — it touches 36+
|
||||
frontend files and the core Rust types. A parallel Claude session in any media
|
||||
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
||||
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
||||
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
||||
the disruptive flip. Resist the urge to skip it.
|
||||
- IPC camelCase rules apply to the new enums/structs
|
||||
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
||||
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
||||
`bindings.ts`, never hand-edit.
|
||||
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
||||
Layer assignment table above is the load-bearing section.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Spec: Offline "downloaded only" filtering (issue #10)
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
|
||||
commands, no schema changes, no UI additions.
|
||||
**Requirements:** UR-052 → DR-078, DR-079, DR-080
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
|
||||
media does not work."*
|
||||
|
||||
## Summary
|
||||
|
||||
Offline, a library page is supposed to show **only media on the device**, with a
|
||||
"Show all server media" toggle that additionally reveals the cached server
|
||||
catalog greyed out (queueable for download on reconnect). In practice the toggle
|
||||
does not gate the listing — every server item still appears. This spec fixes
|
||||
that with two independent changes; either one alone leaves the bug visible.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code. **The feature is built and mostly correct — this is a
|
||||
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
|
||||
command, or the SQL gate.
|
||||
|
||||
1. **The SQL gate works and is unit-tested.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
|
||||
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
|
||||
true; with it false, only downloaded/local rows return. Guarded by
|
||||
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
|
||||
query.**
|
||||
|
||||
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
|
||||
`set_show_server_catalog` command
|
||||
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
|
||||
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
|
||||
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
|
||||
`include = connected || showCatalog` and pushes it on every change.
|
||||
|
||||
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
|
||||
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
|
||||
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
|
||||
them.
|
||||
|
||||
4. **`MediaCard` already greys and queues.**
|
||||
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
|
||||
`isServerOnly` renders the greyed, inert card with a queue button; the queued
|
||||
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
|
||||
it.
|
||||
|
||||
## The two defects
|
||||
|
||||
### Defect A — offline is never actually entered (DR-079)
|
||||
|
||||
`pushCatalogVisibility` keys off `isConnected`, but
|
||||
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
|
||||
|
||||
```ts
|
||||
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
|
||||
```
|
||||
|
||||
`navigator.onLine` is documented in that same file as **advisory only** — the
|
||||
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
|
||||
real traffic*, DR-055). When the server is unreachable but the device link is
|
||||
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
|
||||
stays true, so `include` stays true, so the backend keeps returning the full
|
||||
catalog. The user is "offline" in every meaningful sense but the toggle never
|
||||
gets a chance to gate anything.
|
||||
|
||||
This is the primary cause: it explains why the filter looks dead rather than
|
||||
merely inverted — the gate never closes.
|
||||
|
||||
### Defect B — an intentionally empty result falls through to the server (DR-080)
|
||||
|
||||
With the gate off and nothing downloaded in a library, offline `get_items`
|
||||
correctly returns few or zero rows. But
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
|
||||
hit only `if data.has_content()`. An empty offline result is indistinguishable
|
||||
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
|
||||
~10 other reads) falls through to the server and returns the full server list —
|
||||
re-defeating the filter even after Defect A is fixed.
|
||||
|
||||
## Design
|
||||
|
||||
### Fix A: `isConnected` follows backend reachability alone (DR-079)
|
||||
|
||||
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
|
||||
store:
|
||||
|
||||
```ts
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
```
|
||||
|
||||
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
|
||||
immediate recheck (`online`/`offline` listeners already call
|
||||
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
|
||||
Leave `isOnline` on the state object and the listeners intact.
|
||||
|
||||
Consider whether the optimistic `isServerReachable: true` startup default
|
||||
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
|
||||
first real check resolves. Keep it — flipping the app to "offline" on launch is a
|
||||
worse regression than a brief full-catalog flash before the first probe. Note the
|
||||
choice in a comment.
|
||||
|
||||
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
|
||||
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
|
||||
anything importing it). Enumerate consumers first:
|
||||
|
||||
```
|
||||
grep -rn "isConnected" src/ | grep -v node_modules
|
||||
```
|
||||
|
||||
For each, confirm "server unreachable" (not "device link down") is the correct
|
||||
trigger. It almost always is — that is the whole point of the reachability model
|
||||
— but verify rather than assume, and call out anything that genuinely wanted the
|
||||
device link in the PR description.
|
||||
|
||||
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
|
||||
|
||||
The backend must distinguish "cache is cold, go ask the server" from "user asked
|
||||
for downloads only and there are none here." The gate flag already encodes intent
|
||||
— reuse it.
|
||||
|
||||
Add a getter beside the existing setter in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs):
|
||||
|
||||
```rust
|
||||
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
|
||||
```
|
||||
|
||||
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
|
||||
`!include_catalog_browse()`, treat the offline result as authoritative and return
|
||||
it **as-is even when empty** — do not spawn/await the server fallback for this
|
||||
call. When the flag is on (online fast-path, or offline with the toggle on),
|
||||
behaviour is unchanged: empty cache still falls through to the server.
|
||||
|
||||
Keep it surgical:
|
||||
|
||||
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
|
||||
thread it into `parallel_race` or the other readers, which have no catalog
|
||||
gate and legitimately want the server on an empty cache.
|
||||
- Preserve the online path exactly: with the flag on (its default, and always so
|
||||
while reachable) the method behaves as it does today, including the background
|
||||
cache refresh on a hit.
|
||||
- The flag is process-global `Relaxed`; it is set from the frontend before the
|
||||
query. That ordering already holds for the SQL gate — no new synchronization.
|
||||
|
||||
### Why both
|
||||
|
||||
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
|
||||
downloads present the list still gets padded by the server fallback whenever a
|
||||
library's cache is thin. B alone: the gate never closes because `isConnected`
|
||||
never goes false on a live link. Ship them together.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
|
||||
- `MediaCard` greying / queue-on-reconnect — correct.
|
||||
- Home-screen and resume queries — already downloads-only.
|
||||
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
|
||||
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
|
||||
- Any new IPC command, DB column, or settings entry.
|
||||
- Making the "Show all server media" toggle reachable from Settings (that is a
|
||||
UX-placement question, tracked separately under UR-051's toggle note).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [~] With the server unreachable on a live device link, a library page lists
|
||||
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
|
||||
coverage via UT-069 + gate tests).
|
||||
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
|
||||
hides it again — without leaving/re-entering the page (SQL gate + toggle
|
||||
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
|
||||
- [x] A library with downloads and a thin cache does not get padded with
|
||||
non-downloaded server items when offline with the toggle off (Defect B —
|
||||
UT-070: gate off + empty offline result returned as-is, server not queried).
|
||||
- [x] `isConnected` is false whenever the server is unreachable, regardless of
|
||||
`navigator.onLine`; true for a reachable server even if the browser reports
|
||||
offline (UT-069).
|
||||
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
|
||||
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
|
||||
"server unreachable", which is the new semantics; `CastButton`'s local
|
||||
`isConnected` is unrelated). Full frontend suite (616 tests) green.
|
||||
- [x] Online behaviour is unchanged: with the flag on (its default, always so
|
||||
while reachable) `get_items` keeps the offline fast-path and background
|
||||
refresh (UT-067 + gate-on fall-through test).
|
||||
- [~] A download queued from a greyed offline card resolves and starts on
|
||||
reconnect (IT-017 — regression check, no code change; offlineCatalog
|
||||
resume path untouched).
|
||||
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
|
||||
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
|
||||
touched files).
|
||||
|
||||
## Testing
|
||||
|
||||
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
|
||||
|
||||
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
|
||||
result as-is and does **not** query the server. Assert via a mock online repo
|
||||
whose `get_items` bumps a call counter that must stay at zero.
|
||||
- Gate on + empty cache still falls through to the server (guard the online path).
|
||||
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
|
||||
|
||||
Frontend (vitest, `src/lib/**/*.test.ts`):
|
||||
|
||||
- **UT-069** — `isConnected` follows `isServerReachable` alone: false when
|
||||
unreachable with `navigator.onLine === true`; true when reachable with
|
||||
`navigator.onLine === false`.
|
||||
- **UT-068** — `pushCatalogVisibility` resolves `serverReachable || showCatalog`
|
||||
and pushes to the backend on a change of either input (extend the existing
|
||||
offlineCatalog tests).
|
||||
|
||||
Integration (IT-016, IT-017) are documented as pending in
|
||||
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
|
||||
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
|
||||
|
||||
New/changed requirement code keeps its `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
|
||||
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
|
||||
(`… | DR-078`). Update the getter's tag when you expose it.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
|
||||
before Fix A — it is the canonical statement of the reachability model this fix
|
||||
restores fidelity to.
|
||||
- Fix B relies on the frontend having pushed the flag before the query runs; that
|
||||
ordering already holds for the SQL gate today. No new locking.
|
||||
- Another session is active in this repo (WiFi-only downloads, account menu
|
||||
landed alongside this work). Check `git diff` before "repairing" unexpected
|
||||
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
|
||||
other new rows.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Spec: Move search scope taxonomy behind the Rust boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** Rust + Frontend. **Revises a decision in
|
||||
[scoped-search.md](scoped-search.md).**
|
||||
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
||||
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
||||
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
||||
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
||||
architecture/boundary change with **no user-visible behaviour difference**.
|
||||
|
||||
## Why this spec exists
|
||||
|
||||
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
||||
Rust changes." That was the smallest wiring change, and it worked — but it left
|
||||
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
||||
violates the project's core boundary rule ("Svelte frontend — presentation
|
||||
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
||||
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
||||
|
||||
The offending knowledge lives in
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
||||
|
||||
```ts
|
||||
const SCOPE_ITEM_TYPES = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
const GROUP_ITEM_TYPES = {
|
||||
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
||||
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
||||
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
||||
creates: the day the backend starts returning a type the frontend never
|
||||
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
||||
drops it from both the query filter and the result buckets, and nothing in the
|
||||
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
||||
sources of truth that will drift.
|
||||
|
||||
**This must be fixed while the feature is uncommitted**, before the leak ships
|
||||
baked into a released wire contract.
|
||||
|
||||
### What is *not* a leak (leave it alone)
|
||||
|
||||
Single concrete-type list pages are **not** business logic and stay as-is:
|
||||
|
||||
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
||||
`tv.ts` → `["Series"]`
|
||||
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
||||
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
||||
`PersonDetailView`
|
||||
|
||||
"This page shows albums" is a legitimate presentation choice expressed through a
|
||||
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
||||
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
||||
the line. Do **not** invent a backend enum for every list page — that is
|
||||
over-abstraction, not cleaner separation.
|
||||
|
||||
## The boundary rule after this change
|
||||
|
||||
> The frontend never names a Jellyfin item type **in connection with search.**
|
||||
> It sends an opaque `scope`, and receives results already sorted into labelled
|
||||
> groups. The frontend owns only **group order** (presentation) and
|
||||
> **rendering**.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust owns scope → item-types (query side)
|
||||
|
||||
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
||||
|
||||
```rust
|
||||
// repository/types.rs
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope { All, Music, Movies, Tv }
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or None for `All`
|
||||
/// (which must send NO includeItemTypes — see below).
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter().map(String::from).collect()),
|
||||
SearchScope::Movies => Some(vec!["Movie".into()]),
|
||||
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SearchOptions` gains `scope` and the search command resolves it into the
|
||||
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
||||
online/offline paths (which already honour `include_item_types` — do not touch
|
||||
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
||||
|
||||
```rust
|
||||
pub struct SearchOptions {
|
||||
pub limit: Option<usize>,
|
||||
pub search_term: Option<String>,
|
||||
pub scope: Option<SearchScope>, // NEW
|
||||
// include_item_types stays for the single-type list-page callers,
|
||||
// but the SEARCH command derives it from `scope` when scope is set.
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
||||
non-search `getItems` callers. Document this so a future reader does not send
|
||||
both.
|
||||
|
||||
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
||||
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
||||
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
||||
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
||||
|
||||
### Rust owns result bucketing (result side)
|
||||
|
||||
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
||||
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
||||
```
|
||||
|
||||
Rust emits **every** non-empty group it can classify, in a stable canonical
|
||||
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
||||
those are presentation and stay frontend-side (see below). Items whose type maps
|
||||
to no group are omitted from grouped output (same as today's frontend filter).
|
||||
|
||||
### 🔴 The `search-event` wrinkle — both payloads must change
|
||||
|
||||
Search returns results **twice**: the command resolves with instant local-cache
|
||||
results, then the merged cache+server union arrives later via the `search-event`
|
||||
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
||||
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
||||
command return value **and** the `search-event` payload must carry
|
||||
`GroupedSearchResult`. If only one is converted, the instant results group and
|
||||
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
||||
is the single largest part of the change and the easiest to half-do.
|
||||
|
||||
### What the frontend keeps (all pure presentation)
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
||||
|
||||
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
||||
Rust enum (delete the hand-written union).
|
||||
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
||||
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
||||
unit-tested. **Stays exactly as-is.**
|
||||
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
||||
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
||||
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
||||
presentation.
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
||||
|
||||
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
||||
- `scopeItemTypes()`, `groupItemTypes()`.
|
||||
- The `.type`-inspecting body of `composeSearchGroups()`.
|
||||
|
||||
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
||||
groups** — no `.type` inspection anywhere:
|
||||
|
||||
```ts
|
||||
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
||||
// attach labels, omit empties. No Jellyfin type vocabulary.
|
||||
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
||||
```
|
||||
|
||||
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
||||
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
||||
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
||||
drop out via the empty-omit rule — so the frontend does not strictly need
|
||||
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
||||
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
||||
it as a display hint, not authority.
|
||||
|
||||
### Frontend call-site changes
|
||||
|
||||
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
||||
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
||||
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
||||
event merge) is preserved.
|
||||
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
||||
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
||||
results.
|
||||
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
||||
behaviour; only the type it passes to `SearchResults` changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to online/offline `include_item_types` **filtering** — it already
|
||||
works; only the *source* of the type list moves.
|
||||
- Single concrete-type list pages (see "What is not a leak").
|
||||
- Ranking within or across groups.
|
||||
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
||||
[scoped-search.md](scoped-search.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
||||
in `searchScope.ts` or any search call path. Verify:
|
||||
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
||||
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
||||
`bindings.ts`, not hand-written unions.
|
||||
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
||||
groups, same order, same empty/out-of-scope omission, offline included.
|
||||
- [ ] Both the command return and the `search-event` payload carry the grouped
|
||||
shape; no shape flicker between instant and merged results.
|
||||
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
||||
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
||||
committed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`src-tauri`, `cargo test`):
|
||||
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
||||
- Search command: `scope: Music` resolves to the four music types on the query;
|
||||
`scope: All` sends no `include_item_types`.
|
||||
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
||||
unknown types are dropped; groups come out in canonical order.
|
||||
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
||||
|
||||
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
||||
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
||||
outgoing options — **rewrite** to assert `scope` is sent instead.
|
||||
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
||||
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
||||
compose-over-groups (order + empty-omit, no type inspection).
|
||||
- `searchGroupOrder.test.ts` — unchanged.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
||||
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
||||
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
||||
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
||||
the wire shape.
|
||||
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
||||
§Design "Scope model / Threading scope through the store," which asserted no
|
||||
Rust change. Update that spec's status to note the boundary was moved, or add a
|
||||
banner pointing here — do not leave the two specs contradicting silently.
|
||||
- The IPC camelCase rule applies to the new enums and structs
|
||||
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
||||
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
||||
`tauriIntegration`-style test if a new command is introduced.
|
||||
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
||||
types; do not hand-edit it.
|
||||
- **Another Claude session may be active in these same files** (per project
|
||||
memory). `git diff` before repairing anything unexpected; these search files
|
||||
are exactly the ones a parallel session touched.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Spec: Context-scoped search with filter chips and configurable group order
|
||||
|
||||
> ⚠️ **Superseded in part by
|
||||
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
||||
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
||||
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
||||
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
||||
> 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.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**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)).
|
||||
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
||||
§6.3 group order, §6.4 current deviations.
|
||||
|
||||
## Summary
|
||||
|
||||
Two related changes to search:
|
||||
|
||||
1. **Scope** — a search started inside a library searches *that* library.
|
||||
Started from Home, `/library`, or the search tab, it searches everything.
|
||||
The active scope shows as a chip row under the search bar, preselected from
|
||||
context and freely changeable without retyping.
|
||||
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
||||
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
||||
|
||||
## Motivation
|
||||
|
||||
Searching "office" while browsing TV currently returns music albums, because
|
||||
both search entry points call the same unscoped query. The user has already
|
||||
told us what they're looking at; ignoring that makes search feel indiscriminate
|
||||
and pushes the relevant result below unrelated media.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code — **most of the plumbing is already there.** This is
|
||||
substantially a wiring task, not new infrastructure.
|
||||
|
||||
1. **`SearchOptions` already carries the filter.**
|
||||
[bindings.ts](../../src/lib/api/bindings.ts) —
|
||||
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
||||
|
||||
2. **Rust already honours `include_item_types` on both paths** — online
|
||||
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
||||
options mapping) and offline
|
||||
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
||||
type filter from it). **Do not add Rust code for filtering.**
|
||||
|
||||
3. **Per-page list search already does this correctly.**
|
||||
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
||||
the reference for the call shape, including the `requestId` handling.
|
||||
|
||||
4. **The gap is exactly one function.**
|
||||
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
||||
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
||||
any scope. Both callers
|
||||
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
||||
it.
|
||||
|
||||
5. **Group order is hardcoded in markup.**
|
||||
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
||||
renders three fixed sections in source order.
|
||||
|
||||
6. **Frontend preferences persist via `localStorage`**, per the existing
|
||||
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
||||
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
||||
command for this.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope model
|
||||
|
||||
One `SearchScope` type, defined once and shared:
|
||||
|
||||
| Scope | `includeItemTypes` | Chip label |
|
||||
|-------|--------------------|------------|
|
||||
| `all` | *unset* | All |
|
||||
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
||||
| `movies` | `Movie` | Movies |
|
||||
| `tv` | `Series`, `Episode` | TV |
|
||||
|
||||
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
||||
the two are not equivalent for item types not enumerated here (Person, folders).
|
||||
|
||||
### Route → scope resolution (DR-063)
|
||||
|
||||
A pure function, unit-testable without a DOM:
|
||||
|
||||
```ts
|
||||
resolveSearchScope(pathname: string): SearchScope
|
||||
```
|
||||
|
||||
- `/library/music*` → `music`
|
||||
- `/library/movies*` → `movies`
|
||||
- `/library/tv*` → `tv`
|
||||
- `/`, `/library`, `/search`, anything else → `all`
|
||||
|
||||
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
||||
current route list before finalising — do not assume this table is exhaustive.
|
||||
|
||||
### Scope is a starting point, not a lock (DR-064)
|
||||
|
||||
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
||||
their choice governs until they leave the search surface. Concretely: derive the
|
||||
initial value from the route, hold it in component state, and do not re-derive
|
||||
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
||||
|
||||
Changing a chip re-runs the current query at the new scope. Changing the query
|
||||
keeps the current scope.
|
||||
|
||||
### Threading scope through the store (DR-065)
|
||||
|
||||
Extend the store's search signature to accept an optional scope and pass
|
||||
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
||||
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
||||
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
||||
additive parameter — no caller should break.
|
||||
|
||||
### Group order (DR-066, DR-067)
|
||||
|
||||
Persist an ordered array of group ids:
|
||||
|
||||
```
|
||||
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
||||
```
|
||||
|
||||
Rendering composes scope and order as **two independent axes**, in this order:
|
||||
|
||||
1. drop groups outside the active scope,
|
||||
2. sort the remainder by the user's saved order,
|
||||
3. omit groups that came back empty.
|
||||
|
||||
Scope never rewrites the saved order — narrowing to Music and back to All must
|
||||
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
||||
the worked example.
|
||||
|
||||
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
||||
keyboard-operable move up/down controls with proper labels, or the setting is
|
||||
unusable with a screen reader and on any pointerless input.
|
||||
|
||||
Unknown or missing ids in the stored array must not crash rendering — treat the
|
||||
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
||||
no longer exist. A user upgrading from a build with fewer groups must not lose
|
||||
the new ones.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Ranking *within* a group. Order is presentation-only.
|
||||
- Server-side search ranking or the Jellyfin query itself.
|
||||
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
||||
is already implicitly scoped by its own `itemType`.
|
||||
- Any Rust change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
||||
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
||||
- [ ] The chip row renders under the search bar on both the search page and the
|
||||
in-library header search, with the context-derived chip preselected.
|
||||
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
||||
query preserves the selected chip.
|
||||
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
||||
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
||||
empty groups omitted and relative order preserved.
|
||||
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
||||
restarts, and ships with the documented default.
|
||||
- [ ] Offline search respects scope (the offline path already filters — verify,
|
||||
don't reimplement).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
||||
|
||||
- `resolveSearchScope` — pure unit tests over the route table, including the
|
||||
`/library/shows/genres` case and unknown routes falling back to `all`.
|
||||
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
||||
- The compose step: scope filter + user order + empty-group omission, including
|
||||
the "narrow then widen restores order" case and a stored order containing an
|
||||
unknown id.
|
||||
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
||||
the existing stale-`requestId` guard still discards superseded responses.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
||||
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
||||
and ordered rendering `UR-050 | DR-066, DR-067`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
||||
document is the implementation plan.
|
||||
- The IPC camelCase rule applies to anything new that crosses the boundary
|
||||
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
||||
- Another session may be active in this repo. Check `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -0,0 +1,233 @@
|
||||
# Spec: Background audio for video playback (Android)
|
||||
|
||||
**Status:** Draft
|
||||
**Scope:** Android only (v1). Linux noted as future work.
|
||||
**Branch base:** `android-picture-in-picture`
|
||||
**Requirements:** UR-040 → IR-025, JA-032, DR-051, DR-052 (see
|
||||
[requirements.md](../requirements.md)). Tests: UT-059, UT-060, UT-061, IT-013.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a per-player toggle that lets the **audio** of a video keep playing when the
|
||||
app is backgrounded or the screen is locked, while **video decoding stops**.
|
||||
When the app returns to the foreground, video decoding resumes from the current
|
||||
audio position.
|
||||
|
||||
This is the audio-first counterpart to the existing Picture-in-Picture feature
|
||||
(which keeps the *whole video* decoding in a floating window). The two are
|
||||
mutually exclusive: enabling background audio suppresses auto-PiP.
|
||||
|
||||
## Motivation
|
||||
|
||||
Users watching talk-heavy content (podcasts-as-video, lectures, music videos,
|
||||
concert films) want to lock the phone or switch apps and keep listening without
|
||||
draining battery on video decode or needing a visible floating window.
|
||||
|
||||
## Background: how playback actually works here
|
||||
|
||||
Two facts drive the entire design (verified in code, not assumed):
|
||||
|
||||
1. **Video renders through the HTML5 `<video>` element in the WebView on both
|
||||
platforms.** The native ExoPlayer *video* surface path is disabled — see the
|
||||
INTERIM override in
|
||||
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)
|
||||
around the `playerPlayItem` response handling (`useHtml5Element` is forced
|
||||
`true`, native backend is stopped). So "video decoding" == the WebView
|
||||
`<video>` element, and the WebView is what Android suspends on background.
|
||||
|
||||
2. **An Android WebView `<video>` element does not keep playing audio when the
|
||||
app is backgrounded / locked.** The system throttles the WebView and media
|
||||
pauses. Keeping audio alive in the background requires a **native foreground
|
||||
media service**, which already exists for music:
|
||||
[`JellyTauPlaybackService`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt)
|
||||
+
|
||||
[`JellyTauPlayer`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt)
|
||||
(ExoPlayer) + `MediaSessionCompat`.
|
||||
|
||||
**Therefore the design is a handoff**, not "keep the WebView alive": on
|
||||
background, stop the WebView `<video>` and start audio-only playback of the same
|
||||
item through the existing native ExoPlayer audio service; on foreground, hand
|
||||
back to the WebView `<video>`.
|
||||
|
||||
This also aligns with the project's one-directional playback rule
|
||||
(`CLAUDE.md` → "Playback state is one-directional"): the currently-authoritative
|
||||
player (WebView element **or** native audio service) drives position; the UI and
|
||||
MediaSession consume it. The handoff is a change of *which* player is
|
||||
authoritative, and must transfer position cleanly.
|
||||
|
||||
## User-facing behavior
|
||||
|
||||
### The toggle
|
||||
|
||||
- A toggle button in the video player controls (next to the existing PiP /
|
||||
fullscreen buttons in
|
||||
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)).
|
||||
- Icon: headphones / "audio-only" glyph. Two visual states (on/off).
|
||||
- **Visible only when** `isPipSupported()`-equivalent conditions hold — i.e.
|
||||
Android with a native audio service available. Hidden on Linux in v1.
|
||||
- State is a UI preference on the player. Consider persisting the last choice
|
||||
per user (see Open Questions) — v1 may default OFF each session.
|
||||
|
||||
### When toggle is ON and the app goes to background / screen locks
|
||||
|
||||
1. Auto-PiP is suppressed (see "Interaction with PiP").
|
||||
2. The WebView `<video>` is paused and its decode stopped (release the media
|
||||
source so the decoder is freed, not merely `pause()`).
|
||||
3. Native audio-only playback of the same item starts at the current position,
|
||||
through `JellyTauPlaybackService` (foreground notification + lockscreen
|
||||
controls via the existing `MediaSessionCompat`).
|
||||
4. Lockscreen / notification shows the item with play/pause/seek, driven by the
|
||||
native player (existing music behavior — reused, not rebuilt).
|
||||
|
||||
### When toggle is ON and the app returns to foreground
|
||||
|
||||
1. Native audio playback stops; its final position is captured.
|
||||
2. WebView `<video>` reloads/resumes at that position and continues as normal
|
||||
audiovisual playback.
|
||||
3. Playback state (playing/paused) is preserved across the handoff.
|
||||
|
||||
### When toggle is OFF (default)
|
||||
|
||||
Current behavior is unchanged: backgrounding video auto-enters PiP
|
||||
(`onUserLeaveHint` → `PictureInPictureManager.enterPip`).
|
||||
|
||||
## Interaction with PiP
|
||||
|
||||
The toggle chooses one behavior or the other:
|
||||
|
||||
- Toggle **ON** → call `AndroidPictureInPicture.setAutoEnterEnabled(false)` (the
|
||||
bridge already exists,
|
||||
[pictureInPicture.ts](../../src/lib/utils/pictureInPicture.ts) →
|
||||
`setAutoEnterEnabled`). Background → audio handoff instead of PiP.
|
||||
- Toggle **OFF** → `setAutoEnterEnabled(true)`. Background → PiP (status quo).
|
||||
|
||||
The frontend must also call `setAutoEnterEnabled(false)` on unmount if it left
|
||||
it enabled, and re-assert the correct value whenever the toggle changes, so a
|
||||
stale setting can't leak into the next player.
|
||||
|
||||
> Note: `canEnterPip()` today requires `isPlayingVideo()` on the *native*
|
||||
> ExoPlayer, but video plays via the WebView, so native `isPlayingVideo()` is
|
||||
> false during normal playback. Confirm during implementation how auto-PiP is
|
||||
> actually triggering today (it may rely on a different signal), because the
|
||||
> background-audio handoff needs the same "is a local video active" signal to
|
||||
> know it should fire. **This is a load-bearing unknown — resolve it first
|
||||
> (Phase 0).**
|
||||
|
||||
## Technical design
|
||||
|
||||
### The audio-only stream
|
||||
|
||||
Jellyfin can transcode/stream a video item as audio-only. Add a repository
|
||||
method (mirroring
|
||||
[`get_video_stream_url`](../../src-tauri/src/repository/online.rs) and
|
||||
[`get_audio_stream_url`](../../src-tauri/src/repository/mod.rs)) that returns an
|
||||
**audio-only stream URL for a video item** at a given audio-stream index — so
|
||||
the currently-selected audio track (`selectedAudioTrackIndex` in the player)
|
||||
carries over. Prefer direct-play of the audio stream where the container/codec
|
||||
allows; transcode to a broadly-supported audio codec otherwise.
|
||||
|
||||
Position semantics must match between the WebView `<video>` timeline and the
|
||||
audio stream (account for the transcoded-HLS `seekOffset` model already in the
|
||||
player — see the `seekOffset` handling in `VideoPlayer.svelte`).
|
||||
|
||||
### Backend command surface (Rust)
|
||||
|
||||
New/extended `#[tauri::command]`s in `src-tauri/src/commands/player/` (follow the
|
||||
camelCase param rule and `Result<T, String>` convention):
|
||||
|
||||
- `player_enter_background_audio(item_id, position_seconds, audio_stream_index)`
|
||||
— stop WebView authority, start native audio-only playback at position; makes
|
||||
the native player authoritative. Emits state via the existing player-event
|
||||
channel so MediaSession/UI stay consumers.
|
||||
- `player_exit_background_audio() -> position_seconds` — stop native audio,
|
||||
return final position for the WebView to resume from; restores WebView
|
||||
authority.
|
||||
|
||||
Reuse existing `player_play_*` / `player_stop` plumbing where possible rather
|
||||
than adding a parallel path.
|
||||
|
||||
### Android native
|
||||
|
||||
- Reuse `JellyTauPlaybackService` + `JellyTauPlayer` audio path
|
||||
(`MediaSessionCompat`, foreground notification, audio-becoming-noisy, etc. —
|
||||
all already implemented for music).
|
||||
- Add a bridge method (alongside `AndroidPictureInPicture`) or reuse an existing
|
||||
one so the frontend can signal "prepare for background audio handoff" tied to
|
||||
the Activity lifecycle (`onPause`/`onStop`/`onUserLeaveHint`).
|
||||
- On `onUserLeaveHint` / screen-off with background-audio enabled: **do not**
|
||||
enter PiP; instead trigger the handoff command.
|
||||
- Respect the deadlock gotchas in `CLAUDE.md` (no sync/blocking calls from
|
||||
player event callbacks; bind locked `AutoplayDecision` to a `let` before
|
||||
matching).
|
||||
|
||||
### Frontend (VideoPlayer.svelte)
|
||||
|
||||
- Add toggle state + button. On change, call `setAutoEnterEnabled(!on)`.
|
||||
- Listen for Android lifecycle background/foreground signals (via a bridge event
|
||||
or existing visibility hooks) and:
|
||||
- background + ON → `player_enter_background_audio(...)`, pause + tear down the
|
||||
`<video>`/HLS decode (reuse the existing HLS teardown sequence to avoid dual
|
||||
audio).
|
||||
- foreground + ON → `player_exit_background_audio()`, reload `<video>` at the
|
||||
returned position, restore play/pause state.
|
||||
- **Follow the native-mode pitfall** (memory:
|
||||
`videoplayer-native-mode-pitfalls`): no lifecycle calls after an `await` in
|
||||
`onMount`. Keep the handoff logic out of that window.
|
||||
- Dual-audio is the key regression risk: at every handoff exactly one of
|
||||
{WebView `<video>`, native ExoPlayer} produces audio. Tear the other down
|
||||
*before* starting the next, mirroring the existing HLS cleanup discipline.
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Phase 0 — De-risk (do first):**
|
||||
- Confirm what actually triggers today's auto-PiP given video is on the
|
||||
WebView (resolve the `canEnterPip`/`isPlayingVideo` question).
|
||||
- Spike: obtain an audio-only stream URL for a video item and play it through
|
||||
the native audio service; measure position accuracy and that WebView audio
|
||||
is fully silenced (no dual audio).
|
||||
- **Phase 1 — Backend:** repository audio-only-URL method + the two player
|
||||
commands + events.
|
||||
- **Phase 2 — Native:** lifecycle wiring, PiP suppression, handoff trigger.
|
||||
- **Phase 3 — Frontend:** toggle UI, lifecycle listeners, handoff calls,
|
||||
teardown discipline.
|
||||
- **Phase 4 — Polish:** persist toggle preference, subtitle/audio-track
|
||||
carry-over, edge cases (calls, headphone unplug, autoplay-next during
|
||||
background audio).
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust: unit tests for the audio-only URL builder and the two commands
|
||||
(`cargo test`, `bun run test:rust`).
|
||||
- IPC param-naming integration tests for any new commands
|
||||
(`bun run test -- tauriIntegration.test.ts`).
|
||||
- Frontend: `bun run check`, `bun run test`, plus a VideoPlayer logic test for
|
||||
the handoff state machine (mirror the existing
|
||||
`VideoPlayer.logic.test.ts`).
|
||||
- Manual on-device matrix:
|
||||
- toggle ON: home button → audio continues, video stops decoding; return →
|
||||
video resumes at position; playing/paused preserved.
|
||||
- toggle ON: screen lock → audio continues; lockscreen controls work; unlock →
|
||||
resumes.
|
||||
- toggle OFF: background → PiP (unchanged).
|
||||
- No dual audio at any transition. No audio leak after leaving the player.
|
||||
- Transcoded (HEVC/10-bit) item — verify position with `seekOffset`.
|
||||
- Autoplay-next fires correctly if an episode ends during background audio.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Persist the toggle per user/series, or default OFF each session?**
|
||||
(Recommend: remember last choice; series-level like the audio-track
|
||||
preference is a nice-to-have.)
|
||||
2. **Autoplay-next during background audio** — should the next episode start as
|
||||
audio-only and stay audio until foreground, or pause at episode end? (Recommend:
|
||||
continue as audio-only.)
|
||||
3. **Subtitles** are irrelevant in audio-only mode but must restore on
|
||||
foreground — confirm they survive the `<video>` teardown/reload.
|
||||
4. Exact **Android lifecycle signal** for "screen locked" vs "app backgrounded"
|
||||
— `onUserLeaveHint` covers Home but not lock; may need a screen-off receiver.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Linux background audio (desktop windows keep running unfocused; low value).
|
||||
- Replacing or removing PiP — it stays as the toggle-OFF behavior.
|
||||
- Re-enabling the native ExoPlayer *video* surface path.
|
||||
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
||||
|
||||
## Gitea Actions Workflows
|
||||
|
||||
Two workflows are configured in `.gitea/workflows/`:
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
||||
Gitea-native workflow with:
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
|
||||
**Runs on:** Every push and pull request
|
||||
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||
|
||||
### 2. `traceability.yml` (Alternative)
|
||||
GitHub-compatible workflow with additional features:
|
||||
- Pull request comments with coverage stats
|
||||
- GitHub-specific integrations
|
||||
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
||||
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
||||
`traceability-check.yml` rather than reviving the old file.
|
||||
|
||||
## What Gets Validated
|
||||
|
||||
|
||||
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
|
||||
- Logo (links to `/library`)
|
||||
- Navigation links: Home, Library, Downloads, Settings
|
||||
- Search bar (inline)
|
||||
- User menu: Username, Downloads icon, Logout button
|
||||
- Account menu (see §1.2)
|
||||
|
||||
**Mobile Navigation:**
|
||||
|
||||
On mobile, the header contains:
|
||||
- Logo
|
||||
- Three-dot overflow menu button (Android-style)
|
||||
- Overflow menu includes:
|
||||
- Downloads
|
||||
- Settings
|
||||
- Sign out
|
||||
- Account menu button (see §1.2)
|
||||
|
||||
### 1.2 Account Menu
|
||||
|
||||
Account-level destinations — the ones that are *about the user* rather than
|
||||
about media — live behind a single **account menu**, anchored to the user's
|
||||
name/avatar at the right of the header.
|
||||
|
||||
**Contents, in order:**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Signed in as <name> │ ← identity, not a menu item
|
||||
│ <server host> │
|
||||
├──────────────────────────┤
|
||||
│ ⬇ Downloads │
|
||||
│ ⚙ Settings │
|
||||
│ ▦ Display │ ← grid/list preference (§5A.2)
|
||||
├──────────────────────────┤
|
||||
│ ⇥ Sign out │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **One menu, both platforms.** Desktop and mobile show the same items in the
|
||||
same order. A user who learns where Settings lives on one form factor finds
|
||||
it in the same place on the other.
|
||||
- **Anchored to identity.** The trigger is the username/avatar, because that is
|
||||
where users look for account actions. A bare three-dot icon does not signal
|
||||
"your account".
|
||||
- **Sign out is separated** by a divider and placed last — it is destructive and
|
||||
must not sit adjacent to routine navigation.
|
||||
- **The menu is reachable from every authenticated screen**, not only from
|
||||
library routes. See §1.3.
|
||||
|
||||
**Access Points Summary:**
|
||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
||||
- **Downloads** → header icon (desktop) + account menu (both)
|
||||
- **Settings** → header nav link (desktop) + account menu (both)
|
||||
- **Sign out** → account menu only
|
||||
|
||||
### 1.3 Chrome availability
|
||||
|
||||
The header is shared across chrome-bearing routes. Routes fall into three groups:
|
||||
|
||||
| Route group | Header | Bottom nav | Account menu reachable? |
|
||||
|-------------|--------|------------|-------------------------|
|
||||
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
|
||||
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
|
||||
| `/settings` | Own layout | No | n/a — already there |
|
||||
| `/player/*`, `/login` | No | No | No (by design) |
|
||||
|
||||
The rule the app honours: every authenticated, non-immersive screen exposes the
|
||||
account menu. Only the full-screen player and the login screen are chrome-free.
|
||||
|
||||
### 1.4 Known deviations
|
||||
|
||||
*(None — the account-menu and chrome-availability defects tracked here under
|
||||
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
|
||||
from every authenticated non-immersive screen via the shared `AccountMenu`, the
|
||||
username/avatar is the menu trigger, desktop and mobile share one menu, and the
|
||||
Display preference has a Settings entry — UR-029, §5A.4.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -386,9 +439,9 @@ flowchart TB
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
||||
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
|
||||
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
|
||||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||
|
||||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||
@@ -445,54 +498,352 @@ flowchart TB
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
## 5A. Library Page Layouts
|
||||
|
||||
### 6.1 Search Page Navigation
|
||||
Every browse page is one of two shapes: a **card grid** or a **row list**. This
|
||||
section is the rule for which shape a page takes, what a card looks like, and
|
||||
what the user is allowed to change.
|
||||
|
||||
### 5A.1 Card shape follows the media, not the page
|
||||
|
||||
Card aspect ratio is a property of *what the item is*, and is never overridden
|
||||
per-page. This is the single most important layout rule: a user scanning a grid
|
||||
recognises content type by silhouette before reading a word.
|
||||
|
||||
| Item type | Aspect | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
|
||||
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
|
||||
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
|
||||
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
|
||||
|
||||
Artist cards are square but rendered **circular-masked**, so artists are
|
||||
distinguishable from albums at a glance within the same music grid.
|
||||
|
||||
### 5A.2 Grid vs. list
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
||||
Page[Library browse page] --> Kind{Content kind}
|
||||
|
||||
ClickSearch --> SearchPage[Search Page<br/>/search]
|
||||
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
|
||||
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
|
||||
|
||||
SearchPage --> EmptyState{Has Query?}
|
||||
|
||||
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
||||
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
||||
|
||||
ShowPrompt --> UserTypes[User Types in Search]
|
||||
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
||||
LiveSearch --> ShowResults
|
||||
|
||||
ShowResults --> UserClick{User Clicks Result}
|
||||
|
||||
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
||||
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
||||
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
||||
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
||||
Grid --> Toggle[View toggle in page header]
|
||||
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||||
```
|
||||
|
||||
**Search Page Layout:**
|
||||
- **Grids are the default** for anything with cover art worth scanning.
|
||||
- **Lists are mandatory, not optional**, where position carries meaning —
|
||||
a track's number within an album, an episode's number within a season.
|
||||
A grid destroys that ordering cue, so these pages expose **no toggle**.
|
||||
- **The toggle is global, not per-page.** A user who prefers dense lists
|
||||
prefers them everywhere; making them re-set it on each page is friction.
|
||||
The choice persists across launches.
|
||||
|
||||
**Responsive columns** (grid mode), tuned so cards stay large enough to read
|
||||
cover art on a phone and don't become postage stamps on a desktop:
|
||||
|
||||
| Breakpoint | Columns |
|
||||
|------------|---------|
|
||||
| base (phone) | 2 |
|
||||
| sm | 3 |
|
||||
| md | 4 |
|
||||
| lg | 5 |
|
||||
| xl | 6 |
|
||||
|
||||
### 5A.3 What a card shows
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ │ ← cover art (aspect per §5A.1)
|
||||
│ artwork │ • progress bar overlay if partially played
|
||||
│ │ • watched/played check if complete
|
||||
│ [▶] │ • play affordance on hover/focus
|
||||
└─────────────┘
|
||||
Primary line ← title, truncated to one line
|
||||
Secondary line ← artist / year+rating / SxEy — one line, dimmed
|
||||
```
|
||||
|
||||
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
|
||||
grows to fit its title breaks grid alignment and makes scanning harder.
|
||||
- **Progress and watched state live on the artwork**, not in the text — they
|
||||
must be readable while scanning, without reading.
|
||||
- **Hover/focus reveals play**, so a card is both a navigation target and a
|
||||
playback target without a second control competing for space at rest.
|
||||
|
||||
### 5A.4 Known deviations
|
||||
|
||||
These are places the implementation currently diverges from the rules above.
|
||||
They are recorded here so the gap is explicit rather than mistaken for intent.
|
||||
|
||||
- **The view toggle is discoverable only on a browse page.** The preference is
|
||||
already global and persisted, but the only control that sets it is the pair
|
||||
of icon buttons in a library page header. Settings has no display section, so
|
||||
there is nowhere to look for it. *(UR-029)*
|
||||
|
||||
---
|
||||
|
||||
## 5B. Video Detail Page Composition
|
||||
|
||||
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
|
||||
surface renders is decided by item type plus the `?episode=` query param, and
|
||||
**section order is part of the spec** — it is what makes "keep watching this
|
||||
show" the path of least resistance.
|
||||
|
||||
### 5B.1 Which surface renders
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Nav[Navigate to /library/[id]] --> Type{Item type}
|
||||
|
||||
Type -->|Person| Person[PersonDetailView]
|
||||
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
|
||||
Type -->|Series| Ep{?episode= param<br/>present?}
|
||||
|
||||
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
|
||||
Ep -->|No| Series[Series detail<br/>§5B.4]
|
||||
|
||||
Focus -->|Back to series| Series
|
||||
Series -->|Click episode| Focus
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### 5B.2 Episode Focus View — section order
|
||||
|
||||
**The next episodes appear directly below the current episode, above cast and
|
||||
similar shows.** Nothing may be inserted between the episode hero and the
|
||||
episode strip.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [←] │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ episode backdrop │ │
|
||||
│ │ Series Name │ │ ← 1. HERO
|
||||
│ │ Episode Title │ │
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
|
||||
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
|
||||
│ │ ││NOW ││ ││ │ │
|
||||
│ └──────┘└──────┘└──────┘└──────┘ │
|
||||
│ │
|
||||
│ Cast │ ← 3. CAST
|
||||
│ ( ○ )( ○ )( ○ )( ○ ) │
|
||||
│ │
|
||||
│ More Like This │ ← 4. SIMILAR
|
||||
│ ┌────┐┌────┐┌────┐┌────┐ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules for the episode strip:**
|
||||
|
||||
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
|
||||
between the current episode and every other section; cast and related
|
||||
content are *below* it, never above.
|
||||
- **Window, not full list.** The strip shows a window around the current
|
||||
episode — roughly 3 before and 6 after — so the immediate next episodes are
|
||||
visible without scrolling, and earlier ones remain reachable by scrolling
|
||||
left. It is horizontally scrollable, not a wrapped grid.
|
||||
- **Forward bias.** More episodes are shown *after* the current one than
|
||||
before it: the dominant intent on this screen is "watch the next one."
|
||||
- **The current episode is present and marked.** It renders in-strip with a
|
||||
"NOW" badge and a highlight ring, and is not clickable. It anchors the
|
||||
user's position in the season rather than being hidden.
|
||||
- **Cross-season continuity.** The window spans the whole series in episode
|
||||
order, so the strip runs past a season boundary into the next season's first
|
||||
episodes rather than dead-ending at the end of a season.
|
||||
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
|
||||
progress bar when partially watched, and a watched checkmark when complete.
|
||||
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
|
||||
not start playback. Playback starts only from the hero's Play button.
|
||||
|
||||
### 5B.3 Movie detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download / Favorite)
|
||||
→ Crew links (Directed by / Written by / Music by)
|
||||
→ Genre tags
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
A movie has no continuation set, so cast follows the hero directly.
|
||||
|
||||
### 5B.4 Series detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
|
||||
Search is **context-scoped**: what you are looking at when you start a search
|
||||
determines what the search covers. A search begun inside the Music library
|
||||
searches music. A search begun from Home or the top-level library page searches
|
||||
everything. The scope is always shown, and always overridable.
|
||||
|
||||
### 6.1 Scope is inherited from context
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start[User starts a search] --> Where{Where from?}
|
||||
|
||||
Where -->|Home (/)| All[Scope: All]
|
||||
Where -->|Library root (/library)| All
|
||||
Where -->|Search tab| All
|
||||
Where -->|Inside Music| Music[Scope: Music]
|
||||
Where -->|Inside Movies| Movies[Scope: Movies]
|
||||
Where -->|Inside TV| TV[Scope: TV]
|
||||
|
||||
All --> Chips[Filter chips shown<br/>All chip selected]
|
||||
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
|
||||
Movies --> Chips2
|
||||
TV --> Chips2
|
||||
|
||||
Chips --> Results[Results, grouped by type]
|
||||
Chips2 --> Results
|
||||
|
||||
Results --> Change{User taps a chip}
|
||||
Change --> Rescope[Re-run search at new scope<br/>query preserved]
|
||||
Rescope --> Results
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Context sets the *initial* chip, never a locked filter.** Entering search
|
||||
from TV preselects the TV chip; the user can tap "All" to widen without
|
||||
retyping the query. Scope is a starting point, not a cage.
|
||||
- **Home, `/library`, and the search tab all start at "All".** These are the
|
||||
places a user has expressed no narrower intent.
|
||||
- **Changing scope preserves the query** and re-runs the search. Changing the
|
||||
query preserves the scope.
|
||||
- **Scope maps to item types**, resolved at the point of search:
|
||||
|
||||
| Chip | `includeItemTypes` |
|
||||
|------|--------------------|
|
||||
| All | *(unset — every type)* |
|
||||
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
|
||||
| Movies | `Movie` |
|
||||
| TV | `Series`, `Episode` |
|
||||
|
||||
- **Chips render under the search bar**, on both the dedicated search page and
|
||||
the in-library header search. They are horizontally scrollable if they
|
||||
overflow, never wrapped onto a second row.
|
||||
|
||||
### 6.2 Search page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ │
|
||||
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
|
||||
│ │
|
||||
│ Songs ──────────────────────────── │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ │
|
||||
│ Albums ─────────────────────────── │
|
||||
│ [Album Cover] Album Title │
|
||||
│ [Album Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ [Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ │
|
||||
│ Artists ────────────────────────── │
|
||||
│ [Photo] Artist Name │
|
||||
│ See all (5) │
|
||||
│ ( Photo ) Artist Name │
|
||||
│ See all (5) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Results stay **grouped by type** even when a scope is selected — a Music
|
||||
search still separates Songs / Albums / Artists.
|
||||
- Each group shows a bounded preview with a **See all (n)** affordance rather
|
||||
than an unbounded list, so no single type can bury the others.
|
||||
- Live search is **debounced** as the user types; a query that becomes empty
|
||||
clears results rather than searching for the empty string.
|
||||
|
||||
### 6.3 Result group order is user-configurable
|
||||
|
||||
Which *kind* of thing a user is usually searching for is personal: a
|
||||
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
|
||||
than guessing, the group order is a setting.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Settings[Settings → Search] --> List[Draggable list of result groups]
|
||||
|
||||
List --> Drag[User drags a group up or down]
|
||||
Drag --> Persist[Order persisted]
|
||||
|
||||
Persist --> Render[Rendering a result set]
|
||||
Scope[Active scope chip §6.1] --> Render
|
||||
|
||||
Render --> Filter[1 - Drop groups outside the active scope]
|
||||
Filter --> Sort[2 - Sort remaining groups by user order]
|
||||
Sort --> Prune[3 - Omit groups with no results]
|
||||
Prune --> Show[Render]
|
||||
```
|
||||
|
||||
**Scope and order compose — they are two independent axes.** The scope chip
|
||||
decides *which* groups are eligible; the settings list decides *what sequence*
|
||||
the eligible ones appear in. Order is preserved as a relative ranking, never
|
||||
renumbered per scope:
|
||||
|
||||
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
|
||||
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
|
||||
groups keep their relative order.
|
||||
- Scope **All** with the same setting renders all five in exactly that order.
|
||||
- **Changing scope never rewrites the saved order.** A user who narrows to
|
||||
Music and back to All sees their original arrangement intact.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Drag and drop to reorder**, in a settings list showing every result group
|
||||
(Songs, Albums, Artists, Movies, TV Shows).
|
||||
- **The order applies to grouped results everywhere** — the search page and
|
||||
the in-library header search alike.
|
||||
- **Order is presentation-only.** It never changes which results are returned
|
||||
or how they are ranked *within* a group, only the sequence groups appear in.
|
||||
- **Empty groups are skipped, not gapped.** A group with no results is omitted
|
||||
entirely; it does not reserve space or leave a stray heading.
|
||||
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
|
||||
so the setting is an adjustment, never a prerequisite.
|
||||
- **Keyboard/accessible reordering must exist** alongside dragging — a
|
||||
drag-only control is unusable with a screen reader or without a pointer.
|
||||
|
||||
### 6.4 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Scope is not implemented.** The in-library header search calls the same
|
||||
unscoped query as the global search page, so searching inside TV returns
|
||||
music. The backend already accepts `includeItemTypes` on both the online and
|
||||
offline paths, and the per-page list search already uses it — only the global
|
||||
path ignores it. *(UR-049)*
|
||||
- **Filter chips do not exist** on either search surface. *(UR-049)*
|
||||
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
|
||||
with no setting. *(UR-050)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Download Flows
|
||||
@@ -532,68 +883,166 @@ States:
|
||||
5. [⏸] Paused - Yellow pause icon
|
||||
```
|
||||
|
||||
### 7.2 Managing Downloads Page
|
||||
### 7.2 Downloads = a browsable offline library, not a flat list
|
||||
|
||||
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
|
||||
*the library, filtered to what's on the device*. A user who has downloaded three
|
||||
seasons of a show and two albums thinks in terms of shows and albums, not
|
||||
seventy-odd individual episode/track transfers. So the primary Downloads surface
|
||||
**reuses the library browse screens**, scoped to downloaded content, and keeps
|
||||
the transfer-progress list as a secondary "Transfers" view for the *act* of
|
||||
downloading.
|
||||
|
||||
This splits one overloaded page into two clear jobs:
|
||||
|
||||
| Surface | Answers | Reuses |
|
||||
|---------|---------|--------|
|
||||
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
|
||||
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User[User] --> NavChoice{Navigation Path}
|
||||
Nav[Open Downloads] --> Downloads[/downloads]
|
||||
|
||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
||||
Downloads --> View{View}
|
||||
View -->|Downloaded (default)| Browse[Offline library browse]
|
||||
View -->|Transfers| Activity[Transfer activity list]
|
||||
|
||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
||||
HeaderIcon --> DownloadsPage
|
||||
TypeURL --> DownloadsPage
|
||||
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||||
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||||
Grid --> Detail[Detail page<br/>same as online]
|
||||
Detail --> Play[Play from local file]
|
||||
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
|
||||
|
||||
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
||||
|
||||
ShowTabs --> ActiveTab{Active Tab}
|
||||
|
||||
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
||||
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
||||
|
||||
ShowActive --> UserAction1{User Action}
|
||||
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
||||
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
||||
|
||||
ShowCompleted --> UserAction2{User Action}
|
||||
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
||||
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
||||
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||||
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||||
```
|
||||
|
||||
**Navigation to Downloads:**
|
||||
- **Desktop:** Click "Downloads" link in header navigation
|
||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
||||
- **Direct:** Navigate to `/downloads` route
|
||||
**Why reuse the library screens (not a bespoke list):**
|
||||
|
||||
- **One mental model.** Browsing offline should feel identical to browsing
|
||||
online — same grids, same card shapes, same detail pages, same play action.
|
||||
The only difference is *what's present*, not *how it looks*.
|
||||
- **It already works in the backend.** The offline repository's `get_items`
|
||||
already returns downloaded items **plus** their containers (an album with any
|
||||
downloaded track, a series/season with any downloaded episode). That is a
|
||||
browsable tree today — see §7.4.
|
||||
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
|
||||
browsable library does not.
|
||||
|
||||
### 7.3 The Downloaded browse surface
|
||||
|
||||
**Downloads Page Layout:**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [←] Downloads │
|
||||
│ │
|
||||
│ [Active (3)] [Completed (12)] │
|
||||
│ │
|
||||
│ ─ Downloading ──────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [████████░░] 80% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [██░░░░░░░░] 20% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ ─ Queued ───────────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ Waiting... │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Downloads │
|
||||
│ ( Downloaded ) ( Transfers ) ← view switch
|
||||
│ │
|
||||
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||||
│ │
|
||||
│ Music │ ← only libraries that
|
||||
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||||
│ │alb ││alb ││art │ │
|
||||
│ └────┘└────┘└────┘ │
|
||||
│ │
|
||||
│ TV │
|
||||
│ ┌────┐┌────┐ │
|
||||
│ │show││show│ │
|
||||
│ └────┘└────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
|
||||
music is downloaded, only Music appears.
|
||||
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
|
||||
browse is the same components with an offline-scoped data source, never a
|
||||
parallel re-implementation.
|
||||
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
|
||||
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
|
||||
- **Disk usage is shown where the user already looks**, in familiar units — see
|
||||
§7.3.1.
|
||||
- **Play always plays the local file** here; nothing on this surface streams.
|
||||
- **Remove is available at every level** — item, album/season, series — and
|
||||
states clearly what it frees. Removing the last downloaded child of a
|
||||
container removes the container from the browse.
|
||||
- **This surface works identically online and offline.** It is "what's on the
|
||||
device," a question whose answer does not depend on connectivity. It must not
|
||||
wait for, or be emptied by, server reachability.
|
||||
|
||||
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
|
||||
|
||||
Users want to know what each thing costs on disk, but that information has to
|
||||
feel like the storage views they already know (phone Settings → Storage, a
|
||||
file browser), not a developer's byte dump.
|
||||
|
||||
- **Size rides along with the item, on the card and the detail page** — a small
|
||||
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
|
||||
report" screen the user has to go find.
|
||||
- **Containers show their total.** A series shows the sum of its downloaded
|
||||
episodes; an album the sum of its tracks; a season its own subtotal. The
|
||||
number a user sees on the "Breaking Bad" card is what removing it frees.
|
||||
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
|
||||
and use it everywhere. Show 2–3 significant figures (`1.2 GB`, not
|
||||
`1,283,048,192 bytes` and not `1.28394 GB`).
|
||||
- **A single device total sits at the top** of the Downloaded surface
|
||||
(`3.4 GB on device · 12 items`) so the headline number is answered before the
|
||||
user scans. It reconciles with the sum of what's listed.
|
||||
- **Remove restates the reclaim** in the same units at the point of action
|
||||
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
|
||||
legible exactly when the user decides.
|
||||
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
|
||||
what to clear) but is not required for v1.
|
||||
|
||||
The bytes-on-disk per item are a backend fact (the download manager writes the
|
||||
files and can stat them); this is a display and aggregation task, not new
|
||||
tracking. See §7.7 deviations for what's missing today.
|
||||
|
||||
### 7.4 Transfers (activity) view
|
||||
|
||||
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
|
||||
It is about *transfers in flight*, so it shows only rows that are doing or
|
||||
waiting to do something:
|
||||
|
||||
- **States:** downloading (with progress), queued, paused, failed,
|
||||
waiting-for-WiFi (§7.5).
|
||||
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
|
||||
and auto-pump are backend concerns and are not surfaced as manual controls.
|
||||
- **Completed transfers fall off this view** once done — the finished item lives
|
||||
in Downloaded, not here. A transient "just finished" confirmation is fine; a
|
||||
permanent completed-list is not (that's what Downloaded is for).
|
||||
- **Empty state** points at the library: "Nothing downloading. Browse your
|
||||
library and tap download to save media for offline."
|
||||
|
||||
### 7.5 Navigation & entry points
|
||||
|
||||
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
|
||||
link/icon → `/downloads`.
|
||||
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
|
||||
and should draw attention (badge/count) only while transfers are active.
|
||||
- Initiating a download is unchanged (§7.1): the download button lives on
|
||||
item/album/series detail pages. The Downloads page manages and browses; it is
|
||||
not where you start a download.
|
||||
|
||||
### 7.7 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
|
||||
row per individual transfer with no browsing, grouping, or reuse of the
|
||||
library screens. Completed downloads never collapse into their album/series.
|
||||
*(UR-055)*
|
||||
- **No offline-scoped browse entry point exists in the client.** All browsing
|
||||
goes through the hybrid repository, which merges cache **and** server; there is
|
||||
no way to ask for "downloaded content only" as a browse surface. The offline
|
||||
repository supports it (§7.2) but is not reachable independently. *(UR-055,
|
||||
DR-082)*
|
||||
- **The "on device" storage summary and per-container remove** are absent from
|
||||
the completed list. *(UR-055, UR-056)*
|
||||
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
|
||||
no size; there is no device total, no container subtotal, and Remove does not
|
||||
state what it frees. *(UR-056)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Settings & Account Flows
|
||||
@@ -627,6 +1076,13 @@ flowchart TB
|
||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||
- **Direct:** Navigate to `/settings` route
|
||||
|
||||
**Settings apply instantly.** Every control on the Settings page persists the
|
||||
moment the user changes it — toggling a switch, picking a level, or releasing a
|
||||
slider writes that setting immediately. There is **no "Save" button** and no
|
||||
save/dirty state to reason about; leaving the page never risks losing a change.
|
||||
Sliders update their live readout while dragging but only persist on release
|
||||
(`change`, not each `input` tick) to avoid flooding the backend.
|
||||
|
||||
### 8.2 Logout Flow
|
||||
|
||||
```mermaid
|
||||
@@ -690,24 +1146,59 @@ flowchart TB
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.2 Video Playback in Background
|
||||
### 9.2 Video Playback in Background (Android — PiP & Background Audio)
|
||||
|
||||
Leaving the app while a **local video** is playing does not simply pause it.
|
||||
What happens depends on which background behaviour is active. The two are
|
||||
**mutually exclusive**, and both apply **only to locally-rendering video** —
|
||||
audio-only playback, library/menu browsing, and remote/cast sessions never
|
||||
trigger PiP (see decision gate below).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
VideoPlaying[Video Playing] --> Background{User Action}
|
||||
Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
|
||||
|
||||
Background -->|Home Button| AutoPause[Automatically Pause]
|
||||
Background -->|Screen Lock| AutoPause
|
||||
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification (§9.1)]
|
||||
|
||||
AutoPause --> SaveProgress[Save Progress]
|
||||
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
||||
Gate -->|Yes| Mode{Background mode armed?}
|
||||
|
||||
ShowNotification --> UserReturn{User Returns?}
|
||||
Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView <video> torn down,<br/>video decode stops, audio continues]
|
||||
Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
|
||||
|
||||
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
|
||||
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
||||
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> (reflects live player state)]
|
||||
|
||||
ResumeVideo --> AskResume[Resume from Saved Position]
|
||||
PiPWindow --> PiPReturn{User action}
|
||||
PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
|
||||
PiPReturn -->|Close window| Stop[Playback stops]
|
||||
|
||||
Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
|
||||
- **Video-only gate.** Auto-PiP is guarded by the native `canEnterPip` check
|
||||
(local video surface actively rendering). Audio playback and menu/library
|
||||
browsing background normally; remote/cast sessions render nothing locally, so
|
||||
a PiP window would be an empty box and is refused. *(UR-041, IR-026)*
|
||||
- **Only one background behaviour at a time.** The background-audio toggle
|
||||
(UR-040) disarms auto-PiP while it is on, so a video is either handed to the
|
||||
audio service *or* floated in PiP, never both.
|
||||
- **PiP controls track the player.** The play/pause RemoteAction in the PiP
|
||||
window reflects the live player state and updates on every playback-state
|
||||
change, not only when the button is pressed. *(DR-053)*
|
||||
- **Non-disruptive transition.** ExoPlayer keeps rendering into the same
|
||||
surface across enter/exit, so entering or leaving PiP never interrupts the
|
||||
video; on exit the surface is re-fit to full-screen bounds. *(DR-053)*
|
||||
|
||||
**PiP window (Android):**
|
||||
```
|
||||
┌───────────────────┐
|
||||
│ │
|
||||
│ ▶ video frame │
|
||||
│ │
|
||||
│ [⏸] │ ← play/pause RemoteAction
|
||||
└───────────────────┘
|
||||
sized to the video's aspect ratio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -17,6 +17,7 @@
|
||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||
"test:all": "./scripts/test-all.sh",
|
||||
"test:rust": "./scripts/test-rust.sh",
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
@@ -28,7 +29,8 @@
|
||||
"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:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
|
||||
set -e
|
||||
|
||||
BUILD_TYPE="${1:-debug}"
|
||||
|
||||
echo "🚀 Build and Deploy Android APK"
|
||||
echo ""
|
||||
|
||||
# Build APK
|
||||
./scripts/build-android.sh "$BUILD_TYPE"
|
||||
# Pass all args (build type and/or --clean) through to the build script.
|
||||
./scripts/build-android.sh "$@"
|
||||
|
||||
echo ""
|
||||
|
||||
# Deploy APK
|
||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
||||
BUILD_TYPE="debug"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||
|
||||
@@ -15,13 +15,24 @@ echo "Android SDK: $ANDROID_HOME"
|
||||
echo "NDK: $NDK_HOME"
|
||||
echo ""
|
||||
|
||||
# Build type: debug or release (default: debug)
|
||||
BUILD_TYPE="${1:-debug}"
|
||||
# Parse args: build type (debug/release) and optional --clean flag.
|
||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||
BUILD_TYPE="debug"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--clean) CLEAN=1 ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Step 0: Clear build caches to ensure fresh builds
|
||||
echo "🧹 Clearing build caches..."
|
||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||
npm install > /dev/null 2>&1
|
||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "🧹 Clearing build caches (clean build)..."
|
||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||
npm install > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Step 1: Sync Android source files
|
||||
echo "🔄 Syncing Android sources..."
|
||||
@@ -33,6 +44,9 @@ bun run build
|
||||
|
||||
# Step 2: Build Android APK
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
# Configure release signing from .env (single source of truth). Must run
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
echo "📦 Building release APK..."
|
||||
bun run tauri android build --apk true
|
||||
else
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# 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
|
||||
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
|
||||
# that motivated this check.
|
||||
#
|
||||
# ⚠️ 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, 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=(
|
||||
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
|
||||
# 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"
|
||||
)
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
[[ "$file" == "$allowed" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
file="${line%%:*}"
|
||||
case "$file" in
|
||||
*.test.*) continue ;;
|
||||
esac
|
||||
if is_allowed "$file"; then
|
||||
echo " ⏭️ allowlisted: $line"
|
||||
continue
|
||||
fi
|
||||
violations+="$line"$'\n'
|
||||
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 " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
||||
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No multi-type taxonomy queries in the frontend."
|
||||
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
||||
echo " the real gate for subtler leaks.)"
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* release-notes.ts — turn a commit range into capability-level release notes
|
||||
* using the TRACES graph instead of raw commit subjects.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/release-notes.ts [<range>]
|
||||
* bun run scripts/release-notes.ts v0.0.15..HEAD
|
||||
*
|
||||
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
|
||||
*
|
||||
* How it works:
|
||||
* 1. `git diff --name-only <range>` → files the range changed.
|
||||
* 2. Read each changed file's `TRACES:` comments → requirement IDs.
|
||||
* 3. Resolve IDs to descriptions from docs/requirements.md.
|
||||
* 4. Group: UR → Features, DR/IR → Improvements. Deduped, so many commits
|
||||
* touching one requirement collapse to one line.
|
||||
*
|
||||
* This is a drafting aid for docs/release-checklist.md — review the output,
|
||||
* it does not invent descriptions for untraced changes (those are listed
|
||||
* separately so nothing is silently dropped).
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
|
||||
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
|
||||
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
|
||||
|
||||
function sh(cmd: string): string {
|
||||
return execSync(cmd, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function defaultRange(): string {
|
||||
try {
|
||||
const tag = sh("git describe --tags --abbrev=0");
|
||||
return `${tag}..HEAD`;
|
||||
} catch {
|
||||
return ""; // no tags: fall through to whole-history diff
|
||||
}
|
||||
}
|
||||
|
||||
/** Map requirement ID → human description, parsed from docs/requirements.md. */
|
||||
function loadRequirementDescriptions(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const text = readFileSync("docs/requirements.md", "utf8");
|
||||
for (const line of text.split("\n")) {
|
||||
const m = line.match(REQ_ROW_RE);
|
||||
// First definition wins: the descriptive tables come before the later
|
||||
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
|
||||
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function changedFiles(range: string): string[] {
|
||||
const cmd = range
|
||||
? `git diff --name-only ${range}`
|
||||
: "git ls-files"; // untagged repo: describe everything currently traced
|
||||
return sh(cmd)
|
||||
.split("\n")
|
||||
.filter((f) => f && existsSync(f));
|
||||
}
|
||||
|
||||
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
||||
function idsFromFiles(files: string[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const file of files) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const trace of content.matchAll(TRACE_RE)) {
|
||||
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const range = process.argv[2] ?? defaultRange();
|
||||
const descriptions = loadRequirementDescriptions();
|
||||
const files = changedFiles(range);
|
||||
const ids = idsFromFiles(files);
|
||||
|
||||
const features: string[] = []; // UR
|
||||
const improvements: string[] = []; // DR / IR
|
||||
const unknown: string[] = []; // traced but not in requirements.md
|
||||
|
||||
for (const id of [...ids].sort()) {
|
||||
const desc = descriptions.get(id);
|
||||
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
|
||||
if (!desc) {
|
||||
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
|
||||
continue;
|
||||
}
|
||||
const line = `- ${desc} (${id})`;
|
||||
if (id.startsWith("UR")) features.push(line);
|
||||
else improvements.push(line);
|
||||
}
|
||||
|
||||
const header = range || "(entire history — no tags found)";
|
||||
const out: string[] = [`## Release notes — ${header}`, ""];
|
||||
|
||||
if (features.length) out.push("### ✨ Features", ...features, "");
|
||||
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
|
||||
if (unknown.length)
|
||||
out.push(
|
||||
"### ⚠️ Traced IDs missing from requirements.md",
|
||||
...unknown.map((id) => `- ${id}`),
|
||||
"",
|
||||
);
|
||||
|
||||
const untraced = files.filter((f) => {
|
||||
try {
|
||||
return !/TRACES:/.test(readFileSync(f, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (untraced.length)
|
||||
out.push(
|
||||
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
|
||||
...untraced.map((f) => `- ${f}`),
|
||||
"",
|
||||
);
|
||||
|
||||
if (!features.length && !improvements.length)
|
||||
out.push("_No traced requirements in this range._", "");
|
||||
|
||||
console.log(out.join("\n"));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -41,4 +41,71 @@ if [ -f "$APP_GRADLE_SRC" ]; then
|
||||
echo " Copied: app/build.gradle.kts"
|
||||
fi
|
||||
|
||||
# AndroidManifest.xml. `tauri android init` regenerates gen/android from
|
||||
# tauri.conf.json and would drop our hand-maintained entries (media playback
|
||||
# service + permissions, hardware acceleration, picture-in-picture attributes
|
||||
# on MainActivity), so this tracked copy is the source of truth and must be
|
||||
# restored after a regen. Gradle reads ONLY the gen/ copy - there is no
|
||||
# manifest-merger hook here, so this must be the complete manifest.
|
||||
MANIFEST_SRC="$PROJECT_ROOT/src-tauri/android/src/main/AndroidManifest.xml"
|
||||
MANIFEST_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/AndroidManifest.xml"
|
||||
if [ -f "$MANIFEST_SRC" ]; then
|
||||
cp "$MANIFEST_SRC" "$MANIFEST_DST"
|
||||
echo " Copied: app/src/main/AndroidManifest.xml"
|
||||
fi
|
||||
|
||||
# Custom ProGuard/R8 keep rules. Required for minified release builds:
|
||||
# the player/ and security/ Kotlin classes are loaded by name via JNI from
|
||||
# Rust, so R8 can't see the references and would strip them without this.
|
||||
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
|
||||
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
|
||||
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
|
||||
if [ -f "$PROGUARD_SRC" ]; then
|
||||
cp "$PROGUARD_SRC" "$PROGUARD_DST"
|
||||
echo " Copied: app/proguard-jellytau.pro"
|
||||
fi
|
||||
|
||||
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
|
||||
# low-quality launcher icons from tauri.conf.json (which has no high-res
|
||||
# Android source), so overwrite them with the real committed mipmaps.
|
||||
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
|
||||
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
|
||||
if [ -d "$RES_SRC" ]; then
|
||||
for dir in "$RES_SRC"/mipmap-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
name="$(basename "$dir")"
|
||||
mkdir -p "$RES_DST/$name"
|
||||
cp "$dir"/* "$RES_DST/$name/"
|
||||
echo " Copied res: $name"
|
||||
done
|
||||
|
||||
# values/ (themes.xml): status-bar styling that `tauri android init` does
|
||||
# not generate. Previously this directory was tracked but never copied, so
|
||||
# the theme customizations below never reached a build.
|
||||
if [ -d "$RES_SRC/values" ]; then
|
||||
mkdir -p "$RES_DST/values"
|
||||
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
||||
echo " Copied res: values"
|
||||
fi
|
||||
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||
# ic_launcher_monochrome.png would just be dead weight.
|
||||
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
|
||||
|
||||
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
|
||||
# as API-qualified VECTOR drawables:
|
||||
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
|
||||
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
|
||||
# Because drawable-v24 is a more specific match than our unqualified
|
||||
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
|
||||
# the green square robot instead of our jellyfish. Remove them so the
|
||||
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
|
||||
# to the real committed PNGs.
|
||||
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
|
||||
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
|
||||
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
|
||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||
fi
|
||||
|
||||
echo "✓ Android sources synced successfully"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
|
||||
#
|
||||
# .env is the single source of truth for local release signing. `tauri android
|
||||
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
|
||||
# from .env before every release build (this is the local mirror of what the CI
|
||||
# workflow does from Gitea secrets).
|
||||
#
|
||||
# Required .env vars:
|
||||
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
|
||||
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "❌ $ENV_FILE not found — cannot configure release signing." >&2
|
||||
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
|
||||
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load .env without leaking it into the caller's environment beyond what we need.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
|
||||
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
|
||||
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
|
||||
|
||||
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$PROPS")"
|
||||
umask 077
|
||||
cat > "$PROPS" <<EOF
|
||||
storeFile=$ANDROID_KEYSTORE_FILE
|
||||
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||
keyAlias=$ANDROID_KEY_ALIAS
|
||||
keyPassword=$ANDROID_KEY_PASSWORD
|
||||
EOF
|
||||
|
||||
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
|
||||
@@ -2028,6 +2028,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rusqlite",
|
||||
"tokio-util",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -4959,6 +4960,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -5281,7 +5288,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -33,6 +33,7 @@ rand = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
urlencoding = "2"
|
||||
futures-util = "0.3"
|
||||
async-trait = "0.1"
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# JellyTau custom keep rules.
|
||||
#
|
||||
# These classes are loaded by name from the Rust backend via JNI
|
||||
# (env.find_class / class-loader lookups), so R8 cannot see the
|
||||
# references and would otherwise strip or rename them in a minified
|
||||
# release build — causing an instant ClassNotFoundException crash on
|
||||
# startup. See src-tauri/src/player/android/mod.rs and
|
||||
# src-tauri/src/credentials.rs.
|
||||
-keep class com.dtourolle.jellytau.player.** { *; }
|
||||
-keep class com.dtourolle.jellytau.security.** { *; }
|
||||
|
||||
# Picture-in-picture is driven from the WebView through an
|
||||
# @JavascriptInterface bridge, so the only references to these methods live
|
||||
# in JavaScript. R8 sees them as unused and would strip them, silently
|
||||
# breaking the PiP button in release builds only.
|
||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
@@ -1,39 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.dtourolle.jellytau.player"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
||||
implementation("androidx.media3:media3-common:1.5.1")
|
||||
implementation("androidx.media3:media3-session:1.5.1")
|
||||
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
}
|
||||
@@ -1,5 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Authoritative AndroidManifest for JellyTau.
|
||||
|
||||
NOTE: this is NOT a manifest-merger fragment. Gradle only ever reads
|
||||
gen/android/app/src/main/AndroidManifest.xml, and `tauri android init`
|
||||
regenerates that file from tauri.conf.json - dropping everything below.
|
||||
scripts/sync-android-sources.sh copies this file over the generated one,
|
||||
so this is the full manifest and the single source of truth.
|
||||
|
||||
(An earlier version of this file was a partial <application> fragment on the
|
||||
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
||||
declared never reached any built APK. It is folded in properly below.)
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Enable hardware acceleration for video playback performance -->
|
||||
<application android:hardwareAccelerated="true" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Media playback service for lockscreen controls -->
|
||||
<service
|
||||
android:name="com.dtourolle.jellytau.player.JellyTauPlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -22,6 +22,34 @@ class MainActivity : TauriActivity() {
|
||||
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.
|
||||
*
|
||||
* This is NOT what excludes audio/browsing/cast from PiP — that is the
|
||||
* PictureInPictureManager.canEnterPip guard, which requires a local video
|
||||
* surface to be actively rendering and is re-checked in onUserLeaveHint. This
|
||||
* flag is only toggled by the background-audio feature (via
|
||||
* AndroidPictureInPicture.setAutoEnterEnabled) so background-audio mode and
|
||||
* auto-PiP stay mutually exclusive.
|
||||
*/
|
||||
@Volatile
|
||||
private var autoEnterPipEnabled = true
|
||||
|
||||
/**
|
||||
* Whether the user armed background-audio mode on the current video (UR-040).
|
||||
* When true, leaving the app hands audio off to the native ExoPlayer audio
|
||||
* service (frontend-driven) instead of entering PiP, and video decode stops.
|
||||
* The frontend sets this via AndroidBackgroundAudio.setEnabled.
|
||||
*/
|
||||
@Volatile
|
||||
private var backgroundAudioEnabled = false
|
||||
|
||||
/**
|
||||
* The WebView carrying the Svelte UI, cached once found so lifecycle overrides
|
||||
* can dispatch DOM events into it (native → frontend signalling).
|
||||
*/
|
||||
private var mediaWebView: WebView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -37,6 +65,80 @@ class MainActivity : TauriActivity() {
|
||||
configureWebViewForMedia()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user leaves the app via Home or the gesture equivalent
|
||||
* (but NOT via Back). This is the standard hook for auto-entering PiP so
|
||||
* video keeps playing in a floating window instead of being backgrounded.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*/
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// Never enter PiP while background-audio mode is armed — the two are mutually
|
||||
// exclusive (the handoff runs from onStop instead).
|
||||
if (autoEnterPipEnabled && !backgroundAudioEnabled &&
|
||||
PictureInPictureManager.canEnterPip(this)) {
|
||||
android.util.Log.d("MainActivity", "User leaving with video active - entering PiP")
|
||||
PictureInPictureManager.enterPip(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app is no longer visible (Home, app switch, or screen lock). When
|
||||
* background-audio mode is armed, tell the frontend to hand video playback off
|
||||
* to the native audio service. onStop (rather than onUserLeaveHint) is used
|
||||
* because it fires on screen-lock too, which is the primary use case (UR-040).
|
||||
*
|
||||
* TRACES: UR-040 | IR-025
|
||||
*/
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (backgroundAudioEnabled) {
|
||||
dispatchWebEvent("jellytau-background")
|
||||
}
|
||||
}
|
||||
|
||||
/** The app is visible again — tell the frontend to resume WebView video. */
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (backgroundAudioEnabled) {
|
||||
dispatchWebEvent("jellytau-foreground")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a DOM CustomEvent into the WebView (native → frontend). Mirrors the
|
||||
* evaluateJavascript pattern already used to unmute video elements. Posted to
|
||||
* the WebView thread; safe no-op if the WebView isn't found yet.
|
||||
*/
|
||||
private fun dispatchWebEvent(name: String) {
|
||||
val webView = mediaWebView ?: run {
|
||||
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
|
||||
return
|
||||
}
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||
null
|
||||
)
|
||||
android.util.Log.d("MainActivity", "Dispatched web event: $name")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: android.content.res.Configuration
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
android.util.Log.d("MainActivity", "PiP mode changed: $isInPictureInPictureMode")
|
||||
PictureInPictureManager.onPipModeChanged(this, isInPictureInPictureMode)
|
||||
}
|
||||
|
||||
private fun configureWebViewForMedia() {
|
||||
try {
|
||||
val webView = findWebView(window.decorView)
|
||||
@@ -55,6 +157,7 @@ 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() {
|
||||
@@ -70,6 +173,81 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidAudioFocus")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
||||
|
||||
// Add JavaScript interface for picture-in-picture control.
|
||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||
// methods are invoked on a WebView binder thread.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
@JavascriptInterface
|
||||
fun enterPip() {
|
||||
handler.post { PictureInPictureManager.enterPip(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Whether the PiP button should be offered in the player UI at all. */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean {
|
||||
return PictureInPictureManager.isPipSupported(this@MainActivity)
|
||||
}
|
||||
|
||||
/** Whether entering PiP would work right now (local video playing). */
|
||||
@JavascriptInterface
|
||||
fun canEnterPip(): Boolean {
|
||||
return PictureInPictureManager.canEnterPip(this@MainActivity)
|
||||
}
|
||||
|
||||
/** Let the frontend opt out of auto-PiP (e.g. while casting). */
|
||||
@JavascriptInterface
|
||||
fun setAutoEnterEnabled(enabled: Boolean) {
|
||||
autoEnterPipEnabled = enabled
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
// Add JavaScript interface for background-audio mode (UR-040). The frontend
|
||||
// arms/disarms it via the player toggle; the Activity uses the flag in its
|
||||
// lifecycle overrides to decide between the audio handoff and PiP.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Frontend arms/disarms background-audio mode for the current video. */
|
||||
@JavascriptInterface
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
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")
|
||||
|
||||
// Network transport reporting for the WiFi-only download gate (UR-053).
|
||||
// The frontend polls these on demand and re-pumps the download queue when
|
||||
// the 'jellytau-network-changed' event fires.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
|
||||
@JavascriptInterface
|
||||
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
|
||||
|
||||
/** Whether the active network is unmetered. */
|
||||
@JavascriptInterface
|
||||
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
|
||||
|
||||
/** Whether downloads may run given the wifi-only preference. */
|
||||
@JavascriptInterface
|
||||
fun isAcceptable(wifiOnly: Boolean): Boolean =
|
||||
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
|
||||
|
||||
/** Whether native network detection is available at all (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
@@ -93,7 +271,6 @@ class MainActivity : TauriActivity() {
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = true
|
||||
allowContentAccess = true
|
||||
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
|
||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
/**
|
||||
* Reports the *kind* of network the device is on, so downloads can be gated on
|
||||
* "unmetered only" (the WiFi-only setting).
|
||||
*
|
||||
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
|
||||
* answers a different question: whether the Jellyfin *server* is reachable,
|
||||
* derived from real request outcomes. Reachability and transport type are
|
||||
* orthogonal — you can be on WiFi with a dead server, or on cellular with a
|
||||
* perfectly reachable one.
|
||||
*
|
||||
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
|
||||
* and we report UNKNOWN (which the gate treats as "not acceptable" when
|
||||
* wifi-only is on, failing closed rather than burning mobile data).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
object NetworkTypeMonitor {
|
||||
private const val TAG = "NetworkTypeMonitor"
|
||||
|
||||
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
|
||||
const val TYPE_NONE = "none"
|
||||
const val TYPE_WIFI = "wifi"
|
||||
const val TYPE_ETHERNET = "ethernet"
|
||||
const val TYPE_CELLULAR = "cellular"
|
||||
const val TYPE_OTHER = "other"
|
||||
const val TYPE_UNKNOWN = "unknown"
|
||||
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/** Invoked on any network change; set by [startWatching]. */
|
||||
@Volatile
|
||||
private var onChange: (() -> Unit)? = null
|
||||
|
||||
private fun connectivityManager(context: Context): ConnectivityManager? =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
|
||||
/**
|
||||
* Current transport type of the active network.
|
||||
*
|
||||
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
|
||||
* distinguish "definitely offline" from "couldn't tell".
|
||||
*/
|
||||
fun currentType(context: Context): String {
|
||||
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
|
||||
val network = cm.activeNetwork ?: return TYPE_NONE
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
|
||||
|
||||
return when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
|
||||
else -> TYPE_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active network is unmetered.
|
||||
*
|
||||
* This is the bit that actually matters for the WiFi-only gate: a phone
|
||||
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
|
||||
* cellular data the setting exists to protect. Checking NOT_METERED rather
|
||||
* than the transport alone means tethering doesn't quietly burn a data plan.
|
||||
*/
|
||||
fun isUnmetered(context: Context): Boolean {
|
||||
val cm = connectivityManager(context) ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether downloads may run right now given the wifi-only preference.
|
||||
*
|
||||
* Ethernet counts as acceptable (it is unmetered in practice and is what
|
||||
* Android TV devices use). Cellular never does. When wifi-only is off this is
|
||||
* always true — the gate simply isn't engaged.
|
||||
*/
|
||||
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
|
||||
if (!wifiOnly) return true
|
||||
val type = currentType(context)
|
||||
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
|
||||
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
|
||||
return isUnmetered(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that fires whenever the network changes, so a blocked
|
||||
* download queue can be re-pumped the moment an acceptable network appears.
|
||||
* Without this the queue would stall until some unrelated event pumped it.
|
||||
*
|
||||
* Idempotent: a second call replaces the previous callback.
|
||||
*/
|
||||
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
|
||||
val cm = connectivityManager(context) ?: run {
|
||||
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
|
||||
return
|
||||
}
|
||||
|
||||
stopWatching(context)
|
||||
onChange = onNetworkChanged
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
android.util.Log.d(TAG, "Network available")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
android.util.Log.d(TAG, "Network lost")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
// Fires when e.g. metered-ness flips without the network itself changing.
|
||||
onChange?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cm.registerNetworkCallback(request, cb)
|
||||
callback = cb
|
||||
android.util.Log.d(TAG, "Network callback registered")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unregister the network callback, if one is active. */
|
||||
fun stopWatching(context: Context) {
|
||||
val cb = callback ?: return
|
||||
val cm = connectivityManager(context)
|
||||
try {
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
callback = null
|
||||
onChange = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
|
||||
/**
|
||||
* Drives Android picture-in-picture for native (ExoPlayer) video playback.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*
|
||||
* PiP shrinks the whole Activity into a floating window, so the only thing that
|
||||
* should remain visible is the video SurfaceView that [VideoOverlayManager]
|
||||
* attached at the bottom of the z-order. The WebView carrying the Svelte UI is
|
||||
* hidden for the duration - it is opaque and sits *above* the surface, so
|
||||
* leaving it visible would occlude the video entirely.
|
||||
*
|
||||
* Playback itself is untouched: ExoPlayer keeps rendering into the same surface
|
||||
* across the transition, so entering and leaving PiP never interrupts the video.
|
||||
*/
|
||||
object PictureInPictureManager {
|
||||
|
||||
private const val TAG = "PictureInPictureManager"
|
||||
|
||||
/** Action for the play/pause RemoteAction shown inside the PiP window. */
|
||||
private const val ACTION_MEDIA_CONTROL = "com.dtourolle.jellytau.PIP_MEDIA_CONTROL"
|
||||
private const val EXTRA_CONTROL_TYPE = "control_type"
|
||||
private const val CONTROL_PLAY = 1
|
||||
private const val CONTROL_PAUSE = 2
|
||||
|
||||
/** Request codes must differ per action or the PendingIntents collapse into one. */
|
||||
private const val REQUEST_PLAY = 101
|
||||
private const val REQUEST_PAUSE = 102
|
||||
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var hiddenWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
||||
* and the user (or device manufacturer) can disable the feature per-app.
|
||||
*/
|
||||
fun isPipSupported(activity: Activity): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||
return activity.packageManager.hasSystemFeature(
|
||||
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether entering PiP right now makes sense: a native video must actually
|
||||
* be playing locally. Audio-only playback and remote/cast sessions render
|
||||
* nothing on this device, so a PiP window would be an empty black box.
|
||||
*/
|
||||
fun canEnterPip(activity: Activity): Boolean {
|
||||
if (!isPipSupported(activity)) return false
|
||||
return try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "canEnterPip check failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter picture-in-picture, sizing the window to the video's aspect ratio.
|
||||
*
|
||||
* @return true if the system accepted the transition.
|
||||
*/
|
||||
fun enterPip(activity: Activity): Boolean {
|
||||
if (!canEnterPip(activity)) {
|
||||
android.util.Log.d(TAG, "Not entering PiP: no local video playing")
|
||||
return false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||
|
||||
return try {
|
||||
val params = buildParams(activity)
|
||||
val entered = activity.enterPictureInPictureMode(params)
|
||||
android.util.Log.d(TAG, "enterPictureInPictureMode returned $entered")
|
||||
entered
|
||||
} catch (e: Exception) {
|
||||
// IllegalStateException here means PiP is disallowed (e.g. the user
|
||||
// turned it off in system settings). Never crash over it.
|
||||
android.util.Log.e(TAG, "Failed to enter PiP", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PiP params: aspect ratio from the current video, plus a play/pause
|
||||
* RemoteAction reflecting the live playback state.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildParams(activity: Activity): PictureInPictureParams {
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
|
||||
aspectRatioFor()?.let { builder.setAspectRatio(it) }
|
||||
builder.setActions(listOf(buildPlayPauseAction(activity)))
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* The video's aspect ratio, clamped to the range Android accepts.
|
||||
*
|
||||
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
|
||||
* IllegalArgumentException, which would otherwise take down the Activity on
|
||||
* unusually tall or wide content.
|
||||
*/
|
||||
private fun aspectRatioFor(): Rational? {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val surface = player.getSurfaceView() ?: return null
|
||||
// The surface has already been letterboxed to the video's aspect ratio
|
||||
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
||||
val width = surface.width
|
||||
val height = surface.height
|
||||
if (width <= 0 || height <= 0) return null
|
||||
|
||||
val ratio = width.toDouble() / height.toDouble()
|
||||
val minRatio = 1.0 / 2.39
|
||||
val maxRatio = 2.39
|
||||
val clamped = ratio.coerceIn(minRatio, maxRatio)
|
||||
|
||||
// Scale to integers; Rational(width, height) directly can overflow for
|
||||
// large surfaces, and the clamped value may not match the raw pixels.
|
||||
return Rational((clamped * 1000).toInt(), 1000)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||
val isPlaying = try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||
Quad(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause",
|
||||
CONTROL_PAUSE,
|
||||
REQUEST_PAUSE
|
||||
)
|
||||
} else {
|
||||
Quad(
|
||||
android.R.drawable.ic_media_play,
|
||||
"Play",
|
||||
CONTROL_PLAY,
|
||||
REQUEST_PLAY
|
||||
)
|
||||
}
|
||||
|
||||
val intent = Intent(ACTION_MEDIA_CONTROL)
|
||||
.putExtra(EXTRA_CONTROL_TYPE, controlType)
|
||||
// Explicit package keeps the broadcast internal to the app.
|
||||
.setPackage(activity.packageName)
|
||||
|
||||
val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
val pendingIntent = android.app.PendingIntent.getBroadcast(
|
||||
activity,
|
||||
requestCode,
|
||||
intent,
|
||||
flags
|
||||
)
|
||||
|
||||
return RemoteAction(
|
||||
Icon.createWithResource(activity, iconRes),
|
||||
title,
|
||||
title,
|
||||
pendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
private data class Quad<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D
|
||||
)
|
||||
|
||||
/**
|
||||
* Refresh the PiP window's action button so it tracks play/pause state
|
||||
* while the window is open. Safe to call when not in PiP (no-op).
|
||||
*/
|
||||
fun updatePipActions(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (!activity.isInPictureInPictureMode) return
|
||||
try {
|
||||
activity.setPictureInPictureParams(buildParams(activity))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to update PiP actions", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from MainActivity.onPictureInPictureModeChanged.
|
||||
*
|
||||
* Entering: hide the WebView so only the video surface shows, and register
|
||||
* the receiver backing the PiP play/pause button.
|
||||
* Leaving: restore the WebView and unregister.
|
||||
*/
|
||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||
if (isInPipMode) {
|
||||
hideWebView(activity)
|
||||
registerReceiver(activity)
|
||||
} else {
|
||||
unregisterReceiver(activity)
|
||||
showWebView()
|
||||
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
||||
// the restored full-screen bounds or the video stays postage-stamp sized.
|
||||
try {
|
||||
JellyTauPlayer.getInstance().fitSurfaceToScreen()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to re-fit surface after PiP", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideWebView(activity: Activity) {
|
||||
val webView = findWebView(activity.window.decorView)
|
||||
if (webView == null) {
|
||||
android.util.Log.w(TAG, "No WebView found to hide for PiP")
|
||||
return
|
||||
}
|
||||
// GONE rather than INVISIBLE: the WebView is opaque, and GONE also stops
|
||||
// it from consuming layout space in the shrunken window.
|
||||
webView.visibility = android.view.View.GONE
|
||||
hiddenWebView = webView
|
||||
android.util.Log.d(TAG, "WebView hidden for PiP")
|
||||
}
|
||||
|
||||
private fun showWebView() {
|
||||
hiddenWebView?.let {
|
||||
it.visibility = android.view.View.VISIBLE
|
||||
android.util.Log.d(TAG, "WebView restored after PiP")
|
||||
}
|
||||
hiddenWebView = null
|
||||
}
|
||||
|
||||
private fun registerReceiver(activity: Activity) {
|
||||
if (receiver != null) return
|
||||
|
||||
val r = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
}
|
||||
// Swap the button to reflect the new state.
|
||||
updatePipActions(activity)
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(ACTION_MEDIA_CONTROL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
activity.registerReceiver(r, filter)
|
||||
}
|
||||
receiver = r
|
||||
android.util.Log.d(TAG, "PiP media control receiver registered")
|
||||
}
|
||||
|
||||
private fun unregisterReceiver(activity: Activity) {
|
||||
receiver?.let {
|
||||
try {
|
||||
activity.unregisterReceiver(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Already unregistered - harmless.
|
||||
}
|
||||
}
|
||||
receiver = null
|
||||
}
|
||||
|
||||
private fun findWebView(view: android.view.View): WebView? {
|
||||
if (view is WebView) return view
|
||||
if (view is ViewGroup) {
|
||||
for (i in 0 until view.childCount) {
|
||||
findWebView(view.getChildAt(i))?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
object VideoOverlayManager {
|
||||
|
||||
private var attachedSurfaceView: SurfaceView? = null
|
||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||
private var listenerContentView: ViewGroup? = null
|
||||
|
||||
/**
|
||||
* Attach the video SurfaceView to the Activity's content view.
|
||||
@@ -51,6 +53,23 @@ object VideoOverlayManager {
|
||||
contentView.addView(surfaceView, 0, layoutParams)
|
||||
attachedSurfaceView = surfaceView
|
||||
|
||||
// Re-fit the video whenever the content view's bounds change (e.g. on
|
||||
// device rotation) so the video is letterboxed to fit instead of being
|
||||
// stretched/cropped by the MATCH_PARENT surface.
|
||||
removeLayoutListener()
|
||||
val listener = android.view.View.OnLayoutChangeListener {
|
||||
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
|
||||
player.fitSurfaceToScreen()
|
||||
}
|
||||
}
|
||||
contentView.addOnLayoutChangeListener(listener)
|
||||
contentLayoutListener = listener
|
||||
listenerContentView = contentView
|
||||
|
||||
// Fit once now that the surface is attached and the parent is sized.
|
||||
player.fitSurfaceToScreen()
|
||||
|
||||
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
||||
@@ -64,6 +83,7 @@ object VideoOverlayManager {
|
||||
*/
|
||||
fun detachVideoSurface(activity: Activity) {
|
||||
try {
|
||||
removeLayoutListener()
|
||||
attachedSurfaceView?.let { surfaceView ->
|
||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||
contentView.removeView(surfaceView)
|
||||
@@ -83,4 +103,12 @@ object VideoOverlayManager {
|
||||
fun isVideoSurfaceAttached(): Boolean {
|
||||
return attachedSurfaceView != null
|
||||
}
|
||||
|
||||
private fun removeLayoutListener() {
|
||||
contentLayoutListener?.let { listener ->
|
||||
listenerContentView?.removeOnLayoutChangeListener(listener)
|
||||
}
|
||||
contentLayoutListener = null
|
||||
listenerContentView = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
||||
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
||||
|
||||
// Wrap the ExoPlayer to intercept commands
|
||||
// Wrap the ExoPlayer to intercept commands from Media3 controllers
|
||||
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
|
||||
// session rather than the MediaSessionCompat).
|
||||
//
|
||||
// We do NOT execute on ExoPlayer directly here. Every transport command
|
||||
// is routed to Rust via nativeOnMediaCommand, which is the single decision
|
||||
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
|
||||
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
|
||||
// would double-handle local commands and incorrectly drive the local
|
||||
// player while casting.
|
||||
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||
override fun play() {
|
||||
// Execute immediately for instant lockscreen response
|
||||
super.play()
|
||||
// Then notify Rust for state management
|
||||
nativeOnMediaCommand("play")
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
// Execute immediately for instant lockscreen response
|
||||
super.pause()
|
||||
// Then notify Rust for state management
|
||||
nativeOnMediaCommand("pause")
|
||||
}
|
||||
|
||||
override fun seekToNext() {
|
||||
// Execute immediately for instant lockscreen response
|
||||
super.seekToNext()
|
||||
// Then notify Rust for queue management
|
||||
nativeOnMediaCommand("next")
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
// Execute immediately for instant lockscreen response
|
||||
super.seekToPrevious()
|
||||
// Then notify Rust for queue management
|
||||
nativeOnMediaCommand("previous")
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
// Execute immediately for instant lockscreen response
|
||||
super.seekTo(positionMs)
|
||||
// Then notify Rust of seek
|
||||
val positionSeconds = positionMs / 1000.0
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
// Execute immediately for instant lockscreen response
|
||||
super.stop()
|
||||
// Then notify Rust for state management
|
||||
nativeOnMediaCommand("stop")
|
||||
}
|
||||
}
|
||||
@@ -160,36 +151,47 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
)
|
||||
isActive = true
|
||||
|
||||
// Set callback to handle lock screen button presses
|
||||
// Set callback to handle lock screen button presses.
|
||||
//
|
||||
// All transport commands are routed through Rust via nativeOnMediaCommand
|
||||
// rather than directly to ExoPlayer. Rust is the single decision point:
|
||||
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
|
||||
// the command to the remote Jellyfin session. This keeps the lockscreen
|
||||
// working identically for both, and avoids the ExoPlayer-only behaviour
|
||||
// that left remote playback uncontrollable from the lockscreen.
|
||||
setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||
wrappedPlayer?.play()
|
||||
nativeOnMediaCommand("play")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||
wrappedPlayer?.pause()
|
||||
nativeOnMediaCommand("pause")
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||
wrappedPlayer?.seekToNext()
|
||||
nativeOnMediaCommand("next")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||
wrappedPlayer?.seekToPrevious()
|
||||
nativeOnMediaCommand("previous")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||
wrappedPlayer?.stop()
|
||||
nativeOnMediaCommand("stop")
|
||||
}
|
||||
|
||||
override fun onSeekTo(position: Long) {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||
wrappedPlayer?.seekTo(position)
|
||||
// The scrubber is absolute; Rust owns the seek in absolute terms
|
||||
// (in a background-audio handoff it rebuilds the stream at this
|
||||
// StartTimeTicks). Send the absolute position as-is.
|
||||
val positionSeconds = position / 1000.0
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -253,9 +255,38 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
.build()
|
||||
}
|
||||
|
||||
// Last-known metadata/state, retained so lightweight position ticks can
|
||||
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
|
||||
// and notification. Kept in sync by updateMediaMetadata().
|
||||
private var lastTitle: String = ""
|
||||
private var lastArtist: String = ""
|
||||
private var lastIsPlaying: Boolean = false
|
||||
|
||||
// Base offset (ms) added to every position reported to the lockscreen
|
||||
// MediaSession. During a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||
// however, is the full absolute length — so without this base the scrubber
|
||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||
// position via setPositionOffset(); 0 for normal playback.
|
||||
private var positionOffsetMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Update the MediaSession metadata and playback state.
|
||||
* This updates both the MediaSession and the notification.
|
||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||
*/
|
||||
fun setPositionOffset(offsetSeconds: Double) {
|
||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the MediaSession metadata and playback state, plus the notification.
|
||||
*
|
||||
* Call this when the track or play/pause state changes. For frequent position
|
||||
* updates during playback, use [updatePlaybackPosition] instead, which is much
|
||||
* cheaper (no metadata rebuild, no notification rebuild).
|
||||
*/
|
||||
fun updateMediaMetadata(
|
||||
title: String,
|
||||
@@ -267,6 +298,10 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
|
||||
lastTitle = title
|
||||
lastArtist = artist
|
||||
lastIsPlaying = isPlaying
|
||||
|
||||
// Update MediaSession metadata
|
||||
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
||||
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
@@ -279,8 +314,61 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
session.setMetadata(metadataBuilder.build())
|
||||
|
||||
// Update MediaSession playback state
|
||||
val stateBuilder = PlaybackStateCompat.Builder()
|
||||
// Update MediaSession playback state (position made absolute via the base offset).
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
|
||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||
// arrive on the session poller thread and can race with (or arrive
|
||||
// before) enableRemoteVolume(); this keeps the session routed to the
|
||||
// remote (absolute) volume slider instead of the local media stream.
|
||||
if (isRemoteVolumeEnabled) {
|
||||
volumeProvider?.let { session.setPlaybackToRemote(it) }
|
||||
}
|
||||
|
||||
// Update the notification
|
||||
updateNotification(title, artist, isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the playback position (and play/pause state) on the MediaSession.
|
||||
*
|
||||
* This is the cheap path used for the periodic (250ms) position ticks: it
|
||||
* refreshes the lockscreen scrubber without rebuilding metadata or the
|
||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||
* from the last play/pause and drifts out of sync with actual playback.
|
||||
*
|
||||
* @param position Position in milliseconds
|
||||
* @param isPlaying Whether playback is currently active
|
||||
*/
|
||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||
lastIsPlaying = isPlaying
|
||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
// Only rebuild the notification when the play/pause icon actually flips.
|
||||
if (notificationStateChanged) {
|
||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PlaybackStateCompat with the standard transport actions.
|
||||
*
|
||||
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
||||
* Android does not extrapolate the position past a paused track.
|
||||
*
|
||||
* While remote volume control is enabled (casting), the state is forced to
|
||||
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
|
||||
* (absolute) volume slider for a session that is actively playing; if a
|
||||
* periodic metadata/position push reports paused (e.g. before the remote
|
||||
* session has actually started), reporting STATE_PAUSED here makes the
|
||||
* system tear down the remote slider set up by setPlaybackToRemote() and
|
||||
* fall back to the local media-stream volume.
|
||||
*/
|
||||
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
||||
val playing = isPlaying || isRemoteVolumeEnabled
|
||||
return PlaybackStateCompat.Builder()
|
||||
.setActions(
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
@@ -290,15 +378,11 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
)
|
||||
.setState(
|
||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||
position,
|
||||
1.0f
|
||||
if (playing) 1.0f else 0.0f
|
||||
)
|
||||
|
||||
session.setPlaybackState(stateBuilder.build())
|
||||
|
||||
// Update the notification
|
||||
updateNotification(title, artist, isPlaying)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** Current media ID being played */
|
||||
private var currentMediaId: String? = null
|
||||
|
||||
/**
|
||||
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
|
||||
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
|
||||
* end of a transcoded stream), which would otherwise notify the backend
|
||||
* twice and, for example, decrement the sleep-timer episode counter twice.
|
||||
* Reset whenever new media is loaded.
|
||||
*/
|
||||
private var endedNotified = false
|
||||
|
||||
/** Current media metadata for notification updates */
|
||||
private var currentTitle: String = ""
|
||||
private var currentArtist: String = ""
|
||||
@@ -152,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** SurfaceView for video playback */
|
||||
private var surfaceView: SurfaceView? = null
|
||||
private var surfaceHolder: SurfaceHolder? = null
|
||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
private var currentMediaType: MediaType = MediaType.AUDIO
|
||||
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
|
||||
|
||||
@@ -171,6 +183,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
// Create ExoPlayer with audio focus handling
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
// Pause when the audio output is removed (wired headphones unplugged or
|
||||
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
|
||||
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
|
||||
// the lockscreen notification in sync automatically.
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
// Set up player listener
|
||||
@@ -202,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
// Playback completed
|
||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||
stopPositionUpdates()
|
||||
nativeOnPlaybackEnded()
|
||||
// Only notify the backend once per loaded media. ExoPlayer
|
||||
// can re-enter STATE_ENDED, which would double-count things
|
||||
// like the sleep-timer episode counter.
|
||||
if (!endedNotified) {
|
||||
endedNotified = true
|
||||
nativeOnPlaybackEnded()
|
||||
} else {
|
||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
|
||||
}
|
||||
}
|
||||
Player.STATE_BUFFERING -> {
|
||||
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
||||
@@ -239,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
|
||||
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height}")
|
||||
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height} par=${videoSize.pixelWidthHeightRatio}")
|
||||
// Apply pixel aspect ratio so anamorphic content isn't distorted
|
||||
videoWidth = (videoSize.width * videoSize.pixelWidthHeightRatio).toInt()
|
||||
videoHeight = videoSize.height
|
||||
fitSurfaceToScreen()
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
@@ -316,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
fun load(url: String, mediaId: String) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
@@ -546,6 +577,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
|
||||
// Store metadata for notification updates
|
||||
currentTitle = title
|
||||
@@ -735,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||
while (isActive) {
|
||||
if (exoPlayer.isPlaying) {
|
||||
val position = exoPlayer.currentPosition / 1000.0
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
||||
val position = positionMs / 1000.0
|
||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||
nativeOnPositionUpdate(position, duration)
|
||||
|
||||
// Keep the lockscreen scrubber live. Without this the
|
||||
// MediaSession position only refreshes on play/pause, so the
|
||||
// scrubber freezes mid-track and drifts out of sync.
|
||||
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
||||
}
|
||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
@@ -842,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
|
||||
/**
|
||||
* Resize the video surface (for orientation changes).
|
||||
*
|
||||
* Re-fits the surface to the screen preserving the video's aspect ratio so
|
||||
* nothing is cropped when the device rotates.
|
||||
*/
|
||||
fun resizeSurface(width: Int, height: Int) {
|
||||
fitSurfaceToScreen()
|
||||
}
|
||||
|
||||
/**
|
||||
* Size the video SurfaceView so the video fits entirely inside its parent
|
||||
* (the full-screen content view) while preserving aspect ratio (letterbox/
|
||||
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
|
||||
* video to the surface bounds, which crops the bottom on rotation.
|
||||
*/
|
||||
fun fitSurfaceToScreen() {
|
||||
mainHandler.post {
|
||||
surfaceView?.let { view ->
|
||||
view.layoutParams = view.layoutParams.apply {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
view.requestLayout()
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}")
|
||||
val view = surfaceView ?: return@post
|
||||
val parent = view.parent as? ViewGroup
|
||||
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||
val availW = parent?.width?.takeIf { it > 0 }
|
||||
?: appContext.resources.displayMetrics.widthPixels
|
||||
val availH = parent?.height?.takeIf { it > 0 }
|
||||
?: appContext.resources.displayMetrics.heightPixels
|
||||
|
||||
if (videoWidth <= 0 || videoHeight <= 0 || availW <= 0 || availH <= 0) {
|
||||
return@post
|
||||
}
|
||||
|
||||
val videoAspect = videoWidth.toFloat() / videoHeight.toFloat()
|
||||
val viewAspect = availW.toFloat() / availH.toFloat()
|
||||
|
||||
val targetW: Int
|
||||
val targetH: Int
|
||||
if (videoAspect > viewAspect) {
|
||||
// Video is wider than the screen → fit width, letterbox top/bottom
|
||||
targetW = availW
|
||||
targetH = (availW / videoAspect).toInt()
|
||||
} else {
|
||||
// Video is taller than the screen → fit height, pillarbox sides
|
||||
targetH = availH
|
||||
targetW = (availH * videoAspect).toInt()
|
||||
}
|
||||
|
||||
val lp = view.layoutParams
|
||||
// FrameLayout child: center the fitted surface within the full-screen parent.
|
||||
if (lp is FrameLayout.LayoutParams) {
|
||||
lp.gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
lp.width = targetW
|
||||
lp.height = targetH
|
||||
view.layoutParams = lp
|
||||
view.requestLayout()
|
||||
android.util.Log.d(
|
||||
"JellyTauPlayer",
|
||||
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 870 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 971 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
use crate::connectivity::ConnectivityMonitor;
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
|
||||
pub use session_verifier::SessionVerifier;
|
||||
|
||||
@@ -99,7 +99,10 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
/// Set the connectivity monitor (for marking server reachability)
|
||||
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
|
||||
pub fn set_connectivity_monitor(
|
||||
&mut self,
|
||||
monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
|
||||
) {
|
||||
self.connectivity_monitor = Some(monitor);
|
||||
}
|
||||
|
||||
@@ -133,9 +136,17 @@ impl AuthManager {
|
||||
|
||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||
|
||||
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
|
||||
match self
|
||||
.http_client
|
||||
.get_json_fast::<PublicSystemInfo>(&endpoint)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||
log::info!(
|
||||
"[AuthManager] Connected to server: {} ({})",
|
||||
info.server_name,
|
||||
info.version
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -181,7 +192,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.json(&serde_json::json!({
|
||||
@@ -192,19 +206,31 @@ impl AuthManager {
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let auth_response: AuthenticateByNameResponse = response.json().await
|
||||
let auth_response: AuthenticateByNameResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse login response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
|
||||
log::info!(
|
||||
"[AuthManager] Login successful for user: {} ({})",
|
||||
auth_response.user.name,
|
||||
auth_response.user.id
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -243,13 +269,19 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.get(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.get(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||
format!("Session verification failed: {}", e)
|
||||
@@ -257,24 +289,34 @@ impl AuthManager {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Mark server as unreachable for auth errors
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
let monitor = monitor.lock().await;
|
||||
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
|
||||
monitor
|
||||
.mark_unreachable(Some(format!("Authentication failed: {}", status)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let user_response: JellyfinUser = response.json().await
|
||||
let user_response: JellyfinUser = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
|
||||
log::info!(
|
||||
"[AuthManager] Session verified successfully for: {}",
|
||||
user_response.name
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -306,7 +348,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{AuthManager, User};
|
||||
|
||||
@@ -65,7 +65,10 @@ impl SessionVerifier {
|
||||
let session = auth_manager.get_session().await;
|
||||
|
||||
if let Some(session) = session {
|
||||
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
||||
log::debug!(
|
||||
"[SessionVerifier] Verifying session for: {}",
|
||||
session.username
|
||||
);
|
||||
|
||||
// Verify the session
|
||||
match auth_manager
|
||||
@@ -113,7 +116,10 @@ impl SessionVerifier {
|
||||
reason: "Session expired".to_string(),
|
||||
};
|
||||
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
||||
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Failed to emit event: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +137,18 @@ impl SessionVerifier {
|
||||
message: e.clone(),
|
||||
};
|
||||
if let Err(e) = app.emit("auth:network-error", event) {
|
||||
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Failed to emit event: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown error - log but don't invalidate
|
||||
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Unknown error during verification: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Authentication and session-lifecycle commands.
|
||||
//!
|
||||
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
|
||||
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
|
||||
|
||||
/// Wrapper for AuthManager to manage in Tauri state
|
||||
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Create session object from active session with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||
@@ -56,7 +61,11 @@ pub async fn auth_initialize(
|
||||
// Store in AuthManager
|
||||
auth_manager.0.set_session(Some(session.clone())).await;
|
||||
|
||||
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
|
||||
log::info!(
|
||||
"[AuthManager] Session restored for user: {} with normalized URL: {}",
|
||||
session.username,
|
||||
session.server_url
|
||||
);
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
@@ -80,7 +89,10 @@ pub async fn auth_login(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
// Create session from auth result with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<bool, String> {
|
||||
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
|
||||
match auth_manager
|
||||
.0
|
||||
.verify_session(&server_url, &user_id, &access_token, &device_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||
@@ -138,7 +154,10 @@ pub async fn auth_logout(
|
||||
drop(verifier_guard);
|
||||
|
||||
// Call Jellyfin logout endpoint
|
||||
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
|
||||
auth_manager
|
||||
.0
|
||||
.logout(&server_url, &access_token, &device_id)
|
||||
.await?;
|
||||
|
||||
// Clear session
|
||||
auth_manager.0.set_session(None).await;
|
||||
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
// Get current session to extract server_url and username
|
||||
let session = auth_manager.0.get_session().await
|
||||
let session = auth_manager
|
||||
.0
|
||||
.get_session()
|
||||
.await
|
||||
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||
|
||||
// Re-login with stored credentials
|
||||
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(
|
||||
&session.server_url,
|
||||
&session.username,
|
||||
&password,
|
||||
&device_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update session with new token
|
||||
let updated_session = Session {
|
||||
|
||||