docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning.
This commit is contained in:
@@ -247,6 +247,184 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
}
|
||||
```
|
||||
|
||||
### Audio settings on ExoPlayer
|
||||
|
||||
**TRACES**: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
|
||||
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body. For a
|
||||
long time `ExoPlayerBackend` took that default, so Settings › Audio rendered
|
||||
controls that silently did nothing on Android — the parity gap recorded in
|
||||
[requirements.md](../requirements.md#platform-playback-backend-parity-linux-vs-android),
|
||||
now closed.
|
||||
|
||||
The settings cross to Kotlin as **JSON over JNI**, not as a wide signature, so new
|
||||
fields do not change the method signature — the same approach `load()` uses for
|
||||
subtitles:
|
||||
|
||||
```rust
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let json = audio_settings_jni_payload(settings)?;
|
||||
env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?;
|
||||
// Store the sanitised form, so audio_settings() reflects what was applied.
|
||||
self.shared_state.lock_safe().audio_settings =
|
||||
settings.clone().with_crossfade_clamped().with_equalizer_normalised();
|
||||
}
|
||||
```
|
||||
|
||||
Kotlin owns the *mechanics* — attaching `AudioEffect`s to the audio session — while
|
||||
the canonical band layout and preset curves stay in Rust:
|
||||
|
||||
| Feature | Android mechanism | Notes |
|
||||
|---------|-------------------|-------|
|
||||
| Gapless | `pauseAtEndOfMediaItems` | |
|
||||
| Volume normalization | `LoudnessEnhancer` | A gain stage — approximate next to MPV's `dynaudnorm` |
|
||||
| Equalizer | `android.media.audiofx.Equalizer` | The canonical 10 bands are resampled onto the device's own band centres |
|
||||
| Crossfade | — | Unimplemented on **every** platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap |
|
||||
|
||||
Two things are deliberately still open: the effects are **not yet verified on a
|
||||
physical device** (`AudioEffect` availability and band layouts are device-specific),
|
||||
and the trait default is still a silent `Ok(())` rather than an error, so a backend
|
||||
that omits the method still reports success. Flipping that default waits on the
|
||||
device verification.
|
||||
|
||||
### The equalizer, and where its vocabulary lives
|
||||
|
||||
**TRACES**: UR-027 | DR-030, IR-020
|
||||
|
||||
The canonical band layout (`EQ_BANDS`) and the preset curves live in
|
||||
`settings.rs`, **not** in either backend and not in the UI: a preset *is* a gain
|
||||
curve defined by the band layout, and the layout is a property of the audio
|
||||
engine rather than of the picker that renders it. Presets are Flat, Rock, Pop,
|
||||
Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within
|
||||
±8 dB) so they stack safely with volume normalization.
|
||||
|
||||
| Platform | Mechanism |
|
||||
|----------|-----------|
|
||||
| Linux | One ffmpeg two-pole peaking `equalizer` filter per band, composed by `build_af_filter` into MPV's `af` property alongside the normalization filter: `equalizer=f=31:width_type=o:width=1:g=5` |
|
||||
| Android | `android.media.audiofx.Equalizer`, with the canonical 10 bands **resampled onto whatever band centres the device actually has** |
|
||||
|
||||
Gains are normalised (`with_equalizer_normalised`) before use, and bands beyond
|
||||
`EQ_BANDS` are ignored, so a malformed settings payload cannot produce a filter
|
||||
chain of unbounded length.
|
||||
|
||||
## Background Audio Handoff (Android)
|
||||
|
||||
**TRACES**: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203
|
||||
|
||||
Keeping a video's **audio** alive when the app is backgrounded or the screen
|
||||
locks, while video decode stops. Two verified facts drive the whole design:
|
||||
|
||||
1. An Android WebView `<video>` **does not** keep playing audio once the app is
|
||||
backgrounded — the system throttles the WebView and media pauses.
|
||||
2. Keeping audio alive in the background requires a **native foreground media
|
||||
service**, which already exists for music (`JellyTauPlaybackService` +
|
||||
`JellyTauPlayer` + `MediaSessionCompat`).
|
||||
|
||||
So this is a **handoff**, not "keep the WebView alive": on background, tear down
|
||||
the current renderer and play the same item audio-only through the native
|
||||
service; on foreground, hand back. In the project's one-directional playback
|
||||
model this is a change of *which player is authoritative*, and the position must
|
||||
transfer cleanly across it.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as App backgrounded
|
||||
participant FE as VideoPlayer
|
||||
participant Rust as player_enter/exit_background_audio
|
||||
participant Exo as Native audio service
|
||||
|
||||
App-->>FE: jellytau-background (DOM CustomEvent)
|
||||
FE->>Rust: enter(item, position, audioStreamIndex)
|
||||
Rust->>Exo: play audio-only at position
|
||||
Note over Exo: lockscreen + notification, existing MediaSession
|
||||
App-->>FE: jellytau-foreground
|
||||
FE->>Rust: exit() -> final position
|
||||
Rust-->>FE: position
|
||||
FE->>FE: restart the renderer that is on screen
|
||||
```
|
||||
|
||||
Details that were each a shipped defect:
|
||||
|
||||
- **Position is absolute.** Transcoded HLS tracks time as
|
||||
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
|
||||
transcode reload). `computeHandoffPosition` sums both terms; using the element
|
||||
time alone rewinds by the offset.
|
||||
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
|
||||
stream takes the base and no seek; a handoff at 0:00 takes neither.
|
||||
- **The return must restart the renderer that is actually on screen** (DR-196).
|
||||
The two paths resume by different means — the webview `<video>` reloads off its
|
||||
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
|
||||
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
|
||||
assignment restarted nothing on the native path and left a black screen with a
|
||||
play button that did nothing.
|
||||
- **`wasPlaying` is captured on the way out** so play/pause survives the round
|
||||
trip, and the handoff does not silently rewind (DR-203).
|
||||
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
|
||||
toggle off → PiP on background, the status quo. The frontend re-asserts the
|
||||
value whenever the toggle changes and on unmount, so a stale setting cannot
|
||||
leak into the next player.
|
||||
- The pure arithmetic and state transitions live in
|
||||
`backgroundAudioHandoff.ts`, free of Svelte and the DOM, so they are testable
|
||||
without mounting the player.
|
||||
|
||||
Native signals background/foreground to the frontend as DOM CustomEvents
|
||||
(`jellytau-background` / `jellytau-foreground`); the frontend carries the toggle
|
||||
state to native through the `AndroidBackgroundAudio` bridge. No-op on every
|
||||
non-Android platform.
|
||||
|
||||
## Native Video Compositing (Android)
|
||||
|
||||
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
|
||||
|
||||
Android can render video on the **native ExoPlayer surface behind a transparent
|
||||
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
|
||||
the HTML5 `<video>` path remains the fallback and is not being removed. The
|
||||
default has been flipped and reverted twice and each revert has a named cause —
|
||||
the per-defect record is in `requirements.md` (DR-150 … DR-196).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Window["One Android window"]
|
||||
Texture["TextureView (index 0)<br/>ExoPlayer video"]
|
||||
WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
|
||||
end
|
||||
Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
|
||||
Player --> Texture
|
||||
MainActivity -->|"setTransparent(true)"| WebView
|
||||
VideoOverlayManager -->|"attach / detach"| Texture
|
||||
```
|
||||
|
||||
Load-bearing details, each of which was a shipped defect:
|
||||
|
||||
- **TextureView, not SurfaceView** (DR-192). A SurfaceView renders on its own
|
||||
layer *outside* the app window and punches a transparent hole through it;
|
||||
everything drawn above that hole — for us the whole UI — depends on that
|
||||
composition path, which Android's own documentation says does not reliably
|
||||
work. A TextureView makes "behind" ordinary view z-order within one window.
|
||||
- **Attached at index 0** by `VideoOverlayManager`, and **detached when the video
|
||||
goes** (DR-184) — a surface left in the hierarchy outlives its player.
|
||||
- **Bridges are installed before the page that uses them** (DR-183).
|
||||
`addJavascriptInterface` must run once per WebView instance and a call that
|
||||
lands after the page has loaded never reaches it, so `setTransparent(true)`
|
||||
could be dropped entirely.
|
||||
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
|
||||
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
|
||||
targeted an attribute nothing ever set, so the fix looked applied and was not.
|
||||
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
|
||||
native reveal fires on a `playing` state or a position tick carrying a position
|
||||
or duration, and on nothing else.
|
||||
- **Letterbox bars are painted**, not left holding whatever was last in the
|
||||
framebuffer (DR-194).
|
||||
- There is deliberately **no audio-focus bridge**: manual focus requests from the
|
||||
WebView competed with Chromium's `AudioFocusDelegate` and with ExoPlayer, and
|
||||
the resulting `AUDIOFOCUS_LOSS` paused playback.
|
||||
|
||||
Related Kotlin pieces in the same window: `PictureInPictureManager` (DR-160/161),
|
||||
`ScreenWakeManager` (DR-202 — Android counts its display timeout from touch
|
||||
events, which a playing video does not generate), `ImmersiveModeBridge` and
|
||||
`WindowInsetsBridge` (IR-031/DR-112 — see
|
||||
[02-svelte-frontend.md](02-svelte-frontend.md#safe-area-insets)).
|
||||
|
||||
## Android MediaSession & Remote Volume Control
|
||||
|
||||
**Location**: `JellyTauPlaybackService.kt`
|
||||
|
||||
Reference in New Issue
Block a user