docs(traces): tag the twelve "Done but untraced" requirements, and stop the matrix over-reporting
Twelve requirements were marked Done in docs/requirements.md with zero TRACES anywhere in the tree. The features work — the tags were simply never written — so the matrix over-reported on exactly the requirements a reviewer would most want to verify. Each is now tagged at the code that actually implements it: - JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at their Jellyfin call sites in repository/online.rs (search, get_item's MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE, get_person/get_items_by_person), plus the commands that expose them. - UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and LockscreenMetadata / update_lockscreen_metadata. - IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the manual AudioFocusRequest listener for video — and at the media-type string that chooses between them. - UR-037 (with DR-042, also untraced) on the video-library poster grid: LibraryGrid, MediaCard, and the tv/movies routes. Resolve contradictory statuses across layers, evidence first: - IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv. MpvBackend is the audio-only backend and overrides neither set_subtitle_track nor set_audio_track — the trait's not_implemented() default still stands — so UR-020/UR-021 are met by ExoPlayer and by the HTML5 <video> path instead. Both IRs are re-scoped to those backends and marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs follow. - IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in the project and update_lockscreen_metadata is a no-op off Android. UR-006 is corrected to Done (Android) rather than the IR being marked Done. - A note under the IR table records where a UR is met by a different mechanism than its IR anticipated. Define the two dangling IDs the source already referenced: DR-189 (the control bar never auto-hid on a touchscreen, because its timer was armed only from onmousemove) and UT-188 (its rule test). The live-denominator assertion in extract-traces.test.ts moves 187/330 to 188/331 accordingly. Traced requirements 444 to 459; IR coverage 19/32 to 25/32.
This commit is contained in:
+30
-8
@@ -16,7 +16,7 @@ For a narrative overview of the system design, see
|
||||
| UR-003 | Play videos | High | Done |
|
||||
| UR-004 | Play audio uninterrupted | High | Done |
|
||||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done (Android); **not implemented on Linux** — see IR-005 |
|
||||
| UR-007 | Navigate media in library | High | Done |
|
||||
| UR-008 | Search media across libraries | High | Done |
|
||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||
@@ -101,7 +101,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
||||
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
||||
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned — genuinely absent: no `mpris`/`souvlaki`/`zbus`/`dbus` code or dependency in the project (`zbus` appears in `Cargo.lock` only transitively, via `tauri-plugin-opener`), and no `navigator.mediaSession` use in the frontend. `player::update_lockscreen_metadata` is a no-op off Android. UR-006 is therefore Android-only |
|
||||
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
||||
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
||||
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
||||
@@ -115,8 +115,8 @@ External system integrations and platform-specific implementations.
|
||||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||
| IR-018 | Subtitle rendering and selection in the **video** playback backends: ExoPlayer sideloads each track as a `MediaItem.SubtitleConfiguration` and selects by text-track-group position (Android), and the WebKitGTK HTML5 `<video>` element renders `<track kind="subtitles">` children carrying `data-stream-index` (Linux). **Originally scoped to libmpv, which never implemented it**: `MpvBackend` is the audio-only backend here and does not override `PlayerBackend::set_subtitle_track`, so the default `not_implemented()` still stands there. UR-020 is satisfied by the two paths above rather than by MPV | Playback | UR-020 | Done |
|
||||
| IR-019 | Audio track selection in the **video** playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 `<video>` path cannot switch a track in the element and instead re-opens the stream at the chosen `AudioStreamIndex` and resumes at the same position (Linux) — the two outcomes `AudioTrackSwitchResponse` distinguishes. **Originally scoped to libmpv, which never implemented it**: `MpvBackend` does not override `PlayerBackend::set_audio_track`, so the default `not_implemented()` still stands there. UR-021 is satisfied by the two paths above rather than by MPV | Playback | UR-021 | Done |
|
||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
|
||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||
@@ -130,6 +130,26 @@ External system integrations and platform-specific implementations.
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||
|
||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
||||
> integration requirements were written when libmpv was expected to be the single
|
||||
> playback backend. It is not: `MpvBackend` is the **audio-only** backend, Linux
|
||||
> plays video through a WebKitGTK HTML5 `<video>` element (HLS/h264), and Android
|
||||
> plays through ExoPlayer. So:
|
||||
>
|
||||
> * **UR-020 / UR-021** (subtitle and audio track selection) are Done, but not by
|
||||
> MPV — `MpvBackend` overrides neither `PlayerBackend::set_subtitle_track` nor
|
||||
> `set_audio_track`, leaving the trait's `not_implemented()` default. IR-018 and
|
||||
> IR-019 have been **re-scoped to the backends that actually deliver them**
|
||||
> (ExoPlayer sideloaded `SubtitleConfiguration`s and native track switching;
|
||||
> HTML5 `<track>` children and stream re-open at the chosen `AudioStreamIndex`)
|
||||
> and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
|
||||
> * **UR-006** (lockscreen / BLE headset control) is Done **on Android only**, via
|
||||
> `MediaSessionCompat` (IR-006) and ExoPlayer/`AudioManager` focus (IR-008).
|
||||
> IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is
|
||||
> no MPRIS/D-Bus code or dependency in the project, and
|
||||
> `player::update_lockscreen_metadata` is a no-op off Android. UR-006's status
|
||||
> was corrected rather than IR-005's.
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
API endpoints and data contracts required for Jellyfin integration.
|
||||
@@ -348,6 +368,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
|
||||
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
|
||||
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
|
||||
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
|
||||
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
|
||||
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
|
||||
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
|
||||
@@ -394,8 +415,8 @@ Internal architecture, components, and application logic.
|
||||
| UR-017 | - | DR-014, DR-021 |
|
||||
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
|
||||
| UR-019 | IR-015 | DR-022 |
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 |
|
||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||
| UR-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||
| UR-024 | IR-010 | DR-027 |
|
||||
@@ -636,6 +657,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
|
||||
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
|
||||
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
|
||||
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
|
||||
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
|
||||
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
|
||||
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
|
||||
@@ -655,8 +677,8 @@ Internal architecture, components, and application logic.
|
||||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
||||
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||||
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
||||
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
||||
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
||||
| IT-008 | Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 `<track>` children) — *not* via libmpv, which does not implement it | IR-018, UR-020 | Pending |
|
||||
| IT-009 | Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen `AudioStreamIndex`) — *not* via libmpv, which does not implement it | IR-019, UR-021 | Pending |
|
||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||
|
||||
+4852
-4483
File diff suppressed because it is too large
Load Diff
@@ -175,8 +175,10 @@ describe("live requirements.md", () => {
|
||||
|
||||
expect(defined.UR).toBe(75);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(187);
|
||||
// 188 since DR-189 was given the definition its TRACES comments in
|
||||
// VideoPlayer.svelte / controlsVisibility.ts had always referenced.
|
||||
expect(defined.DR).toBe(188);
|
||||
expect(defined.JA).toBe(36);
|
||||
expect(defined.total).toBe(330);
|
||||
expect(defined.total).toBe(331);
|
||||
});
|
||||
});
|
||||
|
||||
+11
@@ -27,6 +27,17 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
*
|
||||
* Media commands are routed back to Rust via JNI to ensure proper
|
||||
* queue management for next/previous track operations.
|
||||
*
|
||||
* This class owns both sessions: the media3 [MediaSession] the service contract
|
||||
* requires, and the legacy [MediaSessionCompat] that actually carries the
|
||||
* lockscreen transport. The compat session is flagged
|
||||
* FLAG_HANDLES_MEDIA_BUTTONS or FLAG_HANDLES_TRANSPORT_CONTROLS, which is what
|
||||
* makes a Bluetooth headset's AVRCP play/pause/skip arrive as a transport
|
||||
* callback; every one of those callbacks is forwarded to Rust through
|
||||
* nativeOnMediaCommand rather than acted on locally, so the player stays the
|
||||
* single source of truth and the session remains a consumer of its state.
|
||||
*
|
||||
* TRACES: UR-006 | IR-006
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
@@ -263,7 +263,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
|
||||
// Create ExoPlayer with audio focus handling
|
||||
// Create ExoPlayer with audio focus handling.
|
||||
//
|
||||
// For audio playback ExoPlayer manages focus itself: handleAudioFocus=true
|
||||
// makes it request AUDIOFOCUS_GAIN on play, duck on a transient loss, and
|
||||
// pause on a call or another app taking focus. Video re-applies this per
|
||||
// load with handleAudioFocus=false and drives focus manually instead (see
|
||||
// requestAudioFocus), because a video needs delayed-focus handling.
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
// Pause when the audio output is removed (wired headphones unplugged or
|
||||
@@ -1328,7 +1336,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* Request audio focus for video playback.
|
||||
* This is critical for video to have audio on Android.
|
||||
*
|
||||
* TRACES: UR-004 | DR-145
|
||||
* The listener installed here is the pause-on-call path: AUDIOFOCUS_LOSS and
|
||||
* AUDIOFOCUS_LOSS_TRANSIENT (an incoming call is the latter) both pause,
|
||||
* LOSS_TRANSIENT_CAN_DUCK lowers the volume instead, and GAIN restores —
|
||||
* resuming only what we paused, via pendingPlayOnFocusGain.
|
||||
*
|
||||
* TRACES: UR-004, UR-006 | IR-008, DR-145
|
||||
*
|
||||
* @return true if focus was granted outright and playback may start now.
|
||||
* false for a DELAYED or refused request — the caller must hold playback
|
||||
|
||||
@@ -1478,6 +1478,16 @@ pub async fn player_seek_video(
|
||||
|
||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
||||
///
|
||||
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
|
||||
/// change audio track, so the stream is re-opened at the chosen
|
||||
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
|
||||
/// `position`; a native backend (ExoPlayer) switches in place by track-group
|
||||
/// index. libmpv implements neither — it is the audio-only backend here and
|
||||
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
|
||||
/// which is why IR-019 is met by these two paths rather than by MPV.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_switch_audio_track(
|
||||
@@ -1650,6 +1660,9 @@ pub async fn player_set_stream_quality(
|
||||
Ok(StreamQualityResponse::Native { position })
|
||||
}
|
||||
|
||||
/// Set the active audio track on a native backend directly.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_audio_track(
|
||||
@@ -1663,6 +1676,14 @@ pub async fn player_set_audio_track(
|
||||
Ok(get_player_status(&controller))
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) the active subtitle track on a native backend.
|
||||
///
|
||||
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
|
||||
/// children. libmpv implements neither, leaving the trait default in place.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_subtitle_track(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tauri commands for repository access
|
||||
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||
//!
|
||||
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
||||
//! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::collections::HashMap;
|
||||
@@ -294,7 +294,13 @@ pub async fn repository_get_latest_items(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get resume items (continue watching/listening)
|
||||
/// Get resume items (continue watching/listening).
|
||||
///
|
||||
/// The home screen's Continue Watching row and every library's "pick up where
|
||||
/// you left off" hero come through here; each item carries its own resume
|
||||
/// position in `UserData`.
|
||||
///
|
||||
/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_resume_items(
|
||||
@@ -318,7 +324,9 @@ pub async fn repository_get_resume_items(
|
||||
})
|
||||
}
|
||||
|
||||
/// Get next up episodes
|
||||
/// Get next up episodes.
|
||||
///
|
||||
/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_next_up_episodes(
|
||||
|
||||
@@ -420,7 +420,18 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
None => JValue::Object(&null_obj),
|
||||
};
|
||||
|
||||
// Determine media type string for JNI
|
||||
// Determine media type string for JNI.
|
||||
//
|
||||
// This is not cosmetic: the string decides *which audio-focus mechanism*
|
||||
// runs on the Kotlin side. `JellyTauPlayer.load()` re-applies
|
||||
// `setAudioAttributes(attrs, handleAudioFocus = mediaType == AUDIO)`, so
|
||||
// "audio" leaves focus to ExoPlayer (request on play, duck on transient
|
||||
// loss, pause on a call) while "video" switches it to the manual
|
||||
// `AudioFocusRequest` path, which needs delayed-focus handling. Either
|
||||
// way the resulting pause comes back through `nativeOnStateChanged`, so
|
||||
// the Rust controller — not the focus listener — stays authoritative.
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
let media_type_str = match media.media_type {
|
||||
MediaType::Video => "video",
|
||||
MediaType::Audio => "audio",
|
||||
@@ -1096,6 +1107,15 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
///
|
||||
/// Commands from lockscreen controls, notification buttons, and Bluetooth
|
||||
/// devices are routed through here to the Rust PlayerController.
|
||||
///
|
||||
/// This is the inbound half of UR-006: `MediaSessionCompat` is flagged
|
||||
/// `FLAG_HANDLES_MEDIA_BUTTONS`, so an AVRCP play/pause/skip from a headset
|
||||
/// arrives at the service's transport callback and lands here as a command
|
||||
/// string. The player stays authoritative — the session is a consumer that
|
||||
/// *requests*, and the resulting state comes back out through
|
||||
/// [`update_lockscreen_metadata`].
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
|
||||
mut env: JNIEnv,
|
||||
@@ -1396,6 +1416,8 @@ use crate::player::LockscreenMetadata;
|
||||
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
|
||||
/// isn't, this is a no-op rather than an error so it can be called freely on
|
||||
/// every poll tick.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -98,9 +98,12 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active audio track by stream index
|
||||
///
|
||||
/// @req-planned: UR-021 - Select audio track for video content
|
||||
/// @req-planned: IR-019 - libmpv audio track selection
|
||||
/// @req-planned: DR-024 - Audio track selection UI in video player
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it — MPV is the audio-only backend here, so it keeps
|
||||
/// this `not_implemented()` default and the Linux video path switches track by
|
||||
/// re-opening the stream instead (`player_switch_audio_track`).
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
@@ -108,9 +111,12 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
///
|
||||
/// @req-planned: UR-020 - Select subtitles for video content
|
||||
/// @req-planned: IR-018 - libmpv subtitle rendering and selection
|
||||
/// @req-planned: DR-023 - Subtitle selection UI in video player
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it, so it keeps this `not_implemented()` default;
|
||||
/// the Linux video path renders subtitles as `<track>` children of the
|
||||
/// WebKitGTK HTML5 `<video>` element and never calls this.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
|
||||
@@ -140,6 +140,8 @@ const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
|
||||
/// poller fills this in from the remote Jellyfin session and pushes it to the
|
||||
/// notification so the lockscreen stays in sync while casting.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
#[derive(Debug, Clone)]
|
||||
// Fields are read only by the Android MediaSession bridge; on other platforms
|
||||
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
|
||||
@@ -157,6 +159,11 @@ pub struct LockscreenMetadata {
|
||||
|
||||
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
|
||||
/// session poller can call it unconditionally and stay platform-agnostic.
|
||||
///
|
||||
/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
|
||||
/// IR-005, which is still Planned.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
|
||||
@@ -760,7 +760,11 @@ struct JellyfinItem {
|
||||
/// `UserData` in the `Fields=` list so the shape is explicit rather than
|
||||
/// dependent on the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
|
||||
/// `PlaybackPositionTicks` is the server's resume position for the item, and the
|
||||
/// only place it is published — Jellyfin has no per-item "resume position"
|
||||
/// endpoint, so reading `UserData` *is* how a resume point is obtained.
|
||||
///
|
||||
/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinUserData {
|
||||
@@ -854,6 +858,8 @@ fn build_get_items_endpoint(
|
||||
///
|
||||
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
||||
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||
format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
@@ -875,7 +881,7 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
||||
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
|
||||
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
|
||||
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
@@ -1129,6 +1135,17 @@ impl MediaRepository for OnlineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch one item with every field the detail and player screens need.
|
||||
///
|
||||
/// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
|
||||
/// they are named. `MediaStreams` is what makes the item's **audio and
|
||||
/// subtitle tracks** knowable at all — there is no separate "tracks"
|
||||
/// endpoint, so this single call is how the player learns which audio tracks
|
||||
/// an item offers (`to_media_item` maps them, and the player's selector
|
||||
/// filters them by `kind`). `People` is likewise how **cast and crew** are
|
||||
/// obtained.
|
||||
///
|
||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
|
||||
|
||||
@@ -1152,6 +1169,14 @@ impl MediaRepository for OnlineRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Continue Watching: the items this user has started and not finished.
|
||||
///
|
||||
/// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
|
||||
/// in the Continue Watching row" and "where was this left off" — each item
|
||||
/// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
|
||||
/// is named in `Fields=` rather than left to the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -1175,6 +1200,10 @@ impl MediaRepository for OnlineRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// "Next Up": the episode that follows the ones this user has finished,
|
||||
/// per series — the Shows-scoped counterpart to Continue Watching.
|
||||
///
|
||||
/// TRACES: UR-023, UR-059 | IR-024, JA-014
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
series_id: Option<&str>,
|
||||
@@ -1348,6 +1377,11 @@ impl MediaRepository for OnlineRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Continue Watching, narrowed to movies — the home screen's movie row and
|
||||
/// the movie library's own hero both want the unfinished films without the
|
||||
/// episodes mixed in.
|
||||
///
|
||||
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
@@ -1423,6 +1457,14 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(genres)
|
||||
}
|
||||
|
||||
/// Search every library the user can see.
|
||||
///
|
||||
/// `Recursive=true` with no `ParentId` is what makes this cross-library
|
||||
/// rather than folder-scoped; a caller narrowing the search passes the item
|
||||
/// types through `SearchOptions` (already expanded from an opaque
|
||||
/// `SearchScope` on this side of the boundary).
|
||||
///
|
||||
/// TRACES: UR-008 | IR-010, JA-006
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -2195,6 +2237,12 @@ impl MediaRepository for OnlineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
|
||||
/// as [`Self::mark_favorite`], removed rather than posted. Written out by
|
||||
/// hand rather than through `post_json` because it is the one favourite call
|
||||
/// that needs `DELETE`.
|
||||
///
|
||||
/// TRACES: UR-017 | JA-018, DR-021
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
@@ -2316,12 +2364,22 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// A single Person item (actor, director, …) by id.
|
||||
///
|
||||
/// Jellyfin models people as ordinary items, so this is the plain item
|
||||
/// endpoint rather than anything under `/Persons`; the cast entries returned
|
||||
/// on an item's `People` field carry the ids this is called with.
|
||||
///
|
||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
Ok(item.to_media_item(self.user_id.clone()))
|
||||
}
|
||||
|
||||
/// A person's filmography — every item they are credited on.
|
||||
///
|
||||
/// TRACES: UR-036 | IR-022, JA-031
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
person_id: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
|
||||
<!-- TRACES: UR-029, UR-037, UR-051 | DR-042, DR-069, DR-070 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 -->
|
||||
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
|
||||
@@ -276,7 +276,15 @@
|
||||
});
|
||||
|
||||
|
||||
// Get available audio tracks from media streams
|
||||
// The audio tracks available for this item, as the server described them.
|
||||
//
|
||||
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
|
||||
// item itself, in `MediaStreams`, which the backend asks for by name in
|
||||
// `get_item`'s `Fields=` list and tags with an opaque `kind`. Selecting by
|
||||
// that tag rather than by Jellyfin's `Type` string keeps the taxonomy on the
|
||||
// Rust side of the boundary.
|
||||
//
|
||||
// TRACES: UR-021 | IR-016, JA-009 | DR-024
|
||||
const audioTracks = $derived(() => {
|
||||
if (!media || !media.mediaStreams) {
|
||||
console.log("[VideoPlayer] No media or mediaStreams available");
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
`/library/movies/genres`). They are now `?view=browse|all|genres` here; the
|
||||
old routes redirect.
|
||||
|
||||
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-105
|
||||
TRACES: UR-007, UR-023, UR-034, UR-037, UR-063 | DR-007, DR-038, DR-039, DR-042, DR-105
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
the last of which did not even share a prefix with the others). They are now
|
||||
`?view=browse|all|genres` here; the old routes redirect.
|
||||
|
||||
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-103, DR-105
|
||||
TRACES: UR-007, UR-023, UR-034, UR-037, UR-063 | DR-007, DR-038, DR-039, DR-042, DR-103, DR-105
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
Reference in New Issue
Block a user