android-picture-in-picture #12
@@ -0,0 +1,125 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- 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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
+36
-1
@@ -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.
|
||||||
+45
-10
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-1
@@ -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": {
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user