Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ef231e2f | ||
|
|
cb79a376b3 | ||
|
|
b11188e9dd | ||
|
|
f636b6b151 | ||
|
|
37ffabee06 | ||
|
|
13e0860401 | ||
|
|
d1c01a6bc3 | ||
|
|
e5d3cc06f2 |
@@ -6,6 +6,63 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Audio settings now work on Android.** The equalizer, volume normalization
|
||||
and gapless playback controls in Settings › Audio previously rendered on
|
||||
Android and did nothing — `ExoPlayerBackend` was the only backend that never
|
||||
implemented `set_audio_settings`, and the trait's default silently reported
|
||||
success while applying nothing. All three now take effect:
|
||||
- **Equalizer** — the canonical 10-band ISO curve is resampled onto whatever
|
||||
bands the device's equalizer actually exposes (commonly 5), by nearest
|
||||
centre frequency.
|
||||
- **Volume normalization** — via `LoudnessEnhancer`. Note this is a gain
|
||||
stage, not a true EBU R128 normalizer like the Linux `dynaudnorm` path, so
|
||||
it approximates rather than matches Linux behaviour.
|
||||
- **Gapless playback** — honours the setting via `pauseAtEndOfMediaItems`
|
||||
(ExoPlayer is gapless by default, so this disables it when you turn it off).
|
||||
|
||||
The effects re-attach automatically when ExoPlayer rebuilds its audio sink on
|
||||
a format change, so the equalizer no longer stops applying part-way through a
|
||||
queue. (UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036, IR-004)
|
||||
|
||||
⚠️ **Not yet verified on a physical device.** `AudioEffect` availability and
|
||||
band layouts vary by device and OEM ROM; where an effect is unavailable it is
|
||||
logged and skipped rather than crashing playback.
|
||||
|
||||
### 📋 Documentation
|
||||
|
||||
- **Playback backend unification investigation.** Six new specs in
|
||||
[docs/specs/](docs/specs/) record why the playback backends cannot be unified
|
||||
onto a single engine: every candidate (mpv, GStreamer, libVLC) fails the same
|
||||
webview-compositing constraint, because WebKitGTK/WebView2/Android WebView each
|
||||
own their compositor surface and native video cannot interleave with HTML.
|
||||
Audio *can* unify; video cannot. Also specifies the Android native-video spike,
|
||||
a Windows native audio backend, and the `libmpv2` migration.
|
||||
|
||||
### 🐛 Corrected requirement statuses
|
||||
|
||||
These were documented as working and were not. No behaviour changed — the docs
|
||||
were wrong.
|
||||
|
||||
- **Crossfade (UR-031, DR-034) was marked "Done (Linux only)". It is implemented
|
||||
nowhere**, and is architecturally blocked on mpv: its audio chain is
|
||||
single-stream, and FFmpeg's `acrossfade` requires two inputs. Real crossfade
|
||||
would need two libmpv instances.
|
||||
- The platform parity matrix listed crossfade as a Linux/Android gap (it is
|
||||
neither) and omitted the equalizer (which was a genuine gap, now closed).
|
||||
- `nativeAdapter.ts` cited tauri#10152 as blocking native Android video. That
|
||||
issue is a stale feature request; the capability shipped in September 2024.
|
||||
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
||||
by a spec rather than asserted as an upstream blocker.
|
||||
|
||||
<!--
|
||||
Note: v0.1.3–v0.1.5 have no entries here. Their changes are in the git log
|
||||
and docs/traceability.md.
|
||||
-->
|
||||
|
||||
## v0.1.2
|
||||
|
||||
### ✨ Features
|
||||
|
||||
+34
-12
@@ -41,7 +41,7 @@ For a narrative overview of the system design, see
|
||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||
| UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034) |
|
||||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||||
@@ -71,6 +71,7 @@ For a narrative overview of the system design, see
|
||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -192,7 +193,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md) |
|
||||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
||||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
||||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||||
@@ -245,6 +246,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -313,6 +315,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087 |
|
||||
| UR-060 | - | DR-090, DR-091 |
|
||||
| UR-061 | - | DR-092 |
|
||||
|
||||
---
|
||||
|
||||
@@ -383,6 +386,7 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
| UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
|
||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
||||
@@ -402,6 +406,10 @@ Internal architecture, components, and application logic.
|
||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
|
||||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done |
|
||||
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done |
|
||||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -491,22 +499,36 @@ The `PlayerBackend` trait defines optional audio settings methods with default e
|
||||
| Basic playback | ✅ | ✅ | Parity |
|
||||
| Volume control | ✅ | ✅ | Parity |
|
||||
| Seek | ✅ | ✅ | Parity |
|
||||
| Crossfade | ✅ | ❌ | Gap |
|
||||
| Gapless playback | ✅ | ❌ | Gap |
|
||||
| Volume normalization | ✅ | ❌ | Gap |
|
||||
| Crossfade | ❌ | ❌ | Not implemented (blocked on MPV) |
|
||||
| Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification |
|
||||
| Volume normalization | ✅ | ⚠️ | Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification |
|
||||
| Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification |
|
||||
| Position updates | 250ms | On-demand | Inconsistent |
|
||||
|
||||
**Future Fix**:
|
||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
||||
5. Standardize position update frequency across platforms
|
||||
**Status** (see docs/specs/android-audio-settings-parity.md):
|
||||
1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
|
||||
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
|
||||
3. ✅ Volume normalization via `LoudnessEnhancer`
|
||||
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands
|
||||
resampled onto the device's band centres
|
||||
5. ⬜ **Not yet verified on a physical device** — the EQ/normalization effects
|
||||
depend on device-specific `AudioEffect` availability and band layouts
|
||||
6. ⬜ Flip the trait's `set_audio_settings` default from `Ok(())` to
|
||||
`Err(not_implemented())` so a backend that omits it fails loudly instead of
|
||||
silently reporting success. Deferred until (5) confirms the Android path works
|
||||
7. ⬜ Standardize position update frequency across platforms
|
||||
|
||||
Crossfade is deliberately absent: it is unimplemented on every platform and
|
||||
architecturally blocked on MPV, so building it on Android alone would invert the
|
||||
parity gap. (The previously suggested `ConcatenatingMediaSource` is also
|
||||
deprecated in current Media3.)
|
||||
|
||||
**Impact**:
|
||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||
- User experience differs between platforms
|
||||
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
||||
- UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now
|
||||
implemented on Android as well as Linux, pending on-device verification
|
||||
- UR-031 (Crossfade) works nowhere — see DR-034
|
||||
|
||||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Spec: Android audio settings parity (EQ, normalization, gapless)
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-031, UR-032, UR-033, UR-027 → DR-034, DR-035, DR-036, DR-030; IR-004
|
||||
**UX spec:** n/a — no UI change; Settings › Audio already renders these controls
|
||||
**Supersedes / revises:** closes the audio half of the parity gap recorded in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `set_audio_settings` / `audio_settings` on `ExoPlayerBackend` so the
|
||||
equalizer, volume normalization, and gapless playback settings actually take
|
||||
effect on Android. Today the Settings › Audio panel renders these controls on
|
||||
Android and they silently do nothing — `ExoPlayerBackend` is the only backend
|
||||
that does not override the trait's no-op defaults.
|
||||
|
||||
Crossfade is explicitly **not** included; see Out of scope.
|
||||
|
||||
## Motivation
|
||||
|
||||
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body.
|
||||
`MpvBackend`, `NullBackend`, and `WebviewAudioBackend` all override it;
|
||||
`ExoPlayerBackend` does not. The settings are persisted, pushed to the backend on
|
||||
every track load, and displayed in the UI — and then dropped on the floor.
|
||||
|
||||
This is the single most user-visible platform divergence in the app: a user who
|
||||
sets a "Rock" EQ preset on Android sees the sliders move and hears no change.
|
||||
|
||||
The backend-unification investigation ruled out fixing this by swapping engines
|
||||
(video cannot be unified; see the sibling spec), so the fix is to implement the
|
||||
trait methods where they are missing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Band count, centre frequencies, gain range, preset→curve map | Rust (existing) | Already domain-owned in `settings.rs` per [audio-equalizer.md](audio-equalizer.md). Android must consume the same `AudioSettings`, not define its own bands. Duplicating the band layout in Kotlin would be a taxonomy leak of exactly the kind `check:boundary` guards against. |
|
||||
| Mapping `AudioSettings` → Android audio-effect parameters | Rust → JNI boundary | Platform playback detail, the direct analogue of `build_af_filter` in `mpv_backend.rs`. Belongs with the other `set_audio_settings` code. |
|
||||
| Attaching/detaching `Equalizer` and `LoudnessEnhancer` to the ExoPlayer audio session | Kotlin (`JellyTauPlayer.kt`) | Android platform API mechanics; needs the live `audioSessionId`, which only the Kotlin layer holds. |
|
||||
| Normalization preset (Loud/Normal/Quiet) → target gain | Rust (existing) | `VolumeLevel` is domain vocabulary; the same preset must mean the same loudness on every platform. |
|
||||
| Rendering sliders / preset chips | Frontend (existing) | Pure presentation; unchanged by this spec. |
|
||||
|
||||
Borderline row: attaching the effects could arguably be driven entirely from
|
||||
Rust via JNI property calls. It goes to Kotlin because `AudioEffect` construction
|
||||
requires the audio session id and must be re-attached when ExoPlayer rebuilds its
|
||||
audio sink — lifecycle state that lives in `JellyTauPlayer.kt`. Rust still owns
|
||||
*what* the values are; Kotlin owns *when* the effect objects exist.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust — `ExoPlayerBackend` (`src-tauri/src/player/android/mod.rs`)
|
||||
|
||||
Override the two defaulted methods, mirroring the shape of the existing
|
||||
`set_audio_track` JNI call:
|
||||
|
||||
```rust
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let s = settings.clone().with_crossfade_clamped().with_equalizer_normalised();
|
||||
// Serialize as JSON — the same pattern load() already uses for subtitles,
|
||||
// avoiding a 6-arg JNI signature that has to change every time a field lands.
|
||||
let json = serde_json::to_string(&s).map_err(|e| PlayerError { message: e.to_string() })?;
|
||||
// Kotlin: fun setAudioSettings(json: String)
|
||||
self.call_player_method_string("setAudioSettings", &json)?;
|
||||
self.shared_state.lock_safe().audio_settings = s;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.shared_state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
```
|
||||
|
||||
`ExoPlayerState` gains an `audio_settings: AudioSettings` field. Note
|
||||
`ExoPlayerBackend` currently holds no such state — `position`/`state`/`volume` are
|
||||
all pushed in by JNI callbacks — so this is the first *pull*-side field. That is
|
||||
correct: audio settings are commanded downward, never reported upward.
|
||||
|
||||
### Kotlin — `JellyTauPlayer.kt`
|
||||
|
||||
```kotlin
|
||||
fun setAudioSettings(json: String) {
|
||||
val s = JSONObject(json)
|
||||
applyEqualizer(s.getBoolean("equalizerEnabled"), s.getJSONArray("equalizerBands"))
|
||||
applyNormalization(s.getBoolean("normalizeVolume"), s.getString("volumeLevel"))
|
||||
exoPlayer.pauseAtEndOfMediaItems = !s.getBoolean("gaplessPlayback")
|
||||
}
|
||||
```
|
||||
|
||||
Three independent mechanisms:
|
||||
|
||||
- **Gapless** — nearly free. ExoPlayer is gapless by default for compatible
|
||||
formats; honouring the setting means *disabling* it when the user turns it off,
|
||||
via `pauseAtEndOfMediaItems`. Note this only applies within a loaded playlist;
|
||||
our queue loads one item at a time, so verify behaviour before claiming DR-035
|
||||
on Android (see Testing).
|
||||
- **Equalizer** — `android.media.audiofx.Equalizer` bound to
|
||||
`exoPlayer.audioSessionId`. Android's EQ exposes a device-dependent band count
|
||||
(commonly 5) at fixed centre frequencies, which will **not** match our 10-band
|
||||
ISO layout. Rust owns the canonical 10 bands; Kotlin resamples them onto the
|
||||
device's bands by nearest-centre-frequency interpolation. Gains are in
|
||||
millibels (`setBandLevel` takes mB, we store dB → ×100), clamped to the
|
||||
device's reported `getBandLevelRange()`.
|
||||
- **Normalization** — `android.media.audiofx.LoudnessEnhancer`, also bound to the
|
||||
audio session, `setTargetGain(mB)` derived from `VolumeLevel`. This is a gain
|
||||
booster, not a true EBU R128 normalizer like MPV's `dynaudnorm`; parity is
|
||||
approximate and should be documented as such rather than overclaimed.
|
||||
|
||||
Lifecycle: build the effects lazily on first use, release them in `release()`,
|
||||
and re-attach on `onAudioSessionIdChanged` — ExoPlayer can rebuild its audio sink
|
||||
(e.g. on a format change), which invalidates effects bound to the old session.
|
||||
|
||||
### Make the silent-failure mode impossible
|
||||
|
||||
The trait's default is the root cause of this whole class of bug:
|
||||
|
||||
```rust
|
||||
// backend.rs:85 — reports success while doing nothing
|
||||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Android inherits this, so every EQ/normalization change on Android returns `Ok`
|
||||
and silently does nothing — the UI ships and has no effect, with no error anywhere.
|
||||
|
||||
Once `ExoPlayerBackend` implements the methods, **change the trait default to
|
||||
`Err(PlayerError::not_implemented())`**, matching how `set_audio_track` /
|
||||
`set_subtitle_track` already behave. Any future backend that forgets to implement
|
||||
audio settings then fails loudly instead of lying.
|
||||
|
||||
Check the call sites before flipping it: `NullBackend` overrides both methods, so
|
||||
the graceful-degradation path is unaffected, but confirm nothing treats a
|
||||
`set_audio_settings` error as fatal to playback.
|
||||
|
||||
### Re-application on track load
|
||||
|
||||
`PlayerController` already re-pushes `AudioSettings` per track on the platforms
|
||||
that implement it; the Android path inherits that for free once the trait methods
|
||||
exist. No controller change.
|
||||
|
||||
### 🔴 Threading note
|
||||
|
||||
`setAudioSettings` is invoked from Rust on whatever thread the command lands on.
|
||||
`AudioEffect` construction must not happen on the ExoPlayer application thread
|
||||
from inside a player callback — that is the re-entrancy hazard CLAUDE.md warns
|
||||
about, and the same shape as the `AutoplayDecision` deadlock. Post the work to
|
||||
the player's handler rather than doing it inline in a listener.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Crossfade (UR-031 / DR-034).** Not implemented on *any* platform today, and
|
||||
architecturally blocked on MPV (single-stream audio chain; `acrossfade` needs
|
||||
two inputs). Implementing it on Android alone would invert the parity gap. It
|
||||
needs its own spec and probably two player instances.
|
||||
- True EBU R128 normalization. `LoudnessEnhancer` is a gain stage; matching
|
||||
`dynaudnorm` exactly is out of reach without a custom `AudioProcessor`.
|
||||
- Windows audio settings — see [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `ExoPlayerBackend` overrides `set_audio_settings` and `audio_settings`.
|
||||
- [ ] EQ preset change on Android audibly changes playback; setting persists across track changes and app restart.
|
||||
- [ ] Normalization toggle audibly changes level; the three presets are ordered Loud > Normal > Quiet.
|
||||
- [ ] Disabling gapless produces a gap between consecutive tracks; enabling it does not.
|
||||
- [ ] Effects are released on `release()` and survive an audio-session rebuild.
|
||||
- [ ] `requirements.md` parity matrix updated: EQ and normalization ✅ Android.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): `set_audio_settings` stores the sanitized settings and
|
||||
`audio_settings()` returns them — assert clamping/normalisation is applied
|
||||
(crossfade clamped to 12s, band vector normalised to `EQ_BANDS.len()`). The JNI
|
||||
call itself is not unit-testable; extract the JSON serialization into a pure
|
||||
function and test that its shape matches what the Kotlin parser expects. That
|
||||
serialization contract is the part most likely to silently break.
|
||||
|
||||
**Kotlin**: the band-resampling function (10 canonical bands → N device bands) is
|
||||
pure arithmetic — extract it and unit-test it, including the degenerate cases of
|
||||
a 5-band device and a device reporting 10 bands.
|
||||
|
||||
**Manual, on device** (these are the ones that actually prove it):
|
||||
1. Set Bass Boost, play a track, confirm audible change.
|
||||
2. Toggle normalization mid-track; confirm level change without a playback stall.
|
||||
3. Queue two gapless-encoded tracks, toggle the setting, confirm the gap appears/disappears.
|
||||
4. Force a format change (44.1kHz → 48kHz track) and confirm the EQ still applies afterwards — this exercises the session-rebuild re-attach.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `ExoPlayerBackend::set_audio_settings` → `// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
||||
- Kotlin `setAudioSettings` / `applyEqualizer` / `applyNormalization` → same IDs
|
||||
- Band-resampling helper + its tests → `DR-030 | UT-xxx`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [audio-equalizer.md](audio-equalizer.md) first — it defines the canonical
|
||||
band layout and the preset→curve rule this spec consumes. Do not redefine bands
|
||||
in Kotlin.
|
||||
- Android source edits go in `src-tauri/android/src` (canonical tree), then run
|
||||
`scripts/sync-android-sources.sh`. Never edit the `gen/` tree.
|
||||
- There is a **stale duplicate** `JellyTauPlayer.kt` (285 lines) at
|
||||
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/` alongside
|
||||
the real 1103-line file at `src-tauri/android/src/main/java/...`. Edit the
|
||||
latter. Consider deleting the former as a separate change.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Spec: Android native video — transparent-webview spike
|
||||
|
||||
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
|
||||
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
|
||||
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
|
||||
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Test whether ExoPlayer's existing `SurfaceView` video path can be composited
|
||||
behind a transparent Tauri WebView on Android. If it works, Android regains
|
||||
hardware video decoding (MediaCodec) and libass-quality ASS/SSA subtitles, both
|
||||
of which the current webview path lacks. If it does not, we document why and
|
||||
delete the dead code.
|
||||
|
||||
This is a **spike**, not a feature commitment. The deliverable is a yes/no answer
|
||||
with evidence, plus either a working path behind a flag or a removal.
|
||||
|
||||
## Motivation
|
||||
|
||||
`createAdapter()` hardcodes `const effectiveKind = "html5"` and does
|
||||
`void backendKind`, discarding the `use_html5_element` value Rust computes in
|
||||
`get_player_status`. As a result:
|
||||
|
||||
- `NativePlayerAdapter` is dead code.
|
||||
- `JellyTauPlayer.kt`'s `getOrCreateSurfaceView()` — which already calls
|
||||
`setZOrderMediaOverlay(false)` and wires `setVideoSurfaceHolder` — is
|
||||
unreachable.
|
||||
- Android video decodes in the WebView instead of via MediaCodec, despite
|
||||
`CodecDetector.kt` going to the trouble of reporting hardware codec
|
||||
capabilities back to Rust for DeviceProfile generation.
|
||||
|
||||
The code comment in `nativeAdapter.ts:11-14` justifies this by citing
|
||||
tauri#10152 as an upstream blocker. **That justification is stale.**
|
||||
|
||||
### Why the blocker no longer holds
|
||||
|
||||
- tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature
|
||||
request* ("Support transparent webviews on mobile"), not a bug report about
|
||||
compositing.
|
||||
- The capability shipped in tauri commit `27d01834` (2024-09-02) — a clippy
|
||||
cleanup that moved `transparent()` out of the desktop-gated impl block, fencing
|
||||
only the tao call behind `#[cfg(desktop)]`. Because it landed as unrelated
|
||||
cleanup, nobody closed the issue.
|
||||
- The black/white-screen reports (tauri#8381, tauri#9408) were a real but
|
||||
*different* bug: a broken JNI signature for `setBackgroundColor`, fixed in
|
||||
**wry 0.39.4** (PR #1237). We ship wry 0.55.x.
|
||||
- Current wry calls `setBackgroundColor(0)` unconditionally on Android when
|
||||
transparency is requested.
|
||||
|
||||
### The honest caveat
|
||||
|
||||
**Nobody has demonstrated SurfaceView-behind-WebView on Tauri Android.** A search
|
||||
of both `tauri-apps/tauri` and `tauri-apps/wry` issues for `surfaceview` returns
|
||||
zero results, and the one native-video Tauri plugin
|
||||
(`YeonV/tauri-plugin-videoplayer`) sidesteps compositing by launching a separate
|
||||
fullscreen Activity. Nothing upstream blocks this; nothing upstream proves it.
|
||||
Hence: spike, not feature.
|
||||
|
||||
Note this is the *Android* question only. The equivalent Linux compositing
|
||||
problem is maintainer-declared unfixable and is **not** in scope — see the
|
||||
unification spec.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which video backend this platform uses | Rust (existing) | `get_player_status` already computes `use_html5_element`. The frontend must *consume* it, not decide it. Restoring that is the point of the spike. |
|
||||
| Surface creation, z-ordering, `setVideoSurfaceHolder` lifecycle | Kotlin | Android platform mechanics; already written in `JellyTauPlayer.kt`. |
|
||||
| Seek/audio-track *strategy* | Rust (existing) | Already returned by `player_seek_video` / `player_switch_audio_track`; `NativePlayerAdapter` executes the chosen primitive. Unchanged — this is exactly what the `PlayerAdapter` contract was built for. |
|
||||
| Positioning the surface under the video viewport | Frontend | Pure presentation/layout. **This is the risk area** — see Design. |
|
||||
|
||||
## Design
|
||||
|
||||
### Phase 1 — prove compositing (no app changes)
|
||||
|
||||
Before touching the adapter factory, verify the primitive works at all:
|
||||
|
||||
1. Set `"transparent": true` in `tauri.conf.json` for the Android build, plus
|
||||
`html, body { background: transparent; }`.
|
||||
2. Confirm the WebView is genuinely transparent (a native view behind it is
|
||||
visible) and that the app does not regress to a black/white screen.
|
||||
|
||||
If this fails, stop — everything downstream is moot, and the finding is that
|
||||
Tauri Android transparency is still broken in practice despite the shipped fix.
|
||||
|
||||
### Phase 2 — un-hardcode the factory
|
||||
|
||||
```ts
|
||||
// src/lib/player/adapters/index.ts
|
||||
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
|
||||
return backendKind === "native"
|
||||
? new NativePlayerAdapter(host)
|
||||
: new Html5PlayerAdapter(host, bridge);
|
||||
}
|
||||
```
|
||||
|
||||
`backendKind` comes from `get_player_status` (`VideoBackend::Native` on Android).
|
||||
Gate behind a setting — `experimentalNativeVideo`, default **off** — so a broken
|
||||
spike cannot ship as a regression. Rust already owns this decision; the flag only
|
||||
suppresses it.
|
||||
|
||||
**Also in scope: remove the user-agent sniffing in
|
||||
`src/lib/services/webviewAudio.ts:30-41`.** It re-derives which audio backend the
|
||||
platform has from `navigator.userAgent` ("matching the Rust cfg gate", per its own
|
||||
comment) — the frontend deciding a backend fact it should be told. Same root cause
|
||||
as the hardcode above, same fix: consume the value Rust already computes. Fold it
|
||||
in here rather than leaving a second, subtler copy of the bug behind. If
|
||||
`get_player_status` does not currently expose enough to cover the audio case, add
|
||||
the field — that is backend work, and correct.
|
||||
|
||||
### Phase 3 — surface positioning
|
||||
|
||||
The hard part, and where this most likely fails. The webview's `<video>` element
|
||||
occupies a laid-out box; the `SurfaceView` must be positioned to match it, and
|
||||
kept matched through scroll, rotation, and mini-player transitions.
|
||||
|
||||
Approach: the video view reports its `getBoundingClientRect()` to Rust, which
|
||||
forwards the rect to Kotlin to position the `SurfaceView`. This is the same
|
||||
"faking it" technique the ecosystem uses on desktop — acceptable here *only if*
|
||||
the video is effectively fullscreen on Android, which it is in the player route.
|
||||
|
||||
**Explicit failure criterion**: if the surface cannot be kept aligned during
|
||||
rotation or the mini-player transition without visible artefacts, the spike fails
|
||||
and we keep HTML5. Do not ship a janky native path for a codec win.
|
||||
|
||||
### What we gain if it works
|
||||
|
||||
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
||||
capabilities; the DeviceProfile would finally match what actually plays.
|
||||
- **ASS/SSA subtitles** are *not* automatic. ExoPlayer cannot render them; that
|
||||
would require libmpv, which is a separate and much larger decision (see the
|
||||
unification spec's engine comparison). Scope this spike to hardware decode
|
||||
only, and do not claim subtitle improvements from it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Linux native video. Maintainer-declared unfixable on WebKitGTK/Wayland.
|
||||
- Replacing ExoPlayer with libmpv on Android.
|
||||
- Windows native video.
|
||||
- Removing the HTML5 path. It stays as the default and the fallback.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The spike is **complete** when one of these is true:
|
||||
|
||||
**Success path**
|
||||
- [ ] Transparent WebView confirmed working on a physical device.
|
||||
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
|
||||
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
|
||||
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
|
||||
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
|
||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
|
||||
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
|
||||
|
||||
**Failure path**
|
||||
- [ ] The blocking behaviour is documented in this spec with evidence.
|
||||
- [ ] `NativePlayerAdapter` and the unreachable `SurfaceView` code are deleted, or explicitly retained with a *correct* comment.
|
||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||
|
||||
Either way:
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
Adapter-selection logic is pure and testable without a device: assert
|
||||
`createAdapter` returns `NativePlayerAdapter` for `backendKind: "native"` with
|
||||
the flag on, and `Html5PlayerAdapter` in every other combination — including that
|
||||
the flag off forces HTML5 even when Rust says native. That last case is the
|
||||
regression guard.
|
||||
|
||||
Everything else is manual on-device; there is no meaningful way to unit-test
|
||||
surface compositing. Test on at least two devices — compositing behaviour varies
|
||||
by OEM and Android version.
|
||||
|
||||
Per CLAUDE.md, if the spike turns into a bug fix (e.g. seek breaks under the
|
||||
native adapter), write the failing test first.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-023, DR-024`
|
||||
- Adapter-selection tests → `UT-xxx`
|
||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Do not skip Phase 1.** If transparency does not work, phases 2 and 3 are
|
||||
wasted effort.
|
||||
- `VideoPlayer.svelte` has a documented hazard: no lifecycle calls after an
|
||||
`await` in `onMount` — it flips to HTML5 mode and breaks Android seek. The
|
||||
adapter swap touches exactly this code path.
|
||||
- tauri-specta tagged responses keep Rust field names (`new_url`, not `newUrl`).
|
||||
- Android source edits go in `src-tauri/android/src`, then run
|
||||
`scripts/sync-android-sources.sh`.
|
||||
- A parallel Claude session may be active — `git diff` first.
|
||||
@@ -4,6 +4,7 @@
|
||||
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
|
||||
**UX spec:** n/a (extends the Settings › Audio section, ux-flows §8.1 instant-apply).
|
||||
**Supersedes / revises:** —
|
||||
**Revised by:** [android-audio-settings-parity.md](android-audio-settings-parity.md) — lifts the "Android is a no-op" limitation below.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -115,6 +116,10 @@ fields ride along. `NullBackend`/Android inherit the trait default (no-op).
|
||||
## Out of scope
|
||||
|
||||
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
|
||||
**Now specified in [android-audio-settings-parity.md](android-audio-settings-parity.md)**,
|
||||
which implements `set_audio_settings` on `ExoPlayerBackend`. The canonical band
|
||||
layout and preset→curve map defined here remain authoritative; the Android side
|
||||
resamples those bands onto the device equalizer rather than defining its own.
|
||||
- Per-track or per-library EQ profiles — one global profile only.
|
||||
- Automatic loudness/room correction; only manual bands + presets.
|
||||
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Spec: Migrate to libmpv2 and declare the project licence
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** dependency and licensing housekeeping identified in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Two related pieces of housekeeping that block or complicate later work:
|
||||
|
||||
1. Replace the abandoned `libmpv` crate (pinned to a git branch) with the
|
||||
maintained `libmpv2`.
|
||||
2. Add a `LICENSE` file. The project has none, which leaves its legal status
|
||||
undefined while it links GPL-licensed libmpv.
|
||||
|
||||
Neither changes user-visible behaviour. Both are prerequisites for
|
||||
[windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
|
||||
## Motivation
|
||||
|
||||
### The dependency is dead
|
||||
|
||||
```toml
|
||||
# src-tauri/Cargo.toml
|
||||
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
||||
```
|
||||
|
||||
- crates.io `libmpv` 2.0.1 was published **2020-09-29**.
|
||||
- The upstream repo's last commit was **2023-01-08**; nothing since was released.
|
||||
- We pin a git *branch*, so builds are not reproducible — the same lockfile-less
|
||||
checkout can resolve differently over time, and CI has no protection if the
|
||||
branch moves or the repo disappears.
|
||||
|
||||
`libmpv2` (kohsine/libmpv2-rs) is a maintained fork of exactly this crate:
|
||||
6.0.0 released **2026-05-12**, ~23.5k recent downloads against the original's
|
||||
~1.1k, releases roughly quarterly since 2024.
|
||||
|
||||
### The project has no licence
|
||||
|
||||
There is no `LICENSE`/`COPYING` file and `src-tauri/Cargo.toml` has no `license`
|
||||
field. The project is open source and will never be commercial, so this is purely
|
||||
an omission — but it matters because we link libmpv, and "no licence" defaults to
|
||||
*all rights reserved*, which is incompatible with distributing a GPL-derived
|
||||
work.
|
||||
|
||||
## Design
|
||||
|
||||
### Part 1 — licence
|
||||
|
||||
**Use GPLv3.** This is forced, not chosen:
|
||||
|
||||
- mpv's default build is **GPLv2-or-later**, so the combined work must be
|
||||
GPL-compatible.
|
||||
- Apache-2.0 is **GPLv2-incompatible** (patent-termination and indemnification
|
||||
clauses) but GPLv3-compatible.
|
||||
- A scan of the dependency tree found Apache-2.0-**only** crates with no
|
||||
alternative arm — most importantly **`tao`** (Tauri's own windowing crate),
|
||||
plus `sync_wrapper`, `gethostname`, and `ring` (Apache-2.0 AND ISC).
|
||||
|
||||
`tao` is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's
|
||||
"or later" option puts the combination at **GPLv3**.
|
||||
|
||||
Actions:
|
||||
- Add `LICENSE` containing the GPLv3 text.
|
||||
- Add `license = "GPL-3.0-or-later"` to `src-tauri/Cargo.toml` and `license` to
|
||||
`package.json`.
|
||||
- Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.
|
||||
|
||||
Because the project is open source, we use mpv's **default GPL build** — no
|
||||
`-Dgpl=false`, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis
|
||||
that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg
|
||||
filter.
|
||||
|
||||
🔴 Never build FFmpeg with `--enable-nonfree` — that produces a binary that is
|
||||
**unredistributable under any licence**, open source or not.
|
||||
|
||||
### Part 2 — libmpv → libmpv2
|
||||
|
||||
```toml
|
||||
# Linux (and later Windows, per the Windows audio spec)
|
||||
libmpv2 = "=6.0.0"
|
||||
```
|
||||
|
||||
Pin exactly: `libmpv2` has broken its API in **every** major release.
|
||||
|
||||
Breaking changes to expect, from the changelog:
|
||||
|
||||
| Version | Change | Impact here |
|
||||
|---|---|---|
|
||||
| 4.0.0 | Removed command helper methods — call `mpv.command(...)` directly | Low; we already use `command`/`set_property` |
|
||||
| 5.0.0 | Removed `mpv_node` support entirely (properties return strings; parse JSON yourself); `EventContext` folded into `Mpv`; `ProtocolContext` → `Protocol` | **Medium** — `start_event_loop` uses `create_event_context()`; check whether that call still exists |
|
||||
| 6.0.0 | `RenderContext::new()` → `Mpv::create_render_context()`; `'static` bound on `OpenGLInitParams`; render context now borrows `Mpv` (fixes a use-after-free) | **None** — we do not use the render API |
|
||||
|
||||
The last row matters: we run mpv audio-only (`video = no`), so the entire render
|
||||
surface is irrelevant to us. Consider disabling the default `render` feature to
|
||||
reduce build surface.
|
||||
|
||||
The main porting work is the event loop in `mpv_backend.rs` — `wait_event`,
|
||||
`disable_deprecated_events`, and the `FileLoaded` / `PlaybackRestart` /
|
||||
`PropertyChange` / `EndFile` handling, given 5.0.0 folded `EventContext` into
|
||||
`Mpv`.
|
||||
|
||||
Everything else — `set_property` calls, the `af` filter graph, the 250ms position
|
||||
thread, the seek-suppression window — should port unchanged.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No logic moves. This is a dependency swap plus a licence file; the
|
||||
`PlayerBackend` trait boundary is untouched.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| mpv event → `PlayerStatusEvent` mapping | Rust (unchanged) | Already correct; only the binding API beneath it changes. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any behaviour change. If playback behaves differently after this, that is a bug.
|
||||
- Windows support — separate spec, but this must land first.
|
||||
- Adopting the render API. We are audio-only on mpv.
|
||||
- Re-licensing decisions beyond adding the file the project already implies.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `LICENSE` (GPLv3) present; `license` field set in `Cargo.toml` and `package.json`.
|
||||
- [ ] A full dependency-licence audit has been run (`cargo install cargo-license && cargo license`) and confirms no GPLv3-incompatible dependency. *(The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)*
|
||||
- [ ] `libmpv` git dependency removed; `libmpv2` pinned to an exact version.
|
||||
- [ ] Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
|
||||
- [ ] Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
|
||||
- [ ] `EndFile` still emits `PlaybackEnded` only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
|
||||
- [ ] Builder image updated if the libmpv dev package requirement changed; **no toolchain install added to any CI step**.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
The existing `mpv_backend_test.rs` plus the `build_af_filter`,
|
||||
`eq_filter_entries`, and `normalize_filter_entry` tests are the regression net —
|
||||
they must pass unchanged, since none of them touch the binding API.
|
||||
|
||||
The event loop has no unit tests and is where the risk concentrates. Verify
|
||||
manually on Linux:
|
||||
|
||||
1. Play → pause → play; confirm position does not flash to 0:00 (the known
|
||||
playing-event regression).
|
||||
2. Seek mid-track; confirm no jump-to-zero within 150ms.
|
||||
3. Let a track end naturally; confirm autoplay advances (exercises `EndFile` EOF).
|
||||
4. Press stop; confirm autoplay does **not** advance.
|
||||
5. Sleep-timer expiry; confirm it stops without triggering autoplay.
|
||||
|
||||
Cases 3–5 are the ones most likely to break silently, and each corresponds to a
|
||||
bug already fixed once in this codebase.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `MpvBackend` construction / event loop → existing `// TRACES: UR-003 | IR-003`, unchanged
|
||||
- No new requirement IDs; this is a dependency migration.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do this **before** the Windows audio backend.
|
||||
- Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API
|
||||
in every major release, most recently two months before this spec.
|
||||
- The crates.io `repository` field for `libmpv2` points at `kohsine/libmpv-rs`,
|
||||
but the repo was renamed to **`libmpv2-rs`**; the old raw URLs 404.
|
||||
- `libmpv2-sys` ships pregenerated bindings and vendored headers, so no libclang
|
||||
is needed at build time — relevant to keeping the builder image thin.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,226 @@
|
||||
# Spec: Playback backend unification — findings and strategy
|
||||
|
||||
**Status:** Accepted (analysis; no code changes)
|
||||
**Requirements:** IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** informs [android-native-video-spike.md](android-native-video-spike.md), [android-audio-settings-parity.md](android-audio-settings-parity.md), [windows-native-audio-backend.md](windows-native-audio-backend.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This spec records the outcome of an investigation into unifying JellyTau's
|
||||
playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single
|
||||
engine with hardware acceleration everywhere. **The conclusion is that video
|
||||
cannot be unified onto a native engine, and should not be attempted.** Audio
|
||||
*can* be, and that is where the remaining specs direct effort.
|
||||
|
||||
No code changes follow from this spec directly. It exists so the decision is
|
||||
written down with its evidence, and so a future session does not re-run the same
|
||||
investigation.
|
||||
|
||||
## Motivation
|
||||
|
||||
The requirements doc carries a "Platform Playback Backend Parity" issue noting
|
||||
that audio settings work on Linux but not Android, and proposing eventual
|
||||
convergence. The natural next question — "should we just run one engine
|
||||
everywhere?" — needed answering before spending effort on per-backend patches.
|
||||
|
||||
The investigation also surfaced that several statements in requirements.md and in
|
||||
code comments are factually wrong. Those corrections are part of the deliverable.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. The current architecture is not what the docs describe
|
||||
|
||||
| Platform | Audio | Video |
|
||||
|----------|-------|-------|
|
||||
| Linux | MPV (native, **audio-only**) | webview `<video>` + hls.js |
|
||||
| Android | ExoPlayer (native) | **webview `<video>` + hls.js** |
|
||||
| Windows | webview `<audio>` | webview `<video>` + hls.js |
|
||||
|
||||
Two surprises:
|
||||
|
||||
- **MPV never decodes video.** `mpv_backend.rs` sets `video = no` and
|
||||
`audio-display = no` at construction. Linux video has always been the webview.
|
||||
Correspondingly, `player_play_item` deliberately does *not* load into MPV on
|
||||
Linux (it calls `set_current_item`, which only updates the queue).
|
||||
- **Android video is also the webview.** `createAdapter()` in
|
||||
`src/lib/player/adapters/index.ts` hardcodes `const effectiveKind = "html5"`
|
||||
and does `void backendKind`, discarding the `use_html5_element` signal that
|
||||
`get_player_status` computes in Rust. `NativePlayerAdapter` is dead code, and
|
||||
ExoPlayer's `SurfaceView` path in `JellyTauPlayer.kt` is unreachable.
|
||||
|
||||
So video is *already* unified — on HTML5, everywhere, by accident of that
|
||||
hardcode — and on the path without hardware decoding on Android.
|
||||
|
||||
### 2. Native video cannot be composited with a Tauri webview
|
||||
|
||||
This is the load-bearing finding. It is **not** an mpv limitation; it defeats
|
||||
every candidate engine identically:
|
||||
|
||||
- **mpv**: `tauri-plugin-libmpv`'s own platform table reads Linux ⚠️
|
||||
*"Experimental. Window embedding is not working."*
|
||||
- **GStreamer** (wry discussion #284, 2024): *"Gstreamer was rendering above the
|
||||
surface and covering all html elements."*
|
||||
- **libVLC** (tauri discussion #6343, 2024): *"I had to render the webview in a
|
||||
child window though because vlc kept rendering on top of it."*
|
||||
|
||||
Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):
|
||||
|
||||
> "we are limited to using Webkit2GTK on Linux and that requires a GTK window.
|
||||
> While possible to add a GTK widget as a child X11 window inside raw X11 window,
|
||||
> this is however a bit hacky and **it is not possible on Wayland at all**."
|
||||
|
||||
WebKitGTK, WebView2, and Android WebView each draw into their own compositor
|
||||
surface. A native video surface is either entirely above or entirely below the
|
||||
webview; it cannot interleave with HTML. Every working example in the ecosystem
|
||||
is the same hack — a separate child window position-synced to a
|
||||
`getBoundingClientRect()` div — which breaks on resize, scroll, and any UI drawn
|
||||
over the video. For JellyTau that means the controls, subtitle overlay, and
|
||||
mini-player.
|
||||
|
||||
The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:
|
||||
|
||||
> "I'm faking it and the window is not truly embedded, basically when the parent
|
||||
> moves or resizes I reset the position and size of the libmpv window to align it
|
||||
> with an HTML div."
|
||||
|
||||
**The principle to carry forward: audio can unify on a native engine; video
|
||||
cannot, because video needs a surface and the webview owns the surface.**
|
||||
|
||||
### 3. mpv would regress streaming quality
|
||||
|
||||
mpv has **no adaptive bitrate**. It delegates HLS to FFmpeg's demuxer, which
|
||||
selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR
|
||||
and it never landed. `--hls-bitrate` is a static picker defaulting to `max`.
|
||||
|
||||
The webview path already has real ABR via hls.js. Moving video to mpv would be a
|
||||
**downgrade** on every platform — no graceful degradation on weak networks, and
|
||||
quality changes requiring teardown and reload.
|
||||
|
||||
### 4. Crossfade is architecturally blocked on mpv
|
||||
|
||||
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
||||
requiring two input streams, so there is no second input to feed it. Real
|
||||
crossfade needs **two libmpv instances** with manually ramped volumes. Upstream
|
||||
maintainer response (mpv#4512, closed three minutes after opening):
|
||||
|
||||
> "No. I also find crossfading stupid and complex, so the likeliness of that
|
||||
> happening is low."
|
||||
|
||||
GStreamer *could* do it via `audiomixer`. mpv cannot, at any reasonable cost.
|
||||
|
||||
### 5. Engine comparison summary
|
||||
|
||||
| Criterion | mpv | GStreamer | libVLC |
|
||||
|-----------|-----|-----------|--------|
|
||||
| Webview compositing | ❌ Linux broken | ❌ same wall | ❌ same wall |
|
||||
| Adaptive bitrate HLS | ❌ none | ✅ adaptivedemux2 | ✅ adaptive module |
|
||||
| Rust bindings | ⚠️ `libmpv2` active; our pin is dead | ✅ `gstreamer-rs` excellent | ❌ `vlc-rs` abandoned (2018) |
|
||||
| Windows cross-MSVC | ⚠️ prebuilt DLL | ❌ pkg-config vs cargo-xwin | ❌ no better |
|
||||
| Android packaging | ✅ Maven AAR (used by Findroid) | ⚠️ Cerbero/NDK, painful | ✅ mature AAR |
|
||||
| ASS/SSA subtitles | ✅ libass built in | ✅ libass | ✅ libass |
|
||||
| Crossfade | ❌ impossible | ✅ `audiomixer` | ⚠️ unclear |
|
||||
|
||||
Every candidate fails the first row, which is the disqualifying one.
|
||||
|
||||
### 6. Two further options ruled out
|
||||
|
||||
**Webview `<audio>`/`<video>` everywhere** (i.e. delete the native audio backends
|
||||
too) is dead on Android: `navigator.mediaSession` is *deliberately compiled out*
|
||||
of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification
|
||||
control would be impossible. Chromium has also never shipped `audioTracks`. It
|
||||
remains fine for Windows *video*, which is what we already do.
|
||||
|
||||
**FFmpeg-direct / Rust-native** (`ffmpeg-next`, `rsmpeg`, Symphonia) is not
|
||||
close: the safe bindings do not expose hardware decode at all, `ffmpeg-next` is
|
||||
self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This
|
||||
is a multi-person-year path to reach parity with what we already have.
|
||||
|
||||
### 7. If libmpv is ever revisited on Android
|
||||
|
||||
Recorded so the next investigation starts from evidence rather than repeating the
|
||||
search. The `dev.jdtech.mpv:libmpv` AAR — maintained by Findroid's author, i.e.
|
||||
another Jellyfin Android client — was inspected directly:
|
||||
|
||||
- `libmpv.so` exports the full 54-function `mpv_*` C API with **zero `Java_`
|
||||
symbols**; JNI is a separate optional ~19 KB `libplayer.so`. So it is drivable
|
||||
from Rust without a Java shim. (This is precisely what disqualifies libVLC,
|
||||
whose Android video path hard-requires a Java `AWindow` jobject.)
|
||||
- ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
|
||||
- 🔴 **The published AAR is built `--enable-gpl --enable-version3` — it is
|
||||
GPLv3**, not LGPL. Fine for us (see [libmpv2-migration.md](libmpv2-migration.md)),
|
||||
but it would be a hard constraint for anyone shipping closed source, and an
|
||||
LGPL rebuild would be your own build to own.
|
||||
- Top unverified risk if anyone tries this: whether `libmpv2-sys` can
|
||||
cross-compile for `aarch64-linux-android` against that prebuilt `.so`. No
|
||||
working example of `libmpv2` on Android was found.
|
||||
|
||||
None of this changes the verdict — the cost is the MediaSession/foreground-service
|
||||
rewrite, not the bindings.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Do not unify video onto a native engine.** Video stays in the webview with
|
||||
hls.js on all platforms. This is not a compromise — it is the configuration
|
||||
that falls out of the compositing constraint, and it is the only one that
|
||||
gives us ABR for free.
|
||||
2. **Android native video is worth a bounded spike anyway** — not for
|
||||
unification, but because ExoPlayer's `SurfaceView` path already exists and
|
||||
would restore hardware decode plus ASS/SSA subtitles. See
|
||||
[android-native-video-spike.md](android-native-video-spike.md).
|
||||
3. **Audio parity is the real gap** and is achievable without touching any of the
|
||||
above. See [android-audio-settings-parity.md](android-audio-settings-parity.md)
|
||||
and [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
4. **Migrate the dead libmpv pin** regardless of any of this. See
|
||||
[libmpv2-migration.md](libmpv2-migration.md).
|
||||
|
||||
## Corrections to existing docs
|
||||
|
||||
These are factual errors found during the investigation. Fixing them is in scope
|
||||
for this spec.
|
||||
|
||||
| Location | Says | Actually |
|
||||
|----------|------|----------|
|
||||
| `requirements.md` UR-031 (line ~44) | "Done (Linux only)" | Not implemented on any platform. |
|
||||
| `requirements.md` DR-034 (line ~196) | "Done (Linux only)" | Not implemented anywhere — `mpv_backend.rs` has a bare `// TODO: Implement crossfade via MPV audio filters if needed`. Architecturally blocked on mpv (finding 4). |
|
||||
| `requirements.md` parity matrix | Crossfade ✅ Linux / ❌ Android | ❌ / ❌ |
|
||||
| `requirements.md` parity matrix | (no EQ row) | EQ is also Linux-only — `build_af_filter`/`eq_filter_entries` exist only in `mpv_backend.rs`. Same root cause, same fix. |
|
||||
| `nativeAdapter.ts:11-14` | Native Android video "blocked upstream by tauri#10152" | tauri#10152 is a stale *feature request*, dead since 2024-07-01. The capability shipped in tauri commit `27d01834` (2024-09-02). Not a blocker. |
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No new logic. The one boundary observation worth recording:
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which video backend a platform uses (`use_html5_element`) | Rust | Already correctly computed in `get_player_status`. The frontend currently *discards* it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any code change. This spec is analysis; the sibling specs carry the work.
|
||||
- iOS/macOS. Not current targets.
|
||||
- Replacing hls.js.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `requirements.md` DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
|
||||
- [ ] Stale tauri#10152 comment in `nativeAdapter.ts` corrected.
|
||||
- [ ] The four sibling specs exist and are linked from here.
|
||||
|
||||
## Testing
|
||||
|
||||
n/a — documentation only.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new code. Requirement text changes only; DR-034's status line is the one
|
||||
substantive edit.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- The evidence above was gathered in July 2026. The compositing constraint has
|
||||
been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is
|
||||
unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284
|
||||
are the threads to re-read first.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Spec: Playback documentation corrections
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** revises the status of DR-034; corrects the parity matrix in [requirements.md](../requirements.md)
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** implements the "Corrections to existing docs" section of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Fix four factual errors in the requirements doc and the player source comments,
|
||||
all found while investigating backend unification. Each claims something the code
|
||||
does not do. Small change, but they are actively misleading: two of them assert a
|
||||
feature is implemented when it is implemented nowhere, and one cites an upstream
|
||||
blocker that no longer exists.
|
||||
|
||||
Documentation and comments only — no behaviour change.
|
||||
|
||||
## Motivation
|
||||
|
||||
These errors compound. DR-034 reads "Done (Linux only)", so a future session
|
||||
planning Android parity would reasonably assume crossfade exists on Linux and
|
||||
only needs porting — when in fact it is unimplemented everywhere *and*
|
||||
architecturally blocked on the engine it supposedly runs on. Likewise the
|
||||
tauri#10152 comment has been discouraging work on Android native video since the
|
||||
upstream capability shipped in September 2024.
|
||||
|
||||
## The corrections
|
||||
|
||||
### 1. DR-034 status is wrong
|
||||
|
||||
`requirements.md` line ~196:
|
||||
|
||||
```
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||
```
|
||||
|
||||
The code:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/player/mpv_backend.rs, in set_audio_settings
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
```
|
||||
|
||||
That is the entire crossfade implementation. `crossfade_duration` is plumbed
|
||||
through `AudioSettings` and clamped to 0–12s, but no backend ever acts on it.
|
||||
|
||||
**Change to:** `Not implemented (blocked on MPV — see playback-backend-unification.md)`
|
||||
|
||||
Worth stating *why* in the requirements entry, because it is not a scheduling
|
||||
gap: mpv's audio chain is single-stream, and FFmpeg's `acrossfade` is an `N→A`
|
||||
filter needing two inputs. Real crossfade requires two libmpv instances with
|
||||
manually ramped volumes. Upstream declined the feature (mpv#4512).
|
||||
|
||||
### 1b. UR-031 status is wrong for the same reason
|
||||
|
||||
`requirements.md` line ~44:
|
||||
|
||||
```
|
||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||
```
|
||||
|
||||
Same error one level up: the *user* requirement is also marked done. Since no
|
||||
backend implements crossfade, UR-031 is not satisfied on any platform.
|
||||
|
||||
**Change to:** `Not implemented (blocked — see DR-034)`
|
||||
|
||||
Note line ~517 of the same file (`UR-031 (Crossfade), UR-032 (Gapless),
|
||||
UR-033 (Normalization) only work on Linux`) inherits the error — crossfade works
|
||||
nowhere, so it should read UR-032/UR-033 only.
|
||||
|
||||
### 2. Parity matrix crossfade row is wrong
|
||||
|
||||
```
|
||||
| Crossfade | ✅ | ❌ | Gap |
|
||||
```
|
||||
|
||||
**Change to** `| Crossfade | ❌ | ❌ | Not implemented |` — it is not a
|
||||
platform-parity gap, it is an unbuilt feature.
|
||||
|
||||
### 3. Parity matrix is missing the equalizer
|
||||
|
||||
The matrix lists crossfade, gapless, and normalization but omits the EQ, which
|
||||
has the same Linux-only shape and the same root cause (`ExoPlayerBackend` not
|
||||
overriding `set_audio_settings`). `build_af_filter` and `eq_filter_entries` exist
|
||||
only in `mpv_backend.rs`; there is no equalizer code in the Android tree.
|
||||
|
||||
**Add:** `| Equalizer (10-band) | ✅ | ❌ | Gap |`
|
||||
|
||||
### 4. `nativeAdapter.ts` cites a stale blocker
|
||||
|
||||
`src/lib/player/adapters/nativeAdapter.ts:11-14` states native Android video is
|
||||
blocked upstream by tauri#10152 (transparent webview / SurfaceView compositing).
|
||||
|
||||
tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature request*
|
||||
that `WebviewWindowBuilder::transparent` was desktop-only — not a report that
|
||||
compositing is broken. The capability shipped in tauri commit `27d01834`
|
||||
(2024-09-02), which moved `transparent()` into the cross-platform impl block with
|
||||
only the tao call `#[cfg(desktop)]`-fenced. It landed as a clippy cleanup, so the
|
||||
issue was never closed. Separately, the black/white-screen bug (tauri#8381,
|
||||
tauri#9408) was a broken JNI signature for `setBackgroundColor`, fixed in wry
|
||||
0.39.4; we ship wry 0.55.x.
|
||||
|
||||
**Change to:** a comment stating the adapter is currently unreachable because
|
||||
`createAdapter` hardcodes the HTML5 kind, that transparency is no longer an
|
||||
upstream blocker, and that
|
||||
[android-native-video-spike.md](android-native-video-spike.md) tracks whether
|
||||
SurfaceView compositing actually works. Be explicit that *nobody has
|
||||
demonstrated* SurfaceView-behind-WebView on Tauri Android — nothing upstream
|
||||
blocks it, and nothing upstream proves it.
|
||||
|
||||
### 5. Platform capability is signalled three incompatible ways
|
||||
|
||||
Not a doc error — a real inconsistency found during the same investigation, worth
|
||||
recording here even though fixing it needs its own change.
|
||||
|
||||
Which backend a platform uses is currently expressed three ways:
|
||||
|
||||
1. Rust `#[cfg]` gates in `player/mod.rs` and `create_player_backend` — the truth.
|
||||
2. The `useHtml5Element` / `VideoBackend` value from `get_player_status` — which
|
||||
the frontend discards (see the spike spec).
|
||||
3. **Frontend user-agent sniffing** in `src/lib/services/webviewAudio.ts:30-41`:
|
||||
|
||||
```ts
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isAndroid = ua.includes("android");
|
||||
const isLinux = ua.includes("linux") && !isAndroid;
|
||||
return !isAndroid && !isLinux;
|
||||
```
|
||||
|
||||
The comment says it is "matching the Rust cfg gate" — i.e. the frontend
|
||||
re-derives a backend decision from the user-agent string and hopes it stays in
|
||||
sync. That is the frontend deciding *which backend exists*, which is domain
|
||||
knowledge, not presentation. It also breaks silently the moment a new target is
|
||||
added or a webview's UA changes.
|
||||
|
||||
**This is a boundary leak of the same family the spec-review checklist exists to
|
||||
catch**, even though `check:boundary`'s tripwire (item-type arrays) does not
|
||||
match it. Rust already computes the answer; the frontend should consume it.
|
||||
|
||||
Not fixed by this spec — it is behavioural, not documentation. It should be
|
||||
folded into the spike spec's factory rework, where the same
|
||||
"consume Rust's decision instead of re-deriving it" change is already in scope.
|
||||
|
||||
### Also worth fixing while here
|
||||
|
||||
`requirements.md` IR-004 reads "In Progress (basic playback works, audio settings
|
||||
missing)". That stays accurate until
|
||||
[android-audio-settings-parity.md](android-audio-settings-parity.md) lands, but
|
||||
the "Future Fix" list in the parity issue proposes
|
||||
`ConcatenatingMediaSource` for crossfade — **deprecated in current Media3**. Drop
|
||||
that suggestion; the modern approach is a custom `AudioProcessor`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No logic. Documentation and comments only.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| — | — | No logic introduced or moved by this spec. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Implementing crossfade. This spec only stops claiming it exists.
|
||||
- Implementing Android audio settings — see the parity spec.
|
||||
- Running the Android video spike — see that spec.
|
||||
- Rewriting the architecture docs. `docs/architecture/05-platform-backends.md`
|
||||
should be re-read for the same class of error, but that is a larger pass.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] DR-034 status corrected, with the blocking reason stated.
|
||||
- [ ] UR-031 status corrected (line ~44), and the "only work on Linux" line (~517) no longer lists crossfade.
|
||||
- [ ] Parity matrix: crossfade ❌/❌; equalizer row added.
|
||||
- [ ] `ConcatenatingMediaSource` suggestion removed from the "Future Fix" list.
|
||||
- [ ] `nativeAdapter.ts` comment corrected and pointing at the spike spec.
|
||||
- [ ] `bun run check` and `bun run test` pass (a comment change still touches TS).
|
||||
- [ ] `bun run traces:markdown` re-run if requirement text changed.
|
||||
|
||||
No Rust changes, so the `cargo` gates do not apply.
|
||||
|
||||
## Testing
|
||||
|
||||
None beyond the standard gates — no behaviour changes. Confirm
|
||||
`bun run traces:markdown` regenerates cleanly, since DR-034's row is referenced
|
||||
by the traceability matrix.
|
||||
|
||||
## TRACES
|
||||
|
||||
No code implementing requirements changes; no TRACES comments to add or update.
|
||||
The DR-034 row in `docs/traceability.md` will regenerate with the corrected text.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do **not** silently delete DR-034. The requirement (UR-031 crossfade) is still
|
||||
wanted; it is the *status* that is wrong. Keeping the row with an honest status
|
||||
and a reason is the point.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Spec: Windows native audio backend
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; new IR-030
|
||||
**UX spec:** n/a — Settings › Audio already renders the controls
|
||||
**Supersedes / revises:** acts on the "audio can unify, video cannot" conclusion in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Give Windows a real native audio backend instead of the current webview
|
||||
`<audio>` shim. Windows is the only platform where audio playback has no decoder
|
||||
of its own: `WebviewAudioBackend` hands a URL to a frontend `<audio>` element and
|
||||
relays transport commands. It cannot set volume, cannot apply any audio setting,
|
||||
and reports state only via DOM events.
|
||||
|
||||
Audio needs no rendering surface, so **none of the webview-compositing problems
|
||||
that block unified video apply here.** This is the cleanest available win.
|
||||
|
||||
## Motivation
|
||||
|
||||
`WebviewAudioBackend` was a deliberate stopgap ("audio-only playback for
|
||||
platforms without a native audio backend"), and it works — but it has a hard
|
||||
functional gap. From `webview_audio_backend.rs`:
|
||||
|
||||
```rust
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
// ...stores locally only; there is no ControlCommand action for volume
|
||||
}
|
||||
```
|
||||
|
||||
So volume changes never reach the element; the frontend has to observe the player
|
||||
store and apply volume itself. `set_audio_settings` likewise stores values that
|
||||
nothing consumes — EQ, normalization, and gapless are all inert on Windows.
|
||||
|
||||
Meanwhile the backend-unification investigation established that a native *audio*
|
||||
engine is unproblematic on Windows specifically: `tauri-plugin-libmpv` lists
|
||||
Windows as its **fully tested** platform (in contrast to Linux, where embedding
|
||||
is broken — but that is a *video surface* problem, which audio does not have).
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly. |
|
||||
| Applying `AudioSettings` (EQ/normalize/gapless) | Rust | Same `AudioSettings` contract as MPV/ExoPlayer; band layout and presets stay canonical in `settings.rs`. |
|
||||
| Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it. |
|
||||
| Volume | Rust | Currently broken precisely because it is split across the boundary. |
|
||||
| Rendering the player UI | Frontend | Unchanged. |
|
||||
|
||||
The strongest argument for this change is the third row. CLAUDE.md states
|
||||
playback state is one-directional with the player authoritative; on Windows that
|
||||
is currently false, and the `player_report_*` round-trip exists to paper over it.
|
||||
|
||||
## Design
|
||||
|
||||
### Engine choice
|
||||
|
||||
Two viable options; **libmpv is recommended** for consistency with the Linux
|
||||
audio backend.
|
||||
|
||||
| | libmpv | GStreamer |
|
||||
|---|---|---|
|
||||
| Windows status | ✅ `tauri-plugin-libmpv` reports fully tested | ✅ works, but… |
|
||||
| Rust bindings | `libmpv2` 6.0.0, active | `gstreamer-rs` 0.25.x, excellent |
|
||||
| Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ `gstreamer-sys` uses pkg-config, fights `cargo-xwin` |
|
||||
| Code reuse | ✅ `MpvBackend` logic is directly reusable | ❌ a second engine to learn |
|
||||
| Crossfade capable | ❌ single-stream chain | ✅ `audiomixer` |
|
||||
|
||||
libmpv wins on reuse: `MpvBackend`'s `set_audio_settings` — the `af` lavfi graph
|
||||
built by `build_af_filter`, `eq_filter_entries`, `normalize_filter_entry` — is
|
||||
platform-independent and would apply unchanged.
|
||||
|
||||
The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally
|
||||
cannot do. If crossfade becomes a priority, revisit; it would then argue for
|
||||
GStreamer on *both* Linux and Windows, which is a much larger change.
|
||||
|
||||
### Structure
|
||||
|
||||
Rename the cfg gate so `MpvBackend` is no longer Linux-only:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/player/mod.rs
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod mpv_backend;
|
||||
```
|
||||
|
||||
`MpvBackend::new` needs one platform-specific branch: `detect_audio_system()`
|
||||
currently probes `pactl`/`pw-cli`/`/proc/asound/cards` to pick an `ao`. On
|
||||
Windows the equivalent is `wasapi` (mpv's default), so the detection is a
|
||||
`#[cfg]` returning `"wasapi"` — no probing needed.
|
||||
|
||||
Everything else — the event loop, the 250ms position thread, the seek-suppression
|
||||
window, the `af` filter graph — is unchanged.
|
||||
|
||||
`WebviewAudioBackend` stays for other targets (macOS and anything else hitting
|
||||
the `not(any(...))` arm) and as the fallback if libmpv fails to initialize. The
|
||||
existing `emit_backend_init_failed` path already handles that gracefully.
|
||||
|
||||
### Build
|
||||
|
||||
`libmpv2-sys` is well-suited to cross-compilation: no pkg-config, vendored
|
||||
headers, pregenerated bindings (no libclang). It emits `cargo:rustc-link-lib=mpv`
|
||||
unconditionally, so the build must supply a linkable import library for
|
||||
`x86_64-pc-windows-msvc`.
|
||||
|
||||
Keep the `build_libmpv` feature **off** — its Unix path shells out to mpv-build
|
||||
and explicitly rejects cross-compilation.
|
||||
|
||||
🔴 Per CLAUDE.md, the prebuilt libmpv **must be added to the builder image**
|
||||
(`Dockerfile.builder` → rebuild + push via `scripts/build-builder-image.sh`), not
|
||||
installed at CI job time. `libmpv-2.dll` must also be bundled into the NSIS
|
||||
installer via `tauri.conf.json`'s resources.
|
||||
|
||||
### Verified build mechanics
|
||||
|
||||
The cross-compile path was tested hands-on from Linux (July 2026), not inferred:
|
||||
|
||||
- Neither shinchiro nor zhongfly ships an `mpv.def` or MSVC `mpv.lib` — only a
|
||||
MinGW `libmpv.dll.a`. (Several online sources claim otherwise; they are wrong.)
|
||||
- An MSVC-style import lib can be generated locally with LLVM tools only:
|
||||
`llvm-readobj --coff-exports libmpv-2.dll` → synthesize `mpv.def` →
|
||||
`llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a
|
||||
byte-identical result.
|
||||
- A real `lld-link` link against that import lib **succeeds**, and the resulting
|
||||
import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link`
|
||||
is the linker `cargo-xwin` uses, so this is the load-bearing step.
|
||||
- Linking directly against the shipped MinGW `libmpv.dll.a` **also** succeeds, so
|
||||
def-generation may be skippable — but that relies on lld's GNU-archive
|
||||
tolerance rather than a documented contract. Keep `llvm-dlltool` as the
|
||||
fallback.
|
||||
- MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64
|
||||
Windows calling convention is platform-defined. The upstream note that MSVC
|
||||
cannot *build* mpv is frequently misread as "MSVC cannot *link* libmpv" — that
|
||||
is not what it says.
|
||||
- 🔴 Never free/realloc across the DLL boundary — use `mpv_free`.
|
||||
|
||||
Build wiring is ordinary: `cargo:rustc-link-lib=dylib=mpv` plus
|
||||
`cargo:rustc-link-search`. Nothing about libmpv conflicts with `cargo-xwin`.
|
||||
|
||||
### Size and shipping
|
||||
|
||||
Measured uncompressed: **93 MiB** (zhongfly `mpv-dev-lgpl-x86_64`) vs **112 MiB**
|
||||
(shinchiro, full GPL build); ~26–30 MB compressed in the `.7z`.
|
||||
|
||||
**Ship the zhongfly LGPL build** — smaller, and there is no reason to pull the
|
||||
GPL variant in for an audio-only use.
|
||||
|
||||
Import-table inspection confirms **no companion DLLs are needed**: every
|
||||
dependency is a system DLL (`KERNEL32`, `USER32`, `d2d1`, `DWrite`, `OPENGL32`,
|
||||
`vulkan-1`, UCRT `api-ms-win-*`). One file to bundle.
|
||||
|
||||
93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv
|
||||
audio-only, investigate whether a pruned build (no video decoders, no libplacebo)
|
||||
is worth producing for the builder image — but treat that as an optimization,
|
||||
not a blocker.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Windows *video*. Stays in WebView2 + hls.js — it works and has ABR.
|
||||
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
|
||||
- Replacing `WebviewAudioBackend` for macOS.
|
||||
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is emitted (not a crash) if libmpv is unavailable.
|
||||
- [ ] Volume control works from the UI — the current hard gap.
|
||||
- [ ] EQ, normalization, and gapless audibly take effect on Windows.
|
||||
- [ ] Position/state originate in Rust; the `<audio>` element is no longer in the audio path.
|
||||
- [ ] Seek, next/previous, and queue advance work; sleep timer stops playback.
|
||||
- [ ] `libmpv-2.dll` ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
|
||||
- [ ] Builder image carries the Windows libmpv artefacts; **no toolchain install added to any CI step**.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**: the existing `mpv_backend_test.rs` and the `build_af_filter` /
|
||||
`normalize_filter_entry` / `eq_filter_entries` unit tests already cover the
|
||||
filter-graph logic and are platform-independent — they should pass unchanged
|
||||
under a Windows `cargo check`/test. Add a test asserting `detect_audio_system()`
|
||||
returns `wasapi` under `cfg(windows)`.
|
||||
|
||||
**Manual, on Windows**: volume, EQ preset change, normalization toggle, gapless
|
||||
between two tracks, seek, queue advance, sleep timer. Then the packaging test —
|
||||
install the NSIS output on a clean VM and confirm it launches and plays.
|
||||
|
||||
Per CLAUDE.md, the volume gap is a *bug fix*: write a failing test for
|
||||
"`set_volume` reaches the backend" before implementing.
|
||||
|
||||
## TRACES
|
||||
|
||||
- Windows `MpvBackend` construction in `create_player_backend` → `// TRACES: UR-003 | IR-030`
|
||||
- `detect_audio_system` Windows branch → `IR-030`
|
||||
- Existing `set_audio_settings` gains Windows coverage → `UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
||||
- Allocate **IR-030** in `requirements.md` ("libmpv integration for Windows audio playback").
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do this **after** [libmpv2-migration.md](libmpv2-migration.md) — porting the
|
||||
current dead `libmpv` git pin to a second platform would double the migration
|
||||
work.
|
||||
- `libmpv2` has broken its API in every major release (4.0 removed command
|
||||
helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an
|
||||
exact version.
|
||||
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does
|
||||
not need it, and disabling the default `render` feature may shrink the build.
|
||||
- A parallel Claude session may be active — `git diff` first.
|
||||
+872
-714
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -346,10 +346,12 @@ flowchart TB
|
||||
**User Interaction:**
|
||||
- **Tap screen:** Controls reappear for 3 seconds
|
||||
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
||||
- **Double tap right side:** Forward 30 seconds (shows animated feedback with "+30" indicator)
|
||||
- **Single tap play/pause is deferred** by the 300 ms double-tap window, so a double tap
|
||||
skips without also toggling pause (UR-061)
|
||||
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
||||
- **Keyboard arrows:** ← rewind 10s, → forward 30s (desktop/external keyboard)
|
||||
- **Keyboard space/K:** Toggle play/pause
|
||||
- **Keyboard F:** Toggle fullscreen
|
||||
- **Pinch:** Zoom (planned)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var configAttempts = 0
|
||||
private val maxConfigAttempts = 10
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
|
||||
/**
|
||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
|
||||
*/
|
||||
private var mediaWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* The WebView the @JavascriptInterface bridges have been injected into.
|
||||
*
|
||||
* addJavascriptInterface must run once per WebView instance: re-injecting
|
||||
* over an already-loaded page hands JS a stale proxy whose methods are gone.
|
||||
* Compared by identity so a genuinely new WebView still gets its bridges.
|
||||
*/
|
||||
private var bridgesInstalledOn: WebView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
|
||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||
mediaWebView = webView
|
||||
|
||||
// Add JavaScript interface for audio focus control
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
@JavascriptInterface
|
||||
fun requestAudioFocus() {
|
||||
handler.post { this@MainActivity.requestAudioFocus() }
|
||||
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||
//
|
||||
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
||||
// every onResume (plus each WebView re-find), so this used to re-inject
|
||||
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
||||
// injected objects at page-load time; re-injecting over a live page
|
||||
// leaves JS holding a stale proxy. The object stays truthy while its
|
||||
// methods vanish, which surfaced as a flood of
|
||||
// "WebView: Unknown object" chromium errors and, in JS,
|
||||
// "TypeError: setEnabled is not a function".
|
||||
//
|
||||
// The visible bug: the background-audio toggle turned blue but never
|
||||
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
||||
// dispatched 'jellytau-background', and a locked screen killed audio
|
||||
// instantly (UR-040). Audio focus and PiP broke the same way.
|
||||
//
|
||||
// The settings/WebChromeClient work below is idempotent and must keep
|
||||
// running on resume; only the bridge injection is one-shot.
|
||||
if (webView === bridgesInstalledOn) {
|
||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||
configureWebViewSettings(webView)
|
||||
return
|
||||
}
|
||||
bridgesInstalledOn = webView
|
||||
|
||||
@JavascriptInterface
|
||||
fun abandonAudioFocus() {
|
||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
||||
}
|
||||
}, "AndroidAudioFocus")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
||||
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
|
||||
// requests from the WebView competed with Chromium's own
|
||||
// AudioFocusDelegate and with ExoPlayer, and the resulting
|
||||
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
|
||||
// in configureWebViewSettings().
|
||||
|
||||
// Add JavaScript interface for picture-in-picture control.
|
||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
|
||||
backgroundAudioEnabled = enabled
|
||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||
}
|
||||
|
||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
configureWebViewSettings(webView)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebView settings, chrome client and the video-unmute script.
|
||||
*
|
||||
* Split out from the bridge injection because this half is idempotent and
|
||||
* must re-run on every resume, whereas addJavascriptInterface must not.
|
||||
*/
|
||||
private fun configureWebViewSettings(webView: WebView) {
|
||||
try {
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
|
||||
super.onHideCustomView()
|
||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
|
||||
*
|
||||
* Without this the frontend is invisible to `adb logcat`, which makes
|
||||
* diagnosing anything that spans the JS/native boundary (the
|
||||
* background-audio handoff in particular) guesswork.
|
||||
*/
|
||||
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
|
||||
android.util.Log.d(
|
||||
"JellyTauWeb",
|
||||
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||
|
||||
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
|
||||
video.volume = 1.0;
|
||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||
|
||||
// Add event listeners to manage audio focus
|
||||
video.addEventListener('play', function() {
|
||||
console.log('[Android] Video play event - requesting audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.requestAudioFocus();
|
||||
}
|
||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
||||
});
|
||||
|
||||
video.addEventListener('pause', function() {
|
||||
console.log('[Android] Video pause event - abandoning audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.abandonAudioFocus();
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('ended', function() {
|
||||
console.log('[Android] Video ended event - abandoning audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.abandonAudioFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: deliberately no audio-focus calls here.
|
||||
//
|
||||
// WebView already manages audio focus for <video> through
|
||||
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
|
||||
// again from MainActivity made two requesters compete inside one
|
||||
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
|
||||
// (~45ms), whose handler paused playback - so arming background
|
||||
// audio, or simply pressing play, paused the video in a loop.
|
||||
//
|
||||
// ExoPlayer is the third potential owner and stays authoritative
|
||||
// for native playback (JellyTauPlayer manages its own focus).
|
||||
// Leave focus to whichever engine is actually rendering.
|
||||
video.addEventListener('volumechange', function() {
|
||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||
});
|
||||
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener { focusChange ->
|
||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||
}
|
||||
.build()
|
||||
|
||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = audioManager.requestAudioFocus(
|
||||
{ focusChange ->
|
||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||
},
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let {
|
||||
audioManager.abandonAudioFocusRequest(it)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,53 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** Position update interval in milliseconds */
|
||||
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
||||
|
||||
/** AudioEffect priority. Positive = higher priority than the default. */
|
||||
private const val EFFECT_PRIORITY = 1000
|
||||
|
||||
/**
|
||||
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
|
||||
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
|
||||
* is only the lookup table used to map those gains onto whatever bands
|
||||
* the device's equalizer actually has.
|
||||
*/
|
||||
private val CANONICAL_BAND_CENTRES_HZ =
|
||||
intArrayOf(31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000)
|
||||
|
||||
/**
|
||||
* Map canonical band gains onto a device's band centres by nearest
|
||||
* centre frequency.
|
||||
*
|
||||
* Pure function so it can be unit-tested without a device — device band
|
||||
* counts vary (commonly 5) and getting this wrong silently mis-shapes the
|
||||
* EQ curve rather than failing.
|
||||
*
|
||||
* TRACES: UR-027 | DR-030
|
||||
*/
|
||||
@JvmStatic
|
||||
fun resampleBands(
|
||||
canonicalGains: FloatArray,
|
||||
canonicalCentresHz: IntArray,
|
||||
deviceCentresHz: IntArray
|
||||
): FloatArray {
|
||||
if (canonicalGains.isEmpty() || deviceCentresHz.isEmpty()) {
|
||||
return FloatArray(deviceCentresHz.size)
|
||||
}
|
||||
val usable = minOf(canonicalGains.size, canonicalCentresHz.size)
|
||||
return FloatArray(deviceCentresHz.size) { d ->
|
||||
val target = deviceCentresHz[d]
|
||||
var nearest = 0
|
||||
var bestDelta = Int.MAX_VALUE
|
||||
for (c in 0 until usable) {
|
||||
val delta = kotlin.math.abs(canonicalCentresHz[c] - target)
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta
|
||||
nearest = c
|
||||
}
|
||||
}
|
||||
canonicalGains[nearest]
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance for JNI access */
|
||||
@Volatile
|
||||
private var instance: JellyTauPlayer? = null
|
||||
@@ -135,6 +182,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var positionUpdateJob: Job? = null
|
||||
|
||||
/** Graphic EQ bound to the current audio session, or null if not attached. */
|
||||
private var equalizer: android.media.audiofx.Equalizer? = null
|
||||
|
||||
/** Loudness/normalization effect bound to the current audio session. */
|
||||
private var loudnessEnhancer: android.media.audiofx.LoudnessEnhancer? = null
|
||||
|
||||
/**
|
||||
* Last settings pushed from Rust, replayed when the audio session is rebuilt.
|
||||
* Held as the raw payload so re-application needs no second parse contract.
|
||||
*/
|
||||
private var lastAudioSettings: org.json.JSONObject? = null
|
||||
|
||||
/** Current media ID being played */
|
||||
private var currentMediaId: String? = null
|
||||
|
||||
@@ -334,6 +393,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
|
||||
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
|
||||
// effects bound to the old session are dead. Re-attach, or the EQ
|
||||
// silently stops applying mid-queue.
|
||||
releaseAudioEffects()
|
||||
lastAudioSettings?.let { applyAudioEffects(it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -416,6 +480,133 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply audio settings pushed from Rust as JSON.
|
||||
*
|
||||
* Rust owns *what* the values are (band layout, preset curves, normalization
|
||||
* presets); this owns *when* the Android AudioEffect objects exist, since
|
||||
* that needs the live audio session id and must survive a sink rebuild.
|
||||
*
|
||||
* Posted to the main handler rather than run inline: AudioEffect construction
|
||||
* from a player callback can re-enter the player and deadlock.
|
||||
*
|
||||
* TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
*/
|
||||
fun setAudioSettings(json: String) {
|
||||
mainHandler.post {
|
||||
try {
|
||||
val settings = org.json.JSONObject(json)
|
||||
lastAudioSettings = settings
|
||||
|
||||
// Gapless: ExoPlayer is gapless by default for compatible
|
||||
// formats, so honouring the setting means disabling it when off.
|
||||
exoPlayer.pauseAtEndOfMediaItems = !settings.optBoolean("gaplessPlayback", true)
|
||||
|
||||
applyAudioEffects(settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Failed to apply audio settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach/update the EQ and loudness effects for the current audio session. */
|
||||
private fun applyAudioEffects(settings: org.json.JSONObject) {
|
||||
val sessionId = exoPlayer.audioSessionId
|
||||
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
|
||||
// No sink yet; onAudioSessionIdChanged will re-drive this.
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
applyEqualizer(sessionId, settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Equalizer unavailable on this device", e)
|
||||
}
|
||||
|
||||
try {
|
||||
applyNormalization(sessionId, settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "LoudnessEnhancer unavailable on this device", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEqualizer(sessionId: Int, settings: org.json.JSONObject) {
|
||||
val enabled = settings.optBoolean("equalizerEnabled", false)
|
||||
|
||||
if (!enabled) {
|
||||
equalizer?.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
val eq = equalizer ?: android.media.audiofx.Equalizer(EFFECT_PRIORITY, sessionId).also {
|
||||
equalizer = it
|
||||
}
|
||||
|
||||
val bandsJson = settings.optJSONArray("equalizerBands")
|
||||
val canonicalGains = FloatArray(bandsJson?.length() ?: 0) { i ->
|
||||
bandsJson!!.optDouble(i, 0.0).toFloat()
|
||||
}
|
||||
if (canonicalGains.isEmpty()) {
|
||||
eq.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// The device's band count/centres are device-dependent (commonly 5) and
|
||||
// will not match our canonical 10-band ISO layout, so resample.
|
||||
val deviceBandCount = eq.numberOfBands.toInt()
|
||||
val deviceCentresHz = IntArray(deviceBandCount) { i ->
|
||||
eq.getCenterFreq(i.toShort()) / 1000 // device reports milliHertz
|
||||
}
|
||||
val levelRange = eq.bandLevelRange // millibels, [min, max]
|
||||
|
||||
val resampled = resampleBands(canonicalGains, CANONICAL_BAND_CENTRES_HZ, deviceCentresHz)
|
||||
|
||||
for (i in 0 until deviceBandCount) {
|
||||
val millibels = (resampled[i] * 100f)
|
||||
.coerceIn(levelRange[0].toFloat(), levelRange[1].toFloat())
|
||||
eq.setBandLevel(i.toShort(), millibels.toInt().toShort())
|
||||
}
|
||||
eq.enabled = true
|
||||
}
|
||||
|
||||
private fun applyNormalization(sessionId: Int, settings: org.json.JSONObject) {
|
||||
val enabled = settings.optBoolean("normalizeVolume", false)
|
||||
|
||||
if (!enabled) {
|
||||
loudnessEnhancer?.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
val enhancer = loudnessEnhancer
|
||||
?: android.media.audiofx.LoudnessEnhancer(sessionId).also { loudnessEnhancer = it }
|
||||
|
||||
// Approximate parity with the Linux dynaudnorm path: LoudnessEnhancer is
|
||||
// a gain stage, not a true EBU R128 normalizer, so these are relative
|
||||
// offsets preserving the Loud > Normal > Quiet ordering.
|
||||
val targetGainMb = when (settings.optString("volumeLevel", "normal")) {
|
||||
"loud" -> 600
|
||||
"quiet" -> -600
|
||||
else -> 0
|
||||
}
|
||||
enhancer.setTargetGain(targetGainMb)
|
||||
enhancer.enabled = true
|
||||
}
|
||||
|
||||
private fun releaseAudioEffects() {
|
||||
try {
|
||||
equalizer?.release()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("JellyTauPlayer", "Equalizer release failed", e)
|
||||
}
|
||||
try {
|
||||
loudnessEnhancer?.release()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("JellyTauPlayer", "LoudnessEnhancer release failed", e)
|
||||
}
|
||||
equalizer = null
|
||||
loudnessEnhancer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current playback position in seconds.
|
||||
*/
|
||||
@@ -756,6 +947,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
coroutineScope.cancel()
|
||||
releaseAudioEffects()
|
||||
exoPlayer.release()
|
||||
instance = null
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
@@ -148,6 +149,10 @@ struct ExoPlayerState {
|
||||
volume: f32,
|
||||
is_loaded: bool,
|
||||
current_media: Option<MediaItem>,
|
||||
/// Last applied audio settings. Unlike the fields above (which JNI callbacks
|
||||
/// push *in*), this is commanded *out* — audio settings are never reported
|
||||
/// by the player, so this is the authoritative copy for `audio_settings()`.
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
impl ExoPlayerState {
|
||||
@@ -159,6 +164,7 @@ impl ExoPlayerState {
|
||||
volume: 1.0,
|
||||
is_loaded: false,
|
||||
current_media: None,
|
||||
audio_settings: AudioSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,6 +535,56 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
self.shared_state.lock_safe().volume
|
||||
}
|
||||
|
||||
/// Apply audio settings to ExoPlayer (equalizer, normalization, gapless).
|
||||
///
|
||||
/// Sent as JSON rather than a wide JNI signature so new fields do not change
|
||||
/// the method signature — the same approach `load()` uses for subtitles. The
|
||||
/// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio
|
||||
/// session); the canonical band layout and preset curves stay in Rust.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let json = audio_settings_jni_payload(settings).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e))
|
||||
})?;
|
||||
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let json_jstring = env.new_string(&json).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create settings string: {}", e))
|
||||
})?;
|
||||
|
||||
env.call_method(
|
||||
&self.player_ref,
|
||||
"setAudioSettings",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&json_jstring)],
|
||||
)
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e))
|
||||
})?;
|
||||
|
||||
// Store the sanitised form so audio_settings() reflects what was applied,
|
||||
// not what was requested.
|
||||
self.shared_state.lock_safe().audio_settings = settings
|
||||
.clone()
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.shared_state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
|
||||
@@ -243,6 +243,23 @@ impl PlayerController {
|
||||
self.end_reason.lock_safe().take()
|
||||
}
|
||||
|
||||
/// Record that playback is being stopped by an expiring sleep timer.
|
||||
///
|
||||
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
||||
/// Android), which lands in `on_playback_ended`. Without an end reason that
|
||||
/// reads as a natural finish and autoplay advances — defeating the timer.
|
||||
/// `UserStop` is the honest label: the stop was user-initiated, just via the
|
||||
/// timer they set rather than the stop button.
|
||||
///
|
||||
/// Takes the shared slot rather than `&self` so the sleep-timer thread —
|
||||
/// which owns clones, not the controller — records it the same way.
|
||||
///
|
||||
/// TRACES: UR-023, UR-026 | DR-029
|
||||
fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
|
||||
log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
|
||||
*end_reason.lock_safe() = Some(EndReason::UserStop);
|
||||
}
|
||||
|
||||
/// Increment autoplay episode counter. Returns true if limit is reached.
|
||||
fn increment_autoplay_count(&self) -> bool {
|
||||
let max = self.autoplay_settings.lock_safe().max_episodes;
|
||||
@@ -767,6 +784,7 @@ impl PlayerController {
|
||||
let sleep_timer = self.sleep_timer.clone();
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
let backend = self.backend.clone();
|
||||
let end_reason = self.end_reason.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
@@ -783,6 +801,14 @@ impl PlayerController {
|
||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||
timer.cancel();
|
||||
|
||||
// Mark the stop *before* it reaches the backend. Stopping
|
||||
// makes the native player fire its ended callback, and
|
||||
// cancelling the timer above means on_playback_ended can no
|
||||
// longer tell this apart from a natural end — without this
|
||||
// it would show the next-episode popup / autoplay right
|
||||
// after the sleep timer fired.
|
||||
Self::note_sleep_timer_stop(&end_reason);
|
||||
|
||||
// Emit cancelled state
|
||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||
@@ -2040,6 +2066,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A time-based sleep timer that fires mid-episode must not let the ended
|
||||
/// callback fall through to autoplay.
|
||||
///
|
||||
/// The timer thread stops the backend directly, which makes ExoPlayer emit
|
||||
/// its ended callback. That callback races the thread's own `timer.cancel()`:
|
||||
/// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
|
||||
/// so the timer branch is skipped and the episode path runs — showing a
|
||||
/// next-episode popup (or advancing) after the user's sleep timer expired.
|
||||
#[tokio::test]
|
||||
async fn test_expired_time_sleep_timer_stops_without_autoplay() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let items = create_test_items(3);
|
||||
controller.play_queue(items, 0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
// Arm a time-based timer that is already due, then let the real timer
|
||||
// thread (started in the constructor, 1s tick) observe the expiry and
|
||||
// run its stop path. Driving the actual thread is the point: the bug was
|
||||
// that this path stopped the backend without recording an end reason.
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
|
||||
|
||||
// Wait for the timer thread to process the expiry (tick is 1s).
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !controller.sleep_timer.lock_safe().is_active() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!controller.sleep_timer.lock_safe().is_active(),
|
||||
"Timer thread should have expired and cancelled the sleep timer"
|
||||
);
|
||||
|
||||
// The backend stop above makes the native player fire its ended callback.
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::Stop),
|
||||
"Expected Stop after an expired time-based sleep timer, got {:?}",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_queue_stops() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
@@ -179,10 +179,80 @@ impl VideoSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `AudioSettings` into the JSON payload handed to the Android player
|
||||
/// over JNI.
|
||||
///
|
||||
/// Sanitises first (crossfade clamped, band vector normalised) so a malformed
|
||||
/// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI
|
||||
/// signature so that adding a field does not change the method signature — the
|
||||
/// same approach `load()` already uses for subtitles.
|
||||
///
|
||||
/// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the
|
||||
/// Kotlin side matches on those literals. Both are pinned by tests.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
|
||||
let sanitised = settings
|
||||
.clone()
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
serde_json::to_string(&sanitised)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The JNI payload must sanitise before serialising: an over-long crossfade
|
||||
/// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len().
|
||||
/// Sending raw values would let a malformed vector reach the Kotlin parser.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1
|
||||
#[test]
|
||||
fn test_audio_settings_jni_payload_is_sanitised() {
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 30.0,
|
||||
equalizer_bands: vec![20.0, -30.0],
|
||||
..AudioSettings::default()
|
||||
};
|
||||
|
||||
let json = audio_settings_jni_payload(&settings).expect("serialises");
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
||||
|
||||
assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
|
||||
|
||||
let bands = v["equalizerBands"].as_array().expect("bands array");
|
||||
assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
|
||||
assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
|
||||
assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
|
||||
}
|
||||
|
||||
/// The Kotlin side parses these exact keys. camelCase is what serde emits
|
||||
/// for AudioSettings; a rename here silently breaks the Android parser,
|
||||
/// which is why the contract is pinned by a test rather than by convention.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2
|
||||
#[test]
|
||||
fn test_audio_settings_jni_payload_key_contract() {
|
||||
let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
||||
|
||||
for key in [
|
||||
"crossfadeDuration",
|
||||
"gaplessPlayback",
|
||||
"normalizeVolume",
|
||||
"volumeLevel",
|
||||
"equalizerEnabled",
|
||||
"equalizerBands",
|
||||
] {
|
||||
assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
|
||||
}
|
||||
|
||||
// VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on
|
||||
// these literals.
|
||||
assert_eq!(v["volumeLevel"], "normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_settings() {
|
||||
let settings = AudioSettings::default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -20,6 +20,14 @@
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
type TapFeedback,
|
||||
} from "./tapGestures";
|
||||
import {
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
@@ -102,11 +110,14 @@
|
||||
let touchStartX = $state(0);
|
||||
let touchStartY = $state(0);
|
||||
let touchStartTime = $state(0);
|
||||
let lastTapTime = $state(0);
|
||||
let tapGestures = createTapGestureState();
|
||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let brightness = $state(1); // 0-2, default 1
|
||||
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
|
||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target of a skip already requested but not yet reported back by the player,
|
||||
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||
let pendingSeekTarget: number | null = null;
|
||||
let swipeGestureActive = $state(false);
|
||||
|
||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||
@@ -703,6 +714,16 @@
|
||||
if (debugLogInterval) {
|
||||
clearInterval(debugLogInterval);
|
||||
}
|
||||
// A deferred single tap must not fire play/pause after teardown.
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
tapGestures.cancel();
|
||||
if (doubleTapFeedbackTimeout) {
|
||||
clearTimeout(doubleTapFeedbackTimeout);
|
||||
doubleTapFeedbackTimeout = null;
|
||||
}
|
||||
|
||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||
for (const unlisten of nativeUnlisteners) {
|
||||
@@ -1192,9 +1213,13 @@
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
if (!armed) {
|
||||
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
||||
}
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
}
|
||||
|
||||
@@ -1342,7 +1367,16 @@
|
||||
async function seekRelative(seconds: number) {
|
||||
isSeeking = true;
|
||||
|
||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||
// The facade seeks by absolute position, so resolve the delta here —
|
||||
// chaining off a still-in-flight target so rapid double taps accumulate
|
||||
// instead of all resolving against the same not-yet-updated position.
|
||||
const newTime = resolveSeekTarget({
|
||||
delta: seconds,
|
||||
reportedPosition: currentTime,
|
||||
duration,
|
||||
pendingTarget: pendingSeekTarget,
|
||||
});
|
||||
pendingSeekTarget = newTime;
|
||||
|
||||
console.log("[VideoPlayer] Relative seek:", {
|
||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||
@@ -1358,7 +1392,12 @@
|
||||
}
|
||||
} as unknown as Event;
|
||||
|
||||
try {
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
} finally {
|
||||
// The player is authoritative again from here on.
|
||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -1375,10 +1414,10 @@
|
||||
}
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
seekRelative(-10);
|
||||
seekRelative(SEEK_BACKWARD_SECONDS);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
seekRelative(10);
|
||||
seekRelative(SEEK_FORWARD_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,25 +1428,31 @@
|
||||
touchStartY = touch.clientY;
|
||||
touchStartTime = Date.now();
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastTap = now - lastTapTime;
|
||||
const outcome = registerTap(tapGestures, {
|
||||
x: touch.clientX,
|
||||
screenWidth: window.innerWidth,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
// Double tap detection (within 300ms)
|
||||
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(touch.clientX);
|
||||
lastTapTime = 0; // Reset to prevent triple-tap
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
} else {
|
||||
lastTapTime = now;
|
||||
// Set timeout to clear if no second tap
|
||||
tapTimeout = setTimeout(() => {
|
||||
lastTapTime = 0;
|
||||
}, 300);
|
||||
|
||||
if (outcome.action === "seek") {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single tap so far: defer play/pause until the double-tap window closes,
|
||||
// so a double tap seeks without also toggling pause.
|
||||
tapTimeout = setTimeout(() => {
|
||||
tapTimeout = null;
|
||||
if (tapGestures.resolvePending(Date.now())) {
|
||||
togglePlayPause();
|
||||
}
|
||||
}, outcome.pendingAfterMs);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
@@ -1422,6 +1467,13 @@
|
||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||
swipeGestureActive = true;
|
||||
|
||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
||||
tapGestures.cancel();
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
|
||||
// Brightness control on vertical swipe
|
||||
swipeType = "brightness";
|
||||
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
||||
@@ -1438,20 +1490,22 @@
|
||||
swipeType = null;
|
||||
}
|
||||
|
||||
function handleDoubleTap(x: number) {
|
||||
const screenWidth = window.innerWidth;
|
||||
const isLeftSide = x < screenWidth / 2;
|
||||
|
||||
if (isLeftSide) {
|
||||
// Double tap left: rewind 10 seconds
|
||||
seekRelative(-10);
|
||||
showDoubleTapFeedback = "left";
|
||||
} else {
|
||||
// Double tap right: forward 10 seconds
|
||||
seekRelative(10);
|
||||
showDoubleTapFeedback = "right";
|
||||
/**
|
||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
||||
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
||||
* so the compatibility click that follows a tap must be ignored here —
|
||||
* otherwise it pauses on the first tap of a double tap.
|
||||
*/
|
||||
function handleVideoClick(e: MouseEvent) {
|
||||
// A click synthesized from a touch reports no pointer movement detail.
|
||||
if (e.detail === 0 || tapTimeout !== null) return;
|
||||
togglePlayPause();
|
||||
}
|
||||
|
||||
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
|
||||
seekRelative(seekSeconds);
|
||||
showDoubleTapFeedback = feedback;
|
||||
|
||||
// Hide feedback after animation
|
||||
if (doubleTapFeedbackTimeout) {
|
||||
clearTimeout(doubleTapFeedbackTimeout);
|
||||
@@ -1612,7 +1666,7 @@
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={togglePlayPause}
|
||||
onclick={handleVideoClick}
|
||||
>
|
||||
<!-- Temporarily disabled to debug playback issues
|
||||
{#each subtitleTracks() as track}
|
||||
@@ -1665,7 +1719,7 @@
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1676,7 +1730,7 @@
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DOUBLE_TAP_WINDOW_MS,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
} from "./tapGestures";
|
||||
|
||||
const SCREEN_WIDTH = 1000;
|
||||
const LEFT = 100;
|
||||
const RIGHT = 900;
|
||||
|
||||
function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
|
||||
return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
|
||||
}
|
||||
|
||||
/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
|
||||
function asSeek(outcome: ReturnType<typeof tap>) {
|
||||
if (outcome.action !== "seek") {
|
||||
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
describe("tap gesture resolution", () => {
|
||||
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
||||
const state = createTapGestureState();
|
||||
const first = tap(state, RIGHT, 1000);
|
||||
|
||||
// The first tap must NOT immediately toggle play/pause — it may still
|
||||
// become a double tap.
|
||||
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
||||
});
|
||||
|
||||
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
|
||||
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
||||
expect(resolved).toEqual({ action: "togglePlayPause" });
|
||||
});
|
||||
|
||||
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const second = asSeek(tap(state, RIGHT, 1150));
|
||||
|
||||
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||
expect(second.seekSeconds).toBe(30);
|
||||
expect(second.feedback).toBe("right");
|
||||
|
||||
// The deferred single-tap pause must have been cancelled.
|
||||
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("seeks back 10s on a double tap on the left half", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, LEFT, 1000);
|
||||
const second = asSeek(tap(state, LEFT, 1100));
|
||||
|
||||
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||
expect(second.seekSeconds).toBe(-10);
|
||||
expect(second.feedback).toBe("left");
|
||||
});
|
||||
|
||||
it("treats a second tap after the window as a new pending single tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||
|
||||
expect(late.action).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not treat a third tap as another double tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||
|
||||
// Triple tap: the third tap starts a fresh pending tap rather than
|
||||
// seeking again off the consumed second tap.
|
||||
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
||||
});
|
||||
|
||||
it("accumulates repeated double taps on the same side", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const a = asSeek(tap(state, RIGHT, 1100));
|
||||
tap(state, RIGHT, 1200);
|
||||
const b = asSeek(tap(state, RIGHT, 1300));
|
||||
|
||||
expect(a.seekSeconds).toBe(30);
|
||||
expect(b.seekSeconds).toBe(30);
|
||||
});
|
||||
|
||||
it("uses the tap side, so a double tap split across halves follows the second tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, LEFT, 1000);
|
||||
const second = asSeek(tap(state, RIGHT, 1100));
|
||||
|
||||
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||
expect(second.feedback).toBe("right");
|
||||
});
|
||||
|
||||
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
state.cancel();
|
||||
|
||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seek target resolution", () => {
|
||||
const DURATION = 600;
|
||||
|
||||
it("adds the delta to the reported position", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
|
||||
});
|
||||
|
||||
it("clamps to zero when rewinding past the start", () => {
|
||||
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to the duration when skipping past the end", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
||||
});
|
||||
|
||||
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||
// The player has not yet reported the first seek's result, so the
|
||||
// reported position is still the pre-seek value.
|
||||
const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
|
||||
const second = resolveSeekTarget({
|
||||
delta: 30,
|
||||
reportedPosition: 100,
|
||||
duration: DURATION,
|
||||
pendingTarget: first,
|
||||
});
|
||||
|
||||
expect(second).toBe(160);
|
||||
});
|
||||
|
||||
it("ignores a pending target once the player has caught up past it", () => {
|
||||
const target = resolveSeekTarget({
|
||||
delta: 30,
|
||||
reportedPosition: 200,
|
||||
duration: DURATION,
|
||||
pendingTarget: 130,
|
||||
});
|
||||
|
||||
expect(target).toBe(230);
|
||||
});
|
||||
|
||||
it("falls back to the delta alone when duration is unknown", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tap-gesture interpretation for the video player surface.
|
||||
*
|
||||
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
||||
* a tap cannot be classified at the moment it lands, because it may still turn
|
||||
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
||||
* until the double-tap window closes, and cancelled outright if a second tap
|
||||
* arrives — otherwise a double tap both toggles pause and seeks.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||
*/
|
||||
|
||||
/** A second tap within this window makes a double tap. */
|
||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||
|
||||
/** Double tap on the right half: skip forward. */
|
||||
export const SEEK_FORWARD_SECONDS = 30;
|
||||
|
||||
/** Double tap on the left half: skip back. */
|
||||
export const SEEK_BACKWARD_SECONDS = -10;
|
||||
|
||||
export type TapFeedback = "left" | "right";
|
||||
|
||||
export type TapOutcome =
|
||||
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
||||
| { action: "pending"; pendingAfterMs: number }
|
||||
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
||||
|
||||
export interface TapInput {
|
||||
/** Tap x position, viewport pixels. */
|
||||
x: number;
|
||||
screenWidth: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface TapGestureState {
|
||||
/**
|
||||
* Resolve a still-pending single tap. Returns the play/pause action once the
|
||||
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
||||
* became a double tap, or was cancelled).
|
||||
*/
|
||||
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
||||
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
interface InternalState extends TapGestureState {
|
||||
lastTapTime: number;
|
||||
pendingSince: number | null;
|
||||
}
|
||||
|
||||
export function createTapGestureState(): TapGestureState {
|
||||
const state: InternalState = {
|
||||
lastTapTime: 0,
|
||||
pendingSince: null,
|
||||
resolvePending(now: number) {
|
||||
if (state.pendingSince === null) return null;
|
||||
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
||||
state.pendingSince = null;
|
||||
return { action: "togglePlayPause" };
|
||||
},
|
||||
cancel() {
|
||||
state.pendingSince = null;
|
||||
state.lastTapTime = 0;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
||||
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
||||
* the window returns the seek and clears the pending play/pause.
|
||||
*/
|
||||
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||
const s = state as InternalState;
|
||||
const sinceLastTap = input.now - s.lastTapTime;
|
||||
|
||||
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||
// Second tap: cancel the deferred play/pause and seek instead.
|
||||
s.pendingSince = null;
|
||||
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
||||
const isLeftSide = input.x < input.screenWidth / 2;
|
||||
return isLeftSide
|
||||
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
||||
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
||||
}
|
||||
|
||||
s.lastTapTime = input.now;
|
||||
s.pendingSince = input.now;
|
||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||
}
|
||||
|
||||
export interface SeekTargetInput {
|
||||
/** Relative offset in seconds (negative rewinds). */
|
||||
delta: number;
|
||||
/** Latest position reported by the player — the authoritative source. */
|
||||
reportedPosition: number;
|
||||
/** Media duration; 0/unknown disables the upper clamp. */
|
||||
duration: number;
|
||||
/**
|
||||
* Target of a seek already requested but not yet reflected in
|
||||
* `reportedPosition`. Consecutive double taps chain off this so they add up
|
||||
* instead of all resolving against the same stale position.
|
||||
*/
|
||||
pendingTarget?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative skip to the absolute position the facade expects.
|
||||
*
|
||||
* The player facade seeks by absolute position only (the backend picks the seek
|
||||
* strategy), so the delta is applied here — against the pending target when one
|
||||
* is still in flight and still ahead of what the player has reported.
|
||||
*/
|
||||
export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||
|
||||
const base =
|
||||
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||
? pendingTarget
|
||||
: reportedPosition;
|
||||
|
||||
const target = base + delta;
|
||||
if (target < 0) return 0;
|
||||
if (duration > 0 && target > duration) return duration;
|
||||
return target;
|
||||
}
|
||||
@@ -7,11 +7,19 @@
|
||||
* hls.js. State reporting is unnecessary here because the native backend emits
|
||||
* events directly — the adapter's job is only to forward control intents.
|
||||
*
|
||||
* NOTE: On current Tauri, native Android video rendering is blocked upstream
|
||||
* (transparent webview / SurfaceView compositing — tauri#10152), so video on
|
||||
* Android currently runs through the HTML5 adapter via the interim override in
|
||||
* the factory. This adapter exists for the audio/native path and for when that
|
||||
* upstream limitation is resolved.
|
||||
* NOTE: This adapter is currently unreachable — `createAdapter()` hardcodes the
|
||||
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
|
||||
*
|
||||
* That override was introduced citing tauri#10152 as an upstream blocker. That
|
||||
* is no longer accurate: #10152 is a stale *feature request* (dead since
|
||||
* 2024-07-01) asking that `transparent` not be desktop-only, and the capability
|
||||
* shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen
|
||||
* bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor,
|
||||
* fixed in wry 0.39.4; we ship wry 0.55.x.
|
||||
*
|
||||
* What is genuinely unproven is SurfaceView-behind-WebView *compositing* on
|
||||
* Tauri Android — nothing upstream blocks it, and nothing upstream demonstrates
|
||||
* it either. docs/specs/android-native-video-spike.md tracks that experiment.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-028
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setBackgroundAudioEnabled } from "./backgroundAudio";
|
||||
|
||||
/**
|
||||
* Bridge-reporting contract for the background-audio toggle.
|
||||
*
|
||||
* TRACES: UR-040 | IR-025, DR-051 | UT-062
|
||||
*
|
||||
* Regression guard for the "screen lock kills video audio" bug: MainActivity
|
||||
* re-ran configureWebViewForMedia() on every onResume, re-calling
|
||||
* addJavascriptInterface over a live page. WebView then served a stale proxy —
|
||||
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
|
||||
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
|
||||
*
|
||||
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
|
||||
* a try/catch returning void, so the UI showed "armed" while native never got
|
||||
* the flag — and onStop's `if (backgroundAudioEnabled)` guard never dispatched
|
||||
* `jellytau-background`. Audio died the instant the screen locked.
|
||||
*
|
||||
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
|
||||
* reached, so a dead bridge can never masquerade as an armed toggle.
|
||||
*/
|
||||
describe("setBackgroundAudioEnabled", () => {
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports success when the bridge is present and the call lands", () => {
|
||||
const setEnabled = vi.fn();
|
||||
window.AndroidBackgroundAudio = { setEnabled };
|
||||
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(true);
|
||||
expect(setEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("reports failure when the bridge object is absent entirely", () => {
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports failure for a stale proxy whose methods are gone", () => {
|
||||
// The exact shape of the bug: object present (so `?.` passes) but the
|
||||
// method is missing after re-injection over a live page.
|
||||
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
|
||||
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports failure when the bridge method throws", () => {
|
||||
window.AndroidBackgroundAudio = {
|
||||
setEnabled: () => {
|
||||
throw new TypeError("e.setEnabled is not a function");
|
||||
},
|
||||
};
|
||||
|
||||
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("never throws out to the caller — the toggle must not break the player", () => {
|
||||
window.AndroidBackgroundAudio = {
|
||||
setEnabled: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
interface AndroidBackgroundAudioBridge {
|
||||
setEnabled(enabled: boolean): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
|
||||
return window.AndroidBackgroundAudio;
|
||||
}
|
||||
|
||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
||||
export function isBackgroundAudioSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||
* side runs the audio handoff on background instead of entering PiP.
|
||||
*/
|
||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
||||
export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
||||
const b = bridge();
|
||||
if (!b) {
|
||||
// The button is gated on platform(), not on this bridge, so it can render
|
||||
// before/without the bridge existing. Silently no-oping here leaves the UI
|
||||
// showing "armed" while native never learns — and the handoff then never
|
||||
// fires on lock. Report it so callers can retry.
|
||||
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
bridge()?.setEnabled(enabled);
|
||||
b.setEnabled(enabled);
|
||||
console.log("[BgAudio] setEnabled ->", enabled);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user