Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -60,16 +60,24 @@ jobs:
|
|||||||
cargo test
|
cargo test
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
build:
|
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
||||||
name: Build Android 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
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
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
|
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -97,42 +105,13 @@ jobs:
|
|||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: bun install
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Cargo check (aarch64-linux-android)
|
||||||
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
|
|
||||||
run: |
|
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
|
cd src-tauri
|
||||||
echo "" | bunx tauri android init
|
cargo check --target aarch64-linux-android --lib
|
||||||
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
|
|
||||||
|
|||||||
@@ -161,10 +161,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: |
|
||||||
REF="${GITHUB_REF#refs/tags/v}"
|
# On a tag build, the tag is the single source of truth for the
|
||||||
VERSION="${REF#refs/heads/}"
|
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||||
# On non-tag runs keep whatever is in tauri.conf.json
|
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
echo "Setting version to $VERSION"
|
echo "Setting version to $VERSION"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||||
fi
|
fi
|
||||||
@@ -173,6 +173,35 @@ jobs:
|
|||||||
- name: Initialize Android project
|
- name: Initialize Android project
|
||||||
run: bun run tauri android init
|
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
|
- name: Sync custom Android sources & gradle config
|
||||||
run: ./scripts/sync-android-sources.sh
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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
|
||||||
|
git commit -q -m "docs: publish site from ${GITHUB_SHA::8}"
|
||||||
|
echo "🚀 Force-pushing to gitea-pages"
|
||||||
|
git push -f "$REMOTE" gitea-pages
|
||||||
@@ -25,9 +25,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Bun
|
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||||
uses: oven-sh/setup-bun@v1
|
# action needed — fetching it stalls on this Gitea runner.
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,8 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Bun
|
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||||
uses: oven-sh/setup-bun@v1
|
# action needed — fetching it stalls on this Gitea runner.
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,9 @@ android-keystore/
|
|||||||
|
|
||||||
# Local machine-specific Android NDK toolchain paths (do not commit)
|
# Local machine-specific Android NDK toolchain paths (do not commit)
|
||||||
src-tauri/.cargo/config.toml
|
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,245 @@
|
|||||||
|
# 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`.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 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.
|
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
**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)
|
## MpvBackend (Linux)
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mpv/`
|
**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.
|
- **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.
|
- **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`).
|
- **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.
|
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
||||||
- **Cache-First**: Parallel queries with intelligent fallback.
|
- **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.
|
- **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)
|
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
||||||
│ ├── client.ts # JellyfinClient (helper for streaming)
|
│ ├── client.ts # JellyfinClient (helper for streaming)
|
||||||
│ └── sessions.ts # SessionsApi (remote session control)
|
│ └── 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/
|
├── services/
|
||||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 142 KiB |
@@ -50,6 +50,14 @@ For a narrative overview of the system design, see
|
|||||||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
| 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-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-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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,6 +93,10 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
| 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-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-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 |
|
||||||
|
|
||||||
### 2.2 Jellyfin API Requirements
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
@@ -123,6 +135,7 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
| 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-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | 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
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
@@ -180,6 +193,16 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
| 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-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-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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -198,7 +221,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
| UR-009 | IR-009, IR-010, IR-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-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||||
| UR-012 | IR-009, IR-014 | - |
|
| UR-012 | IR-009, IR-014 | - |
|
||||||
| UR-013 | IR-013 | DR-017 |
|
| UR-013 | IR-013 | DR-017 |
|
||||||
@@ -228,6 +251,14 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -295,6 +326,9 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
| 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-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-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 |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
@@ -312,6 +346,7 @@ Internal architecture, components, and application logic.
|
|||||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
| 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-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -690,24 +690,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
|
```mermaid
|
||||||
flowchart TB
|
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]
|
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification (§9.1)]
|
||||||
Background -->|Screen Lock| AutoPause
|
|
||||||
|
|
||||||
AutoPause --> SaveProgress[Save Progress]
|
Gate -->|Yes| Mode{Background mode armed?}
|
||||||
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
|
||||||
|
|
||||||
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]
|
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> (reflects live player state)]
|
||||||
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
|
||||||
|
|
||||||
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",
|
"name": "jellytau",
|
||||||
"version": "0.1.0",
|
"version": "0.0.16",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"traces": "bun run scripts/extract-traces.ts",
|
"traces": "bun run scripts/extract-traces.ts",
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
BUILD_TYPE="${1:-debug}"
|
|
||||||
|
|
||||||
echo "🚀 Build and Deploy Android APK"
|
echo "🚀 Build and Deploy Android APK"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Build APK
|
# Pass all args (build type and/or --clean) through to the build script.
|
||||||
./scripts/build-android.sh "$BUILD_TYPE"
|
./scripts/build-android.sh "$@"
|
||||||
|
|
||||||
echo ""
|
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"
|
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||||
|
|||||||
@@ -15,13 +15,24 @@ echo "Android SDK: $ANDROID_HOME"
|
|||||||
echo "NDK: $NDK_HOME"
|
echo "NDK: $NDK_HOME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Build type: debug or release (default: debug)
|
# Parse args: build type (debug/release) and optional --clean flag.
|
||||||
BUILD_TYPE="${1:-debug}"
|
# 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
|
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||||
echo "🧹 Clearing build caches..."
|
if [ "$CLEAN" = "1" ]; then
|
||||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
echo "🧹 Clearing build caches (clean build)..."
|
||||||
npm install > /dev/null 2>&1
|
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
|
# Step 1: Sync Android source files
|
||||||
echo "🔄 Syncing Android sources..."
|
echo "🔄 Syncing Android sources..."
|
||||||
@@ -33,6 +44,9 @@ bun run build
|
|||||||
|
|
||||||
# Step 2: Build Android APK
|
# Step 2: Build Android APK
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
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..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk true
|
bun run tauri android build --apk true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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"
|
echo " Copied: app/build.gradle.kts"
|
||||||
fi
|
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"
|
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",
|
||||||
"tokio-rusqlite",
|
"tokio-rusqlite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4959,6 +4960,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -5281,7 +5288,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ rand = "0.8"
|
|||||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
urlencoding = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
async-trait = "0.1"
|
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,67 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Enable hardware acceleration for video playback performance -->
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<application android:hardwareAccelerated="true" />
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -22,6 +22,34 @@ class MainActivity : TauriActivity() {
|
|||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
private var audioFocusRequest: AudioFocusRequest? = null
|
||||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -37,6 +65,75 @@ class MainActivity : TauriActivity() {
|
|||||||
configureWebViewForMedia()
|
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 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() {
|
private fun configureWebViewForMedia() {
|
||||||
try {
|
try {
|
||||||
val webView = findWebView(window.decorView)
|
val webView = findWebView(window.decorView)
|
||||||
@@ -55,6 +152,7 @@ class MainActivity : TauriActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
|
mediaWebView = webView
|
||||||
|
|
||||||
// Add JavaScript interface for audio focus control
|
// Add JavaScript interface for audio focus control
|
||||||
webView.addJavascriptInterface(object : Any() {
|
webView.addJavascriptInterface(object : Any() {
|
||||||
@@ -70,6 +168,52 @@ class MainActivity : TauriActivity() {
|
|||||||
}, "AndroidAudioFocus")
|
}, "AndroidAudioFocus")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
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")
|
||||||
|
|
||||||
// Set WebChromeClient to handle video playback and audio focus
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
@@ -93,7 +237,6 @@ class MainActivity : TauriActivity() {
|
|||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
allowFileAccess = true
|
allowFileAccess = true
|
||||||
allowContentAccess = true
|
allowContentAccess = true
|
||||||
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||||
|
|||||||
@@ -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 {
|
object VideoOverlayManager {
|
||||||
|
|
||||||
private var attachedSurfaceView: SurfaceView? = null
|
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.
|
* Attach the video SurfaceView to the Activity's content view.
|
||||||
@@ -51,6 +53,23 @@ object VideoOverlayManager {
|
|||||||
contentView.addView(surfaceView, 0, layoutParams)
|
contentView.addView(surfaceView, 0, layoutParams)
|
||||||
attachedSurfaceView = surfaceView
|
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")
|
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
||||||
@@ -64,6 +83,7 @@ object VideoOverlayManager {
|
|||||||
*/
|
*/
|
||||||
fun detachVideoSurface(activity: Activity) {
|
fun detachVideoSurface(activity: Activity) {
|
||||||
try {
|
try {
|
||||||
|
removeLayoutListener()
|
||||||
attachedSurfaceView?.let { surfaceView ->
|
attachedSurfaceView?.let { surfaceView ->
|
||||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
contentView.removeView(surfaceView)
|
contentView.removeView(surfaceView)
|
||||||
@@ -83,4 +103,12 @@ object VideoOverlayManager {
|
|||||||
fun isVideoSurfaceAttached(): Boolean {
|
fun isVideoSurfaceAttached(): Boolean {
|
||||||
return attachedSurfaceView != null
|
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 jellyTauPlayer = JellyTauPlayer.getInstance()
|
||||||
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
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) {
|
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||||
override fun play() {
|
override fun play() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.play()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("play")
|
nativeOnMediaCommand("play")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.pause()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("pause")
|
nativeOnMediaCommand("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToNext() {
|
override fun seekToNext() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekToNext()
|
|
||||||
// Then notify Rust for queue management
|
|
||||||
nativeOnMediaCommand("next")
|
nativeOnMediaCommand("next")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToPrevious() {
|
override fun seekToPrevious() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekToPrevious()
|
|
||||||
// Then notify Rust for queue management
|
|
||||||
nativeOnMediaCommand("previous")
|
nativeOnMediaCommand("previous")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekTo(positionMs)
|
|
||||||
// Then notify Rust of seek
|
|
||||||
val positionSeconds = positionMs / 1000.0
|
val positionSeconds = positionMs / 1000.0
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.stop()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("stop")
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,36 +151,47 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
)
|
)
|
||||||
isActive = true
|
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() {
|
setCallback(object : MediaSessionCompat.Callback() {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||||
wrappedPlayer?.play()
|
nativeOnMediaCommand("play")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||||
wrappedPlayer?.pause()
|
nativeOnMediaCommand("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||||
wrappedPlayer?.seekToNext()
|
nativeOnMediaCommand("next")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||||
wrappedPlayer?.seekToPrevious()
|
nativeOnMediaCommand("previous")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||||
wrappedPlayer?.stop()
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(position: Long) {
|
override fun onSeekTo(position: Long) {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
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()
|
.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.
|
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||||
* This updates both the MediaSession and the notification.
|
* 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(
|
fun updateMediaMetadata(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -267,6 +298,10 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
) {
|
) {
|
||||||
val session = mediaSessionCompat ?: return
|
val session = mediaSessionCompat ?: return
|
||||||
|
|
||||||
|
lastTitle = title
|
||||||
|
lastArtist = artist
|
||||||
|
lastIsPlaying = isPlaying
|
||||||
|
|
||||||
// Update MediaSession metadata
|
// Update MediaSession metadata
|
||||||
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
||||||
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
@@ -279,8 +314,61 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
session.setMetadata(metadataBuilder.build())
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
// Update MediaSession playback state
|
// Update MediaSession playback state (position made absolute via the base offset).
|
||||||
val stateBuilder = PlaybackStateCompat.Builder()
|
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(
|
.setActions(
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
@@ -290,15 +378,11 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
PlaybackStateCompat.ACTION_SEEK_TO
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
)
|
)
|
||||||
.setState(
|
.setState(
|
||||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||||
position,
|
position,
|
||||||
1.0f
|
if (playing) 1.0f else 0.0f
|
||||||
)
|
)
|
||||||
|
.build()
|
||||||
session.setPlaybackState(stateBuilder.build())
|
|
||||||
|
|
||||||
// Update the notification
|
|
||||||
updateNotification(title, artist, isPlaying)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Current media ID being played */
|
/** Current media ID being played */
|
||||||
private var currentMediaId: String? = null
|
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 */
|
/** Current media metadata for notification updates */
|
||||||
private var currentTitle: String = ""
|
private var currentTitle: String = ""
|
||||||
private var currentArtist: String = ""
|
private var currentArtist: String = ""
|
||||||
@@ -152,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** SurfaceView for video playback */
|
/** SurfaceView for video playback */
|
||||||
private var surfaceView: SurfaceView? = null
|
private var surfaceView: SurfaceView? = null
|
||||||
private var surfaceHolder: SurfaceHolder? = 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 currentMediaType: MediaType = MediaType.AUDIO
|
||||||
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
|
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
|
// Create ExoPlayer with audio focus handling
|
||||||
exoPlayer = ExoPlayer.Builder(appContext)
|
exoPlayer = ExoPlayer.Builder(appContext)
|
||||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
.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()
|
.build()
|
||||||
|
|
||||||
// Set up player listener
|
// Set up player listener
|
||||||
@@ -202,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Playback completed
|
// Playback completed
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||||
stopPositionUpdates()
|
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 -> {
|
Player.STATE_BUFFERING -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ 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) {
|
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() {
|
override fun onRenderedFirstFrame() {
|
||||||
@@ -316,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
fun load(url: String, mediaId: String) {
|
fun load(url: String, mediaId: String) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
@@ -546,6 +577,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
) {
|
) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
|
|
||||||
// Store metadata for notification updates
|
// Store metadata for notification updates
|
||||||
currentTitle = title
|
currentTitle = title
|
||||||
@@ -735,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
if (exoPlayer.isPlaying) {
|
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
|
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||||
nativeOnPositionUpdate(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)
|
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
@@ -842,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resize the video surface (for orientation changes).
|
* 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) {
|
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 {
|
mainHandler.post {
|
||||||
surfaceView?.let { view ->
|
val view = surfaceView ?: return@post
|
||||||
view.layoutParams = view.layoutParams.apply {
|
val parent = view.parent as? ViewGroup
|
||||||
this.width = width
|
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||||
this.height = height
|
val availW = parent?.width?.takeIf { it > 0 }
|
||||||
}
|
?: appContext.resources.displayMetrics.widthPixels
|
||||||
view.requestLayout()
|
val availH = parent?.height?.takeIf { it > 0 }
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}")
|
?: 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 std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::jellyfin::http_client::HttpClient;
|
|
||||||
use crate::connectivity::ConnectivityMonitor;
|
use crate::connectivity::ConnectivityMonitor;
|
||||||
|
use crate::jellyfin::http_client::HttpClient;
|
||||||
|
|
||||||
pub use session_verifier::SessionVerifier;
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
@@ -99,7 +99,10 @@ impl AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the connectivity monitor (for marking server reachability)
|
/// 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);
|
self.connectivity_monitor = Some(monitor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +136,17 @@ impl AuthManager {
|
|||||||
|
|
||||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
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) => {
|
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
|
// Mark server as reachable
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
@@ -181,7 +192,10 @@ impl AuthManager {
|
|||||||
let auth_header = HttpClient::build_auth_header(None, device_id);
|
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||||
|
|
||||||
// Build request manually for custom headers
|
// 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("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("X-Emby-Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
@@ -192,19 +206,31 @@ impl AuthManager {
|
|||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
// Use retry logic
|
// 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))?;
|
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
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));
|
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))?;
|
.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
|
// Mark server as reachable
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
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);
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
// Build request manually for custom headers
|
// 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)
|
.header("X-Emby-Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
// Use retry logic
|
// 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| {
|
.map_err(|e| {
|
||||||
log::warn!("[AuthManager] Session verification failed: {}", e);
|
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||||
format!("Session verification failed: {}", e)
|
format!("Session verification failed: {}", e)
|
||||||
@@ -257,24 +289,34 @@ impl AuthManager {
|
|||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
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
|
// Mark server as unreachable for auth errors
|
||||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||||
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
let monitor = monitor.lock().await;
|
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));
|
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))?;
|
.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
|
// Mark server as reachable
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
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);
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
// Build request
|
// Build request
|
||||||
let request = self.http_client.client.post(&endpoint)
|
let request = self
|
||||||
|
.http_client
|
||||||
|
.client
|
||||||
|
.post(&endpoint)
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("X-Emby-Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::{AuthManager, User};
|
use super::{AuthManager, User};
|
||||||
|
|
||||||
@@ -65,7 +65,10 @@ impl SessionVerifier {
|
|||||||
let session = auth_manager.get_session().await;
|
let session = auth_manager.get_session().await;
|
||||||
|
|
||||||
if let Some(session) = session {
|
if let Some(session) = session {
|
||||||
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
log::debug!(
|
||||||
|
"[SessionVerifier] Verifying session for: {}",
|
||||||
|
session.username
|
||||||
|
);
|
||||||
|
|
||||||
// Verify the session
|
// Verify the session
|
||||||
match auth_manager
|
match auth_manager
|
||||||
@@ -113,7 +116,10 @@ impl SessionVerifier {
|
|||||||
reason: "Session expired".to_string(),
|
reason: "Session expired".to_string(),
|
||||||
};
|
};
|
||||||
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
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(),
|
message: e.clone(),
|
||||||
};
|
};
|
||||||
if let Err(e) = app.emit("auth:network-error", event) {
|
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 {
|
} else {
|
||||||
// Unknown error - log but don't invalidate
|
// 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 std::sync::Arc;
|
||||||
use tauri::State;
|
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
|
/// Wrapper for AuthManager to manage in Tauri state
|
||||||
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||||
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
|
|||||||
log::info!("[AuthManager] Restoring session from storage...");
|
log::info!("[AuthManager] Restoring session from storage...");
|
||||||
|
|
||||||
// Use the existing storage_get_active_session function
|
// Use the existing storage_get_active_session function
|
||||||
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
let active_session =
|
||||||
Ok(Some(session)) => session,
|
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||||
Ok(None) => {
|
Ok(Some(session)) => session,
|
||||||
log::info!("[AuthManager] No active session in storage");
|
Ok(None) => {
|
||||||
return Ok(None);
|
log::info!("[AuthManager] No active session in storage");
|
||||||
}
|
return Ok(None);
|
||||||
Err(e) => {
|
}
|
||||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
Err(e) => {
|
||||||
return Err(e);
|
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||||
}
|
return Err(e);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create session object from active session with normalized URL
|
// Create session object from active session with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_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
|
// Store in AuthManager
|
||||||
auth_manager.0.set_session(Some(session.clone())).await;
|
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))
|
Ok(Some(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +89,10 @@ pub async fn auth_login(
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<AuthResult, String> {
|
) -> 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
|
// Create session from auth result with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||||
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> 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),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||||
@@ -138,7 +154,10 @@ pub async fn auth_logout(
|
|||||||
drop(verifier_guard);
|
drop(verifier_guard);
|
||||||
|
|
||||||
// Call Jellyfin logout endpoint
|
// 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
|
// Clear session
|
||||||
auth_manager.0.set_session(None).await;
|
auth_manager.0.set_session(None).await;
|
||||||
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
|
|||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
// Get current session to extract server_url and username
|
// 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())?;
|
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||||
|
|
||||||
// Re-login with stored credentials
|
// 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
|
// Update session with new token
|
||||||
let updated_session = Session {
|
let updated_session = Session {
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
//! Tauri commands for the offline "browse & queue" feature.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
|
||||||
|
//!
|
||||||
|
//! Two backend pieces support browsing the full server catalog while offline
|
||||||
|
//! and queueing downloads that fire on reconnect:
|
||||||
|
//!
|
||||||
|
//! - [`sync_full_catalog`] walks every library while online and persists all
|
||||||
|
//! items to the offline cache so the whole catalog is browsable (greyed out)
|
||||||
|
//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in
|
||||||
|
//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
|
||||||
|
//! what `get_items` branch 3 serves offline).
|
||||||
|
//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
|
||||||
|
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
||||||
|
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
|
use crate::repository::types::GetItemsOptions;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// app_settings key holding the RFC-3339 timestamp of the last successful
|
||||||
|
/// full-catalog sync.
|
||||||
|
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
||||||
|
|
||||||
|
/// Item types worth caching for offline browsing: containers the library
|
||||||
|
/// landing pages render plus the playable leaves users queue for download.
|
||||||
|
const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||||
|
"MusicAlbum",
|
||||||
|
"Movie",
|
||||||
|
"Series",
|
||||||
|
"Season",
|
||||||
|
"Episode",
|
||||||
|
"Audio",
|
||||||
|
"BoxSet",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogSyncResult {
|
||||||
|
/// Total items persisted to the offline cache across all libraries.
|
||||||
|
pub items_cached: usize,
|
||||||
|
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||||
|
pub libraries_failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogSyncStatus {
|
||||||
|
/// RFC-3339 timestamp of the last successful sync, if any.
|
||||||
|
pub last_synced_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk every library on the server and persist all items to the offline cache
|
||||||
|
/// so the full catalog is browsable offline (greyed out when not downloaded).
|
||||||
|
///
|
||||||
|
/// Best-effort: a library that fails to fetch is counted and skipped rather than
|
||||||
|
/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
|
||||||
|
/// server. Uses `Recursive=true` so a single request per library returns the
|
||||||
|
/// containers and their playable children.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn sync_full_catalog(
|
||||||
|
repository: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<CatalogSyncResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||||
|
info!(
|
||||||
|
"[Catalog] Full sync starting across {} libraries",
|
||||||
|
libraries.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
let mut items_cached = 0usize;
|
||||||
|
let mut libraries_failed = 0usize;
|
||||||
|
|
||||||
|
for library in &libraries {
|
||||||
|
let opts = GetItemsOptions {
|
||||||
|
recursive: Some(true),
|
||||||
|
include_item_types: Some(include_types.clone()),
|
||||||
|
limit: Some(100_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.cache_items_from_server(&library.id, Some(opts)).await {
|
||||||
|
Ok(items) => {
|
||||||
|
info!(
|
||||||
|
"[Catalog] Cached {} items from library '{}'",
|
||||||
|
items.len(),
|
||||||
|
library.name
|
||||||
|
);
|
||||||
|
items_cached += items.len();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to sync library '{}': {:?}",
|
||||||
|
library.name, e
|
||||||
|
);
|
||||||
|
libraries_failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the sync time so callers can skip re-syncing too eagerly.
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let upsert = Query::with_params(
|
||||||
|
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
|
||||||
|
QueryParam::String(now),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(upsert).await {
|
||||||
|
warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
||||||
|
items_cached, libraries_failed
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(CatalogSyncResult {
|
||||||
|
items_cached,
|
||||||
|
libraries_failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
||||||
|
/// to trigger a fresh sync.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn catalog_sync_status(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
) -> Result<CatalogSyncStatus, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?",
|
||||||
|
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
|
||||||
|
);
|
||||||
|
let last_synced_at: Option<String> = db_service
|
||||||
|
.query_optional(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(CatalogSyncStatus { last_synced_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control whether offline library queries reveal the full synced catalog
|
||||||
|
/// (greyed-out, non-downloaded media) or only downloaded/local media.
|
||||||
|
///
|
||||||
|
/// The frontend calls this from the "Show all server media" toggle: pass `true`
|
||||||
|
/// when online, or when offline with the toggle on; pass `false` when offline
|
||||||
|
/// with the toggle off so library pages show downloaded media only. Fixes the
|
||||||
|
/// bug where offline library pages showed every server item regardless of the
|
||||||
|
/// toggle.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn set_show_server_catalog(show: bool) {
|
||||||
|
crate::repository::offline::set_include_catalog_browse(show);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResumeQueuedResult {
|
||||||
|
/// Rows whose stream URL was resolved and are now pump-eligible.
|
||||||
|
pub resolved: usize,
|
||||||
|
/// Rows that couldn't be resolved (item metadata / URL lookup failed).
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||||
|
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||||
|
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||||
|
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||||
|
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
target_dir: &str,
|
||||||
|
resolve: F,
|
||||||
|
) -> Result<ResumeQueuedResult, String>
|
||||||
|
where
|
||||||
|
F: Fn(String, String, String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Option<String>>,
|
||||||
|
{
|
||||||
|
let rows_query = Query::new(
|
||||||
|
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
||||||
|
FROM downloads
|
||||||
|
WHERE status = 'pending' AND stream_url IS NULL",
|
||||||
|
);
|
||||||
|
let rows: Vec<(i64, String, String, String)> = db_service
|
||||||
|
.query_many(rows_query, |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(ResumeQueuedResult {
|
||||||
|
resolved: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Resolving {} offline-queued downloads on reconnect",
|
||||||
|
rows.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut resolved = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
for (download_id, item_id, media_type, quality) in rows {
|
||||||
|
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
|
||||||
|
Some(url) => url,
|
||||||
|
None => {
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
||||||
|
// concurrent resolver doesn't clobber an already-started row.
|
||||||
|
let update = Query::with_params(
|
||||||
|
"UPDATE downloads SET stream_url = ?, target_dir = ?
|
||||||
|
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(stream_url),
|
||||||
|
QueryParam::String(target_dir.to_string()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
match db_service.execute(update).await {
|
||||||
|
Ok(n) if n > 0 => resolved += 1,
|
||||||
|
Ok(_) => {} // already resolved by someone else; not a failure
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to persist URL for download {}: {}",
|
||||||
|
download_id, e
|
||||||
|
);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResumeQueuedResult { resolved, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the stream URL for every download row that was queued while offline
|
||||||
|
/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
|
||||||
|
/// start. Call this on reconnect.
|
||||||
|
///
|
||||||
|
/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
|
||||||
|
/// 'video') via the pure `get_video_download_url` builder using the row's stored
|
||||||
|
/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
|
||||||
|
/// be resolved are left pending (they retry on the next reconnect).
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn resume_queued_downloads(
|
||||||
|
repository: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<ResumeQueuedResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
use crate::repository::HybridRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
// The pump needs a target_dir; use the same storage root the other download
|
||||||
|
// paths use (the database's parent directory — see `storage_get_path`).
|
||||||
|
let (db_service, target_dir) = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let target_dir = database
|
||||||
|
.path()
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
(Arc::new(database.service()), target_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recover stale downloads: rows left in 'downloading' when the app was killed
|
||||||
|
// mid-transfer are orphaned — nothing ever restarts them, so they show as
|
||||||
|
// permanently "downloading". Reset them to 'pending' and clear the stale
|
||||||
|
// stream_url so they get re-resolved and restarted from scratch below.
|
||||||
|
let recover_query = Query::new(
|
||||||
|
"UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
|
||||||
|
bytes_downloaded = 0, started_at = NULL \
|
||||||
|
WHERE status = 'downloading'",
|
||||||
|
);
|
||||||
|
match db_service.execute(recover_query).await {
|
||||||
|
Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each row's URL against the (now reachable) repository.
|
||||||
|
let repo_for_resolve = Arc::clone(&repo);
|
||||||
|
let outcome = resolve_pending_download_urls(
|
||||||
|
&db_service,
|
||||||
|
&target_dir,
|
||||||
|
move |item_id: String, media_type: String, quality: String| {
|
||||||
|
let repo = Arc::clone(&repo_for_resolve);
|
||||||
|
async move {
|
||||||
|
if media_type == "video" {
|
||||||
|
Some(
|
||||||
|
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||||
|
repo.as_ref(),
|
||||||
|
&item_id,
|
||||||
|
&quality,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||||
|
item_id, e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let ResumeQueuedResult { resolved, failed } = outcome;
|
||||||
|
|
||||||
|
// Kick the pump so the newly-resolved rows actually start.
|
||||||
|
if resolved > 0 {
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Resume complete: {} resolved, {} failed",
|
||||||
|
resolved, failed
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ResumeQueuedResult { resolved, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
fn test_db() -> Arc<RusqliteService> {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
stream_url TEXT,
|
||||||
|
target_dir TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
quality_preset TEXT
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_download(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
item_id: &str,
|
||||||
|
status: &str,
|
||||||
|
stream_url: Option<&str>,
|
||||||
|
media_type: Option<&str>,
|
||||||
|
) {
|
||||||
|
let q = Query::with_params(
|
||||||
|
"INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
QueryParam::String(status.to_string()),
|
||||||
|
stream_url
|
||||||
|
.map(|s| QueryParam::String(s.to_string()))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
media_type
|
||||||
|
.map(|s| QueryParam::String(s.to_string()))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
db.execute(q).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_row(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
item_id: &str,
|
||||||
|
) -> (String, Option<String>, Option<String>) {
|
||||||
|
let q = Query::with_params(
|
||||||
|
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||||
|
vec![QueryParam::String(item_id.to_string())],
|
||||||
|
);
|
||||||
|
db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
|
||||||
|
let db = test_db();
|
||||||
|
// A row queued offline: pending with no URL yet.
|
||||||
|
insert_download(&db, "queued-1", "pending", None, None).await;
|
||||||
|
// An already-resolved pending row: must NOT be touched.
|
||||||
|
insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
|
||||||
|
// A completed row: irrelevant.
|
||||||
|
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||||
|
|
||||||
|
let out =
|
||||||
|
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||||
|
Some(format!("http://resolved/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
assert_eq!(out.failed, 0);
|
||||||
|
|
||||||
|
// The offline-queued row now has a URL + target dir and stays pending.
|
||||||
|
let (status, url, target) = get_row(&db, "queued-1").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
|
||||||
|
assert_eq!(target.as_deref(), Some("/data/downloads"));
|
||||||
|
|
||||||
|
// The already-resolved row is unchanged (not re-resolved).
|
||||||
|
let (_s, url2, _t) = get_row(&db, "already").await;
|
||||||
|
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "bad", "pending", None, None).await;
|
||||||
|
|
||||||
|
// Resolver returns None (e.g. server lookup failed).
|
||||||
|
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 0);
|
||||||
|
assert_eq!(out.failed, 1);
|
||||||
|
|
||||||
|
// Still pending with no URL, so a later reconnect can retry it.
|
||||||
|
let (status, url, _t) = get_row(&db, "bad").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn video_rows_use_media_type_in_resolver() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||||
|
|
||||||
|
let out =
|
||||||
|
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||||
|
assert_eq!(media_type, "video");
|
||||||
|
Some(format!("http://transcode/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||||
|
assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
//! Server-reachability / connectivity commands.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-043 | IR-027 | DR-055
|
||||||
|
|
||||||
|
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
|
||||||
|
|
||||||
/// Wrapper for ConnectivityMonitor managed state
|
/// Wrapper for ConnectivityMonitor managed state
|
||||||
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! Tauri commands for unit conversions and formatting
|
//! Tauri commands for unit conversions and formatting
|
||||||
//!
|
//!
|
||||||
|
//! TRACES: UR-005 | DR-009
|
||||||
|
//!
|
||||||
//! These commands expose conversion utilities to the frontend,
|
//! These commands expose conversion utilities to the frontend,
|
||||||
//! allowing centralized conversion logic in Rust.
|
//! allowing centralized conversion logic in Rust.
|
||||||
|
|
||||||
use crate::utils::conversions::{
|
use crate::utils::conversions::{
|
||||||
format_time, format_time_long, calculate_progress,
|
calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
|
||||||
ticks_to_seconds, percent_to_volume,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Format time in seconds to MM:SS display string
|
/// Format time in seconds to MM:SS display string
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
|
|||||||
/// TRACES: UR-009 | DR-011
|
/// TRACES: UR-009 | DR-011
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
pub async fn device_set_id(
|
||||||
|
device_id: String,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-044 | DR-056
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -45,7 +47,10 @@ pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Resu
|
|||||||
/// Check if an item is pinned
|
/// Check if an item is pinned
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
pub async fn is_item_pinned(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Smart-cache statistics/config and album recommendation commands.
|
//! Smart-cache statistics/config and album recommendation commands.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-045 | DR-057
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use log::info;
|
use log::info;
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
||||||
// DR-015, DR-017, DR-021, DR-028
|
// DR-015, DR-017, DR-021, DR-028
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod catalog;
|
||||||
pub mod connectivity;
|
pub mod connectivity;
|
||||||
pub mod conversions;
|
pub mod conversions;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
@@ -17,17 +18,18 @@ pub mod storage;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
|
pub use catalog::*;
|
||||||
pub use connectivity::*;
|
pub use connectivity::*;
|
||||||
pub use conversions::*;
|
pub use conversions::*;
|
||||||
pub use device::*;
|
pub use device::*;
|
||||||
pub use download::*;
|
pub use download::*;
|
||||||
pub use offline::*;
|
pub use offline::*;
|
||||||
pub use playback_mode::*;
|
pub use playback_mode::*;
|
||||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||||
pub use playback_reporting::*;
|
pub use playback_reporting::*;
|
||||||
pub use player::*;
|
pub use player::*;
|
||||||
pub use playlist::*;
|
pub use playlist::*;
|
||||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||||
pub use sessions::*;
|
pub use sessions::*;
|
||||||
pub use storage::*;
|
pub use storage::*;
|
||||||
pub use sync::*;
|
pub use sync::*;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
//! Playback-mode transfer commands (local ↔ remote).
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-010 | DR-059
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
@@ -41,12 +45,30 @@ pub fn playback_mode_is_transferring(
|
|||||||
pub async fn playback_mode_transfer_to_remote(
|
pub async fn playback_mode_transfer_to_remote(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
position: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[PlaybackModeCommands] Transferring to remote session: {}",
|
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
||||||
session_id
|
session_id,
|
||||||
|
position
|
||||||
);
|
);
|
||||||
manager.0.transfer_to_remote(session_id).await
|
manager.0.transfer_to_remote(session_id, position).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the transferring flag on the playback mode manager.
|
||||||
|
///
|
||||||
|
/// Used by the frontend remote->local flow to mark the whole two-step sequence
|
||||||
|
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
|
||||||
|
/// casting back to the remote session it's leaving. Always pair `true` with a
|
||||||
|
/// later `false` (including on error) so the flag can't stick.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playback_mode_set_transferring(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
transferring: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.0.set_transferring(transferring);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from remote session back to local device
|
/// Transfer playback from remote session back to local device
|
||||||
@@ -87,21 +109,30 @@ pub async fn playback_mode_get_remote_status(
|
|||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
let client_arc = controller.jellyfin_client();
|
let client_arc = controller.jellyfin_client();
|
||||||
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
||||||
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
|
client_opt
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Jellyfin client not configured")?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get session info
|
// Get session info
|
||||||
match client.get_session(&session_id).await {
|
match client.get_session(&session_id).await {
|
||||||
Ok(Some(session)) => {
|
Ok(Some(session)) => {
|
||||||
let position_ticks = session.play_state.as_ref()
|
let position_ticks = session
|
||||||
|
.play_state
|
||||||
|
.as_ref()
|
||||||
.and_then(|ps| ps.position_ticks)
|
.and_then(|ps| ps.position_ticks)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let duration_ticks = session.now_playing_item.as_ref()
|
let duration_ticks = session
|
||||||
|
.now_playing_item
|
||||||
|
.as_ref()
|
||||||
.and_then(|item| item.run_time_ticks)
|
.and_then(|item| item.run_time_ticks)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let is_paused = session.play_state.as_ref()
|
let is_paused = session
|
||||||
|
.play_state
|
||||||
|
.as_ref()
|
||||||
.and_then(|ps| ps.is_paused)
|
.and_then(|ps| ps.is_paused)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
@@ -206,17 +237,20 @@ mod tests {
|
|||||||
fn test_playback_mode_deserialization_from_frontend() {
|
fn test_playback_mode_deserialization_from_frontend() {
|
||||||
// Test what frontend sends for Idle mode
|
// Test what frontend sends for Idle mode
|
||||||
let idle_json = r#"{"type":"idle"}"#;
|
let idle_json = r#"{"type":"idle"}"#;
|
||||||
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
let mode: PlaybackMode =
|
||||||
|
serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||||
assert_eq!(mode, PlaybackMode::Idle);
|
assert_eq!(mode, PlaybackMode::Idle);
|
||||||
|
|
||||||
// Test what frontend sends for Local mode
|
// Test what frontend sends for Local mode
|
||||||
let local_json = r#"{"type":"local"}"#;
|
let local_json = r#"{"type":"local"}"#;
|
||||||
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
let mode: PlaybackMode =
|
||||||
|
serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||||
assert_eq!(mode, PlaybackMode::Local);
|
assert_eq!(mode, PlaybackMode::Local);
|
||||||
|
|
||||||
// Test what frontend sends for Remote mode
|
// Test what frontend sends for Remote mode
|
||||||
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
||||||
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
let mode: PlaybackMode =
|
||||||
|
serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||||
match mode {
|
match mode {
|
||||||
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||||
_ => panic!("Expected Remote mode"),
|
_ => panic!("Expected Remote mode"),
|
||||||
@@ -229,8 +263,8 @@ mod tests {
|
|||||||
|
|
||||||
// Test Search context (the recently fixed issue)
|
// Test Search context (the recently fixed issue)
|
||||||
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||||
let context: PlayTracksContext = serde_json::from_str(search_json)
|
let context: PlayTracksContext =
|
||||||
.expect("Failed to deserialize search context");
|
serde_json::from_str(search_json).expect("Failed to deserialize search context");
|
||||||
match context {
|
match context {
|
||||||
PlayTracksContext::Search { search_query } => {
|
PlayTracksContext::Search { search_query } => {
|
||||||
assert_eq!(search_query, "test query");
|
assert_eq!(search_query, "test query");
|
||||||
@@ -239,11 +273,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test Playlist context
|
// Test Playlist context
|
||||||
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
let playlist_json =
|
||||||
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||||
.expect("Failed to deserialize playlist context");
|
let context: PlayTracksContext =
|
||||||
|
serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
|
||||||
match context {
|
match context {
|
||||||
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
PlayTracksContext::Playlist {
|
||||||
|
playlist_id,
|
||||||
|
playlist_name,
|
||||||
|
} => {
|
||||||
assert_eq!(playlist_id, "pl-123");
|
assert_eq!(playlist_id, "pl-123");
|
||||||
assert_eq!(playlist_name, "My Playlist");
|
assert_eq!(playlist_name, "My Playlist");
|
||||||
}
|
}
|
||||||
@@ -252,8 +290,8 @@ mod tests {
|
|||||||
|
|
||||||
// Test Custom context
|
// Test Custom context
|
||||||
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||||
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
let context: PlayTracksContext =
|
||||||
.expect("Failed to deserialize custom context");
|
serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
|
||||||
match context {
|
match context {
|
||||||
PlayTracksContext::Custom { label } => {
|
PlayTracksContext::Custom { label } => {
|
||||||
assert_eq!(label, Some("Custom Queue".to_string()));
|
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Tauri commands for playback reporting operations
|
//! Tauri commands for playback reporting operations
|
||||||
//!
|
//!
|
||||||
|
//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
|
||||||
|
//!
|
||||||
//! These commands provide frontend access to the Rust playback reporting system,
|
//! These commands provide frontend access to the Rust playback reporting system,
|
||||||
//! replacing the TypeScript implementation with native Rust reporting.
|
//! replacing the TypeScript implementation with native Rust reporting.
|
||||||
//!
|
//!
|
||||||
@@ -16,7 +18,7 @@ use crate::commands::connectivity::ConnectivityMonitorWrapper;
|
|||||||
use crate::commands::storage::DatabaseWrapper;
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
use crate::jellyfin::client::JellyfinClient;
|
use crate::jellyfin::client::JellyfinClient;
|
||||||
use crate::jellyfin::JellyfinConfig;
|
use crate::jellyfin::JellyfinConfig;
|
||||||
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
|
||||||
use crate::utils::conversions::seconds_to_ticks;
|
use crate::utils::conversions::seconds_to_ticks;
|
||||||
|
|
||||||
/// Tauri state wrapper for PlaybackReporter
|
/// Tauri state wrapper for PlaybackReporter
|
||||||
@@ -61,7 +63,10 @@ pub async fn playback_reporter_init(
|
|||||||
// Store in wrapper
|
// Store in wrapper
|
||||||
*reporter_wrapper.0.lock().await = Some(reporter);
|
*reporter_wrapper.0.lock().await = Some(reporter);
|
||||||
|
|
||||||
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
|
log::info!(
|
||||||
|
"[PlaybackReporter] Initialized successfully for user: {}",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +210,12 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Verify enum variant can be created and pattern matched
|
// Verify enum variant can be created and pattern matched
|
||||||
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
|
if let PlaybackOperation::Start {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
context,
|
||||||
|
} = operation
|
||||||
|
{
|
||||||
assert_eq!(item_id, "item-123");
|
assert_eq!(item_id, "item-123");
|
||||||
assert_eq!(position_ticks, 15_000_000);
|
assert_eq!(position_ticks, 15_000_000);
|
||||||
assert!(context.is_some());
|
assert!(context.is_some());
|
||||||
@@ -225,7 +235,10 @@ mod tests {
|
|||||||
context: None,
|
context: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let PlaybackOperation::Start { item_id, context, .. } = operation {
|
if let PlaybackOperation::Start {
|
||||||
|
item_id, context, ..
|
||||||
|
} = operation
|
||||||
|
{
|
||||||
assert_eq!(item_id, "item-789");
|
assert_eq!(item_id, "item-789");
|
||||||
assert!(context.is_none());
|
assert!(context.is_none());
|
||||||
} else {
|
} else {
|
||||||
@@ -241,7 +254,12 @@ mod tests {
|
|||||||
is_paused: true,
|
is_paused: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
|
if let PlaybackOperation::Progress {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
is_paused,
|
||||||
|
} = operation
|
||||||
|
{
|
||||||
assert_eq!(item_id, "item-999");
|
assert_eq!(item_id, "item-999");
|
||||||
assert_eq!(position_ticks, 30_000_000);
|
assert_eq!(position_ticks, 30_000_000);
|
||||||
assert!(is_paused);
|
assert!(is_paused);
|
||||||
@@ -272,7 +290,11 @@ mod tests {
|
|||||||
position_ticks: 120_000_000,
|
position_ticks: 120_000_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
|
if let PlaybackOperation::Stopped {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
} = operation
|
||||||
|
{
|
||||||
assert_eq!(item_id, "item-111");
|
assert_eq!(item_id, "item-111");
|
||||||
assert_eq!(position_ticks, 120_000_000);
|
assert_eq!(position_ticks, 120_000_000);
|
||||||
} else {
|
} else {
|
||||||
@@ -364,7 +386,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cloned = operation.clone();
|
let cloned = operation.clone();
|
||||||
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
|
if let PlaybackOperation::Progress {
|
||||||
|
item_id, is_paused, ..
|
||||||
|
} = cloned
|
||||||
|
{
|
||||||
assert_eq!(item_id, "item-clone");
|
assert_eq!(item_id, "item-clone");
|
||||||
assert!(is_paused);
|
assert!(is_paused);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Queue manipulation commands (add / remove / move / skip).
|
//! Queue manipulation commands (add / remove / move / skip).
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-015 | DR-005, DR-020
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
|
|||||||
) -> Result<QueueStatus, String> {
|
) -> Result<QueueStatus, String> {
|
||||||
use crate::player::queue::AddPosition;
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
info!("player_add_track_by_id called: track_id={}, position={}",
|
info!(
|
||||||
request.track_id, request.position);
|
"player_add_track_by_id called: track_id={}, position={}",
|
||||||
|
request.track_id, request.position
|
||||||
|
);
|
||||||
|
|
||||||
// Get repository (hybrid - supports offline/online)
|
// Get repository (hybrid - supports offline/online)
|
||||||
let repository = repository_manager.0.get(&repository_handle)
|
let repository = repository_manager
|
||||||
|
.0
|
||||||
|
.get(&repository_handle)
|
||||||
.ok_or("Repository not found - user may need to log in")?;
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
// Fetch track metadata via repository
|
// Fetch track metadata via repository
|
||||||
info!("Fetching metadata for track {} via repository", request.track_id);
|
info!(
|
||||||
let track = repository.get_item(&request.track_id).await
|
"Fetching metadata for track {} via repository",
|
||||||
|
request.track_id
|
||||||
|
);
|
||||||
|
let track = repository
|
||||||
|
.get_item(&request.track_id)
|
||||||
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
||||||
|
|
||||||
// Check for local download first
|
// Check for local download first
|
||||||
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get stream URL from repository (works online/offline)
|
// Get stream URL from repository (works online/offline)
|
||||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
let stream_url = repository
|
||||||
|
.get_audio_stream_url(&track.id)
|
||||||
|
.await
|
||||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
MediaSource::Remote {
|
MediaSource::Remote {
|
||||||
@@ -188,23 +201,30 @@ pub async fn player_add_track_by_id(
|
|||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
artist: track
|
||||||
|
.album_artist
|
||||||
|
.clone()
|
||||||
|
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||||
album: track.album_name.clone(),
|
album: track.album_name.clone(),
|
||||||
album_name: track.album_name.clone(), // Frontend compatibility
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
album_id: track.album_id.clone(),
|
album_id: track.album_id.clone(),
|
||||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||||
artists: track.artists.clone(), // Fallback artist info
|
artists: track.artists.clone(), // Fallback artist info
|
||||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
playlist_id: None,
|
playlist_id: None,
|
||||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
track.album_id.as_ref().map(|album_id| {
|
track.album_id.as_ref().map(|album_id| {
|
||||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
repository.get_image_url(
|
||||||
max_width: Some(300),
|
album_id,
|
||||||
tag: Some(tag),
|
ImageType::Primary,
|
||||||
..Default::default()
|
Some(ImageOptions {
|
||||||
}))
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
@@ -250,18 +270,25 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
) -> Result<QueueStatus, String> {
|
) -> Result<QueueStatus, String> {
|
||||||
use crate::player::queue::AddPosition;
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
info!("player_add_tracks_by_ids called: {} tracks, position={}",
|
info!(
|
||||||
request.track_ids.len(), request.position);
|
"player_add_tracks_by_ids called: {} tracks, position={}",
|
||||||
|
request.track_ids.len(),
|
||||||
|
request.position
|
||||||
|
);
|
||||||
|
|
||||||
// Get repository (hybrid - supports offline/online)
|
// Get repository (hybrid - supports offline/online)
|
||||||
let repository = repository_manager.0.get(&repository_handle)
|
let repository = repository_manager
|
||||||
|
.0
|
||||||
|
.get(&repository_handle)
|
||||||
.ok_or("Repository not found - user may need to log in")?;
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
// Fetch metadata and build MediaItems for all tracks
|
// Fetch metadata and build MediaItems for all tracks
|
||||||
let mut media_items = Vec::new();
|
let mut media_items = Vec::new();
|
||||||
for track_id in &request.track_ids {
|
for track_id in &request.track_ids {
|
||||||
info!("Fetching metadata for track {} via repository", track_id);
|
info!("Fetching metadata for track {} via repository", track_id);
|
||||||
let track = repository.get_item(track_id).await
|
let track = repository
|
||||||
|
.get_item(track_id)
|
||||||
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
||||||
|
|
||||||
// Check for local download first
|
// Check for local download first
|
||||||
@@ -275,7 +302,9 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get stream URL from repository (works online/offline)
|
// Get stream URL from repository (works online/offline)
|
||||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
let stream_url = repository
|
||||||
|
.get_audio_stream_url(&track.id)
|
||||||
|
.await
|
||||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
MediaSource::Remote {
|
MediaSource::Remote {
|
||||||
@@ -290,23 +319,30 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
artist: track
|
||||||
|
.album_artist
|
||||||
|
.clone()
|
||||||
|
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||||
album: track.album_name.clone(),
|
album: track.album_name.clone(),
|
||||||
album_name: track.album_name.clone(), // Frontend compatibility
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
album_id: track.album_id.clone(),
|
album_id: track.album_id.clone(),
|
||||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||||
artists: track.artists.clone(), // Fallback artist info
|
artists: track.artists.clone(), // Fallback artist info
|
||||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
playlist_id: None,
|
playlist_id: None,
|
||||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
track.album_id.as_ref().map(|album_id| {
|
track.album_id.as_ref().map(|album_id| {
|
||||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
repository.get_image_url(
|
||||||
max_width: Some(300),
|
album_id,
|
||||||
tag: Some(tag),
|
ImageType::Primary,
|
||||||
..Default::default()
|
Some(ImageOptions {
|
||||||
}))
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
@@ -339,7 +375,10 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
drop(queue_lock);
|
drop(queue_lock);
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
info!("Successfully added {} tracks to queue", request.track_ids.len());
|
info!(
|
||||||
|
"Successfully added {} tracks to queue",
|
||||||
|
request.track_ids.len()
|
||||||
|
);
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,10 +386,16 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_skip_to(
|
pub async fn player_skip_to(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
index: usize,
|
index: usize,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
|
// Prefer downloads that completed since the queue was built
|
||||||
|
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
|
||||||
|
log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Skip to the index and get the item to play
|
// Skip to the index and get the item to play
|
||||||
let item = {
|
let item = {
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
//! Remote Jellyfin session control commands (casting to another device).
|
//! Remote Jellyfin session control commands (casting to another device).
|
||||||
//!
|
//!
|
||||||
|
//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
|
||||||
|
//!
|
||||||
//! These thin command adapters forward control actions to the active Jellyfin
|
//! These thin command adapters forward control actions to the active Jellyfin
|
||||||
//! session via the player's configured `JellyfinClient`.
|
//! session via the player's configured `JellyfinClient`.
|
||||||
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use super::PlayerStateWrapper;
|
use super::PlayerStateWrapper;
|
||||||
|
use crate::jellyfin::client::LmsSyncGroup;
|
||||||
|
|
||||||
/// Play items on a remote Jellyfin session (casting)
|
/// Play items on a remote Jellyfin session (casting)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -16,22 +19,36 @@ pub async fn remote_play_on_session(
|
|||||||
item_ids: Vec<String>,
|
item_ids: Vec<String>,
|
||||||
start_index: usize,
|
start_index: usize,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
|
log::info!(
|
||||||
|
"[RemoteSession] Playing {} items on session {} (start index: {})",
|
||||||
|
item_ids.len(),
|
||||||
|
session_id,
|
||||||
|
start_index
|
||||||
|
);
|
||||||
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(client) = client_opt {
|
if let Some(client) = client_opt {
|
||||||
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
||||||
client.play_on_session(session_id, item_ids, start_index, None).await?;
|
client
|
||||||
|
.play_on_session(session_id, item_ids, start_index, None)
|
||||||
|
.await?;
|
||||||
log::info!("[RemoteSession] Successfully started playback on remote session");
|
log::info!("[RemoteSession] Successfully started playback on remote session");
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
||||||
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
|
Err(
|
||||||
|
"Jellyfin client not configured - please restart the app or log out and log back in"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +60,19 @@ pub async fn remote_send_command(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
command: String,
|
command: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
|
log::info!(
|
||||||
|
"[RemoteSession] Sending command '{}' to session {}",
|
||||||
|
command,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(client) = client_opt {
|
if let Some(client) = client_opt {
|
||||||
@@ -67,11 +92,19 @@ pub async fn remote_session_seek(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
position_ticks: i64,
|
position_ticks: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
|
log::info!(
|
||||||
|
"[RemoteSession] Seeking to {} ticks on session {}",
|
||||||
|
position_ticks,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(client) = client_opt {
|
if let Some(client) = client_opt {
|
||||||
@@ -91,11 +124,19 @@ pub async fn remote_session_set_volume(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
volume: i32,
|
volume: i32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
|
log::info!(
|
||||||
|
"[RemoteSession] Setting volume to {} on session {}",
|
||||||
|
volume,
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(client) = client_opt {
|
if let Some(client) = client_opt {
|
||||||
@@ -118,7 +159,11 @@ pub async fn remote_session_toggle_mute(
|
|||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(client) = client_opt {
|
if let Some(client) = client_opt {
|
||||||
@@ -129,3 +174,112 @@ pub async fn remote_session_toggle_mute(
|
|||||||
Err("Jellyfin client not configured".to_string())
|
Err("Jellyfin client not configured".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
|
||||||
|
//
|
||||||
|
// The frontend addresses LMS players by MAC address, which it derives from a
|
||||||
|
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
|
||||||
|
// plugin REST API via the configured JellyfinClient.
|
||||||
|
|
||||||
|
/// List current LMS sync groups.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_get_sync_groups(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<Vec<LmsSyncGroup>, String> {
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_get_sync_groups().await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
|
||||||
|
/// `slave_macs` zones join it in sync.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_create_sync_group(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
master_mac: String,
|
||||||
|
slave_macs: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!(
|
||||||
|
"[LmsSync] Fusing zones: master={}, slaves={:?}",
|
||||||
|
master_mac,
|
||||||
|
slave_macs
|
||||||
|
);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_create_sync_group(&master_mac, slave_macs).await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a single LMS zone from its sync group (decouple one player).
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_unsync_player(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
mac: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[LmsSync] Decoupling zone {}", mac);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_unsync_player(&mac).await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_dissolve_sync_group(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
master_mac: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller
|
||||||
|
.jellyfin_client()
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_dissolve_sync_group(&master_mac).await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||