Files
jellytau/docs/specs/video-background-audio.md
T
dtourolle 4e6ab017d4 docs: add mdBook docs-site, publish workflow, and release-notes tooling
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
2026-07-22 21:51:56 +02:00

234 lines
11 KiB
Markdown

# 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.