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)
|
||||
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-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
||||
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
||||
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
|
||||
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
|
||||
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
|
||||
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
|
||||
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done |
|
||||
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
||||
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
||||
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
||||
|
||||
---
|
||||
|
||||
@@ -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-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
||||
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
|
||||
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
|
||||
### 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-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
||||
|
||||
### 2.3 Development Requirements
|
||||
|
||||
@@ -180,6 +193,16 @@ Internal architecture, components, and application logic.
|
||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
||||
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
||||
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
||||
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
|
||||
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
|
||||
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
|
||||
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
|
||||
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
|
||||
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
|
||||
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done |
|
||||
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
||||
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +221,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||
| UR-010 | IR-012, IR-021 | DR-037 |
|
||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||
| UR-012 | IR-009, IR-014 | - |
|
||||
| UR-013 | IR-013 | DR-017 |
|
||||
@@ -228,6 +251,14 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
|
||||
---
|
||||
|
||||
@@ -295,6 +326,9 @@ Internal architecture, components, and application logic.
|
||||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
||||
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
||||
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||
|
||||
### 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-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
flowchart TB
|
||||
VideoPlaying[Video Playing] --> Background{User Action}
|
||||
Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
|
||||
|
||||
Background -->|Home Button| AutoPause[Automatically Pause]
|
||||
Background -->|Screen Lock| AutoPause
|
||||
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification (§9.1)]
|
||||
|
||||
AutoPause --> SaveProgress[Save Progress]
|
||||
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
||||
Gate -->|Yes| Mode{Background mode armed?}
|
||||
|
||||
ShowNotification --> UserReturn{User Returns?}
|
||||
Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView <video> torn down,<br/>video decode stops, audio continues]
|
||||
Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
|
||||
|
||||
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
|
||||
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
||||
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> (reflects live player state)]
|
||||
|
||||
ResumeVideo --> AskResume[Resume from Saved Position]
|
||||
PiPWindow --> PiPReturn{User action}
|
||||
PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
|
||||
PiPReturn -->|Close window| Stop[Playback stops]
|
||||
|
||||
Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
|
||||
- **Video-only gate.** Auto-PiP is guarded by the native `canEnterPip` check
|
||||
(local video surface actively rendering). Audio playback and menu/library
|
||||
browsing background normally; remote/cast sessions render nothing locally, so
|
||||
a PiP window would be an empty box and is refused. *(UR-041, IR-026)*
|
||||
- **Only one background behaviour at a time.** The background-audio toggle
|
||||
(UR-040) disarms auto-PiP while it is on, so a video is either handed to the
|
||||
audio service *or* floated in PiP, never both.
|
||||
- **PiP controls track the player.** The play/pause RemoteAction in the PiP
|
||||
window reflects the live player state and updates on every playback-state
|
||||
change, not only when the button is pressed. *(DR-053)*
|
||||
- **Non-disruptive transition.** ExoPlayer keeps rendering into the same
|
||||
surface across enter/exit, so entering or leaving PiP never interrupts the
|
||||
video; on exit the surface is re-fit to full-screen bounds. *(DR-053)*
|
||||
|
||||
**PiP window (Android):**
|
||||
```
|
||||
┌───────────────────┐
|
||||
│ │
|
||||
│ ▶ video frame │
|
||||
│ │
|
||||
│ [⏸] │ ← play/pause RemoteAction
|
||||
└───────────────────┘
|
||||
sized to the video's aspect ratio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
"tauri": "tauri",
|
||||
"traces": "bun run scripts/extract-traces.ts",
|
||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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