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.
11 KiB
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). 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):
-
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 around theplayerPlayItemresponse handling (useHtml5Elementis forcedtrue, native backend is stopped). So "video decoding" == the WebView<video>element, and the WebView is what Android suspends on background. -
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+JellyTauPlayer(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).
- 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
- Auto-PiP is suppressed (see "Interaction with PiP").
- The WebView
<video>is paused and its decode stopped (release the media source so the decoder is freed, not merelypause()). - Native audio-only playback of the same item starts at the current position,
through
JellyTauPlaybackService(foreground notification + lockscreen controls via the existingMediaSessionCompat). - 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
- Native audio playback stops; its final position is captured.
- WebView
<video>reloads/resumes at that position and continues as normal audiovisual playback. - 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 →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 requiresisPlayingVideo()on the native ExoPlayer, but video plays via the WebView, so nativeisPlayingVideo()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 and
get_audio_stream_url) 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+JellyTauPlayeraudio 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 lockedAutoplayDecisionto aletbefore 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.
- background + ON →
- Follow the native-mode pitfall (memory:
videoplayer-native-mode-pitfalls): no lifecycle calls after anawaitinonMount. 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/isPlayingVideoquestion). - 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).
- Confirm what actually triggers today's auto-PiP given video is on the
WebView (resolve the
- 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 existingVideoPlayer.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
- 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.)
- 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.)
- Subtitles are irrelevant in audio-only mode but must restore on
foreground — confirm they survive the
<video>teardown/reload. - Exact Android lifecycle signal for "screen locked" vs "app backgrounded"
—
onUserLeaveHintcovers 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.