Compare commits

..
7 Commits
Author SHA1 Message Date
dtourolle 61df2730bc chore(release): 0.8.2
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 24m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 25m19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m18s
Build & Release / Build Linux (push) Successful in 30m51s
Build & Release / Build Windows (push) Successful in 14m55s
Build & Release / Build Android (push) Successful in 32m54s
Build & Release / Create Release (push) Successful in 20s
2026-08-19 17:29:42 +02:00
dtourolle c18d79c656 fix(android): stop background audio rewinding to where it started
A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".

ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.

A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.

Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:

  before  13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
          (C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
          base, 3.5 minutes after the outage with nothing logged between
  after   14:05:08 "declining the player's retry", playback undisturbed off the
          buffer for 69s (a fatal load error is only raised when the renderer
          next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
          re-opening at 785.6s -> READY, and no rewind in the following 7 min

Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.

TRACES: UR-040, UR-004 | DR-203 | UT-200
2026-08-19 17:29:31 +02:00
dtourolle 69c2498cf7 docs(traceability): record DR-202 device verification
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
FP5, native ExoPlayer path: the hold follows IS PLAYING CHANGED within 17 ms,
dumpsys shows fl=KEEP_SCREEN_ON on the window, and a pause/resume round-trip
releases and re-takes it. The webview <video> path is still unverified.
2026-08-18 15:06:43 +02:00
dtourolle 73dd0ef68b chore(release): 0.8.1
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 16m26s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Patch: one Android fix — the display no longer sleeps mid-video.
2026-08-18 14:56:56 +02:00
dtourolle caebf2d139 fix(android): keep the display awake while video plays
Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.

Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.

ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.

Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with

    ./gradlew :app:testUniversalDebugUnitTest

(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.

TRACES: UR-003, UR-004 | DR-202 | UT-199
2026-08-18 14:56:08 +02:00
dtourolle d5d0e35bca docs(debt): close the R8 release-APK validation item
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 4m56s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Validated on device. It was the last item gating confidence in v0.8.0 itself:
R8 stripping JNI-loaded classes has broken release builds here before, and this
release added a new Kotlin path the unminified debug pass did not exercise.
Recorded as closed rather than deleted, so it is not re-raised.
2026-08-17 07:17:33 +02:00
dtourolle a1cb142df4 docs(debt): record the 12 open items from the codebase audit
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m14s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Findings not addressed in v0.8.0, plus items the device-verification pass turned
up, ordered by what would hurt most if left. Highest are the Android 16 Local
Network Protections exposure (LAN Jellyfin access is the app's core function and
enforcement is coming) and the traceability extractor's blindness to the Kotlin
tree, which means the 90% figure excludes a whole platform.
2026-08-17 06:58:33 +02:00
19 changed files with 4811 additions and 3932 deletions
+41
View File
@@ -9,6 +9,47 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.8.2
A single fix, for Android background audio.
### 🐛 Fixes
- **Listening to a video in the background no longer jumps back to where you
started.** Handing a video off to background audio streams a live mp3
transcode, which is chunked — no length, and no duration the player can read.
ExoPlayer resumes a failed load in place only when it knows one of those two
things; with neither it assumes the source is live and re-requests the URL from
the beginning. That URL starts at the moment you locked the screen, so a
network blip left a retry armed, and when the buffer eventually ran dry —
minutes later, with nothing in between — playback silently resumed from the
handoff point and carried on. No error was raised and nothing ended, so none of
the existing stream-recovery paths could see it; the only sign was a position
that went backwards, which is why it looked random. The player is now refused
its own retry for exactly that kind of stream, so the failure surfaces and the
backend re-opens the stream at the position playback actually reached, keeping
your selected audio track. Music and video are untouched: both declare their
timeline, and the player resumes them where the load stopped.
(UR-040, UR-004 → DR-203)
## v0.8.1
A single fix, for Android.
### 🐛 Fixes
- **The screen no longer sleeps while you are watching something.** Android
counts its display timeout from the last time you touched the phone, and
watching a film is exactly when you do not — so the picture dimmed and the
screen went out mid-playback unless you kept tapping it. Nothing in the app
ever asked the display to stay on, and neither video renderer does so by
itself: ExoPlayer's wake mode keeps the CPU and wifi alive but says nothing
about the screen, and an embedded WebView does not take the display wake lock
that a browser takes for `<video>`. Both rendering paths now hold the screen
awake for as long as video is actually playing, and release it on pause, on
stop, and when the player goes away. Audio is deliberately untouched — playing
music with the screen off is the point of it. (UR-003, UR-004 → DR-202)
## v0.8.0
A security and correctness release, from an audit of the codebase against its own
+1
View File
@@ -80,6 +80,7 @@ silently correct an out-of-range index — which is exactly why it was reported
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
Three of these are worth separating out, because the defect is not a mistake in
the code so much as **plumbing that was built and never connected**:
+34 -2
View File
@@ -375,6 +375,8 @@ Internal architecture, components, and application logic.
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 3336. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
| DR-202 | Video keeps the display awake. Android counts its display timeout from the last *user input*, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: `FLAG_KEEP_SCREEN_ON` appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's `setWakeMode` is a CPU/wifi wake lock that says nothing about the display, and it draws into the `TextureView` this app owns (DR-192) rather than media3's `PlayerView`, which is the widget that would otherwise set `keepScreenOn` itself; the WebView `<video>` path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. `ScreenWakeManager` toggles `FLAG_KEEP_SCREEN_ON` on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired `PowerManager.WakeLock` can, and it needs no permission (the manifest's `WAKE_LOCK` is the media service's). The two rendering paths are independent holders OR-ed in the pure `ScreenWakeState`: the native path follows `onIsPlayingChanged` plus surface teardown, so the hold tracks what ExoPlayer *reports* rather than what the UI intends, and the webview path reuses the `setHtml5VideoState` report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final `active = false`. Also the repo's first Kotlin JVM unit tests: `ScreenWakeState` is framework-free so the decision is testable off-device with `./gradlew :app:testUniversalDebugUnitTest`. Verified on device (FP5, native path): `IS PLAYING CHANGED: true``keepScreenOn = true` 17 ms later and `fl=KEEP_SCREEN_ON` on the window in `dumpsys`, a pause releasing it and the resume re-taking it. The webview path is unverified | Android | UR-003, UR-004 | Done |
| DR-203 | The background-audio handoff stops silently rewinding to the point it started. A player retry is only a *retry* if it can resume where the load failed, and ExoPlayer decides that in `ProgressiveMediaPeriod.configureRetry`: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (`/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http`, DR-129) satisfies neither condition: chunked, so no `Content-Length`, and a live mp3 encode carries no `Xing` header, so the duration is unset — on device every position tick reads `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no `STATE_ENDED`, so neither arm of DR-129 was ever consulted, no `onPositionDiscontinuity` handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same *symptom* (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: `player_retry_restarts_stream` marks a `Remote` audio-only video item, and `loadWithMetadata` carries the answer to Kotlin, where the pure `StreamRetryDecision` holds it for a `DefaultLoadErrorHandlingPolicy` subclass that returns `C.TIME_UNSET` — which makes `onLoadError` answer `DONT_RETRY_FATAL` *before* reaching `configureRetry`. The rewind therefore becomes a recoverable error, and `recoverable_error_resume` already knows what to do with one: re-open at the position playback actually reached, `StartTimeTicks` rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A `onPositionDiscontinuity` handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for `DISCONTINUITY_REASON_INTERNAL`, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. **Before** — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, `BUFFERING``READY` → position `1165.4s``840.3s`, exactly the handoff base, no error and no `STATE_ENDED`; the same log line reports `Media ready! Duration: -9.223372036854776E15`, which is `C.TIME_UNSET` and the precondition itself. **After**`Load error on a stream that cannot be resumed in place — declining the player's retry` at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then `ERROR_CODE_IO_NETWORK_CONNECTION_FAILED``re-opening at 785.6s in 2s``READY`, playing on from 785.6s with no rewind in the following 7 minutes | Playback | UR-040, UR-004 | Done |
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
@@ -403,7 +405,7 @@ Internal architecture, components, and application logic.
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
@@ -439,7 +441,7 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
@@ -675,6 +677,8 @@ Internal architecture, components, and application logic.
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
### Integration Tests
@@ -700,6 +704,34 @@ Internal architecture, components, and application logic.
## 5. Technical Debt
### Open items from the codebase audit (2026-08-16)
Findings from [codebase-audit.md](codebase-audit.md) that were **not** addressed
in v0.8.0, plus items the device-verification pass turned up. Ordered by what
would hurt most if left. The audit doc carries the full reasoning and evidence
for each.
> **Closed 2026-08-17:** the R8-minified release APK was validated on device.
> That was the last item gating confidence in the v0.8.0 release itself; R8
> stripping JNI-loaded classes has broken release builds here before, and
> v0.8.0 added a new Kotlin path (`onFastForward`/`onRewind`) that the
> unminified debug pass did not cover.
| # | Item | Why it matters | Size |
|---|------|----------------|------|
| 1 | **Android 16 Local Network Protections** (audit B8) | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** (audit D3) | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
| 6 | **Confirm the playback service rejects unknown callers** (audit B7, second half) | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (The predictive-back half of B7 was verified working on device and needs nothing) | S |
| 7 | **Media3 is several minor versions behind** (audit B6) | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
| 8 | **Shipped desktop bundles have no update path** (audit C3) | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
| 11 | **Five files carry a disproportionate share of complexity** (audit D4) | `player/mod.rs` (4.7k lines), `repository/offline.rs` (4.7k), `repository/online.rs` (3.7k), `commands/player/mod.rs` (3.3k), `commands/download/mod.rs` (3.2k), plus `VideoPlayer.svelte` (2.8k). The same files the changelog keeps returning to for deadlocks and playback regressions. Not worth a speculative refactor — but the next time one needs substantial work, splitting it is likely cheaper than growing it | L |
### Linux Keyring Integration Workaround
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
+4115 -3919
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.8.0",
"version": "0.8.2",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
+5 -3
View File
@@ -270,9 +270,11 @@ describe("live requirements.md", () => {
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
// merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side.
expect(defined.DR).toBe(192);
// they sum. Resolve this by summing, never by taking one side. 193 adds
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
// transcode refusing the player's own load-error retry).
expect(defined.DR).toBe(194);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(335);
expect(defined.total).toBe(337);
});
});
+13
View File
@@ -23,6 +23,19 @@ rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
# JVM unit tests (src/test). Plain JUnit over the pure decision helpers — no
# Android framework classes — run with `./gradlew :app:testDebugUnitTest` from
# gen/android. Mirrored here so the canonical tree stays the only place tests
# are edited.
TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/test/java/com/dtourolle/jellytau"
TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/test/java/com/dtourolle/jellytau"
if [ -d "$TEST_SOURCE_DIR" ]; then
rm -rf "$TEST_TARGET_DIR"
mkdir -p "$TEST_TARGET_DIR"
cp -r "$TEST_SOURCE_DIR"/. "$TEST_TARGET_DIR/"
echo " Copied unit tests: src/test"
fi
# Copy individual Kotlin files (like VideoOverlayManager.kt)
for kt_file in "$SOURCE_DIR"/*.kt; do
if [ -f "$kt_file" ]; then
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.8.0"
version = "0.8.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -85,6 +85,11 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
// A new WebView means a new page, which reports no video yet. Anything the
// previous one left held would otherwise pin the screen on for the life of
// the process, since a page that goes away never sends its final
// setHtml5VideoState(false, …). (DR-202)
ScreenWakeManager.releaseAll()
installJavascriptBridges(webView)
configureWebViewSettings(webView)
}
@@ -115,6 +120,11 @@ class MainActivity : TauriActivity() {
// TRACES: UR-003, UR-041 | DR-151
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
// The window whose FLAG_KEEP_SCREEN_ON is toggled while video plays. Set on
// every onCreate so a recreated Activity (rotation) re-applies the current
// hold to its new window. (UR-003, DR-202)
ScreenWakeManager.setActivity(this)
// Configure WebView for media playback after Tauri initialization
handler.postDelayed({
configureWebViewForMedia()
@@ -188,6 +198,7 @@ class MainActivity : TauriActivity() {
override fun onDestroy() {
NetworkTypeMonitor.stopWatching(this)
ScreenWakeManager.clearActivity(this)
super.onDestroy()
}
@@ -311,6 +322,10 @@ class MainActivity : TauriActivity() {
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
// The same report is what keeps the display awake on the webview
// rendering path — the WebView takes no display wake lock of its own
// for `<video>`. (DR-202)
ScreenWakeManager.onHtml5VideoState(active, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -0,0 +1,167 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.view.WindowManager
import java.lang.ref.WeakReference
/**
* Which playback paths currently want the screen kept awake.
*
* Pure state, deliberately free of any Android type so it can be unit-tested
* see ScreenWakeStateTest. Two independent holders, because video can be
* rendered by either renderer and only one of them is active at a time:
*
* - **native** ExoPlayer drawing into the TextureView (DR-192)
* - **html5** a `<video>` inside the WebView, reported by the frontend
*
* Audio is deliberately *not* a holder. Playing music with the screen off is the
* point of the audio path; only video needs the display alive.
*
* TRACES: UR-003 | DR-202 | UT-199
*/
class ScreenWakeState {
private var nativeVideoPlaying = false
private var html5VideoPlaying = false
/** True while any video renderer is actively playing. */
val keepScreenOn: Boolean
get() = nativeVideoPlaying || html5VideoPlaying
/**
* @param playing whether ExoPlayer is playing right now
* @param isVideo whether what it is playing is video rather than audio
*/
fun updateNative(playing: Boolean, isVideo: Boolean) {
nativeVideoPlaying = playing && isVideo
}
/**
* @param active whether a webview `<video>` is the current playback surface
* @param playing whether that element is playing right now
*/
fun updateHtml5(active: Boolean, playing: Boolean) {
html5VideoPlaying = active && playing
}
/** Drop every hold (teardown, or a page that can no longer be trusted). */
fun reset() {
nativeVideoPlaying = false
html5VideoPlaying = false
}
}
/**
* Keeps the display awake while video is playing.
*
* TRACES: UR-003 | DR-202
*
* ## Why this is needed at all
*
* Android turns the screen off on its own display timeout, counted from the last
* *user input*. Watching a film is precisely the case where there is none, so
* without an explicit hold the screen dimmed and slept mid-playback and the user
* had to keep tapping it. Nothing in the app held it: `FLAG_KEEP_SCREEN_ON`
* appeared nowhere, and neither renderer supplies one for free ExoPlayer's
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
* `<video>` path does not either: the display wake lock Chrome takes for video
* lives in the browser layer, not in an embedded WebView.
*
* ## Approach
*
* `FLAG_KEEP_SCREEN_ON` on the Activity window rather than a
* `PowerManager.WakeLock`: the flag is scoped to the window, so it stops
* applying the moment the app is not visible and cannot survive a crash or a
* missed release the way an explicitly acquired wake lock can. It needs no
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
*
* The two renderers report independently and are OR-ed together in
* [ScreenWakeState]:
*
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
* native path ExoPlayer is the authoritative source of playback state, so
* the hold follows what it reports rather than what the UI intends.
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
* the webview path. The frontend already reports that state on every
* play/pause and on player teardown for PiP, so no new bridge is needed.
*
* The Activity reference is weak and re-set on every `onCreate`, so a
* recreation (rotation) re-applies the current hold to the new window.
*/
object ScreenWakeManager {
private const val TAG = "ScreenWakeManager"
private val mainHandler = Handler(Looper.getMainLooper())
private val state = ScreenWakeState()
private var activityRef: WeakReference<Activity>? = null
/**
* Adopt the Activity whose window carries the flag, and re-apply the current
* hold to it. Called from `MainActivity.onCreate`, so a rotation-recreated
* Activity keeps the screen awake without waiting for the next state report.
*/
@Synchronized
fun setActivity(activity: Activity) {
activityRef = WeakReference(activity)
apply()
}
/** Drop the Activity on destroy, unless a newer one has already replaced it. */
@Synchronized
fun clearActivity(activity: Activity) {
if (activityRef?.get() === activity) {
activityRef = null
}
}
/** ExoPlayer's playback state changed. */
@Synchronized
fun onNativePlaybackChanged(playing: Boolean, isVideo: Boolean) {
state.updateNative(playing, isVideo)
apply()
}
/**
* The frontend reported the webview `<video>` state. Arrives on a WebView
* binder thread, hence the synchronization and the post to the main thread.
*/
@Synchronized
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
state.updateHtml5(active, playing)
apply()
}
/**
* Drop every hold. Used when a new WebView/page load invalidates whatever the
* previous page last reported a page that goes away without a final
* `setHtml5VideoState(false, )` would otherwise leave the screen pinned on
* for the life of the process.
*/
@Synchronized
fun releaseAll() {
state.reset()
apply()
}
private fun apply() {
val desired = state.keepScreenOn
val activity = activityRef?.get() ?: return
mainHandler.post {
try {
if (activity.isFinishing || activity.isDestroyed) return@post
if (desired) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
android.util.Log.d(TAG, "keepScreenOn = $desired")
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to apply keep-screen-on flag", e)
}
}
}
}
@@ -20,6 +20,9 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import kotlinx.coroutines.*
/**
@@ -256,6 +259,45 @@ class JellyTauPlayer(private val appContext: Context) {
* (and leak) a focus request we already own. */
private var hasAudioFocus = false
/**
* Whether the stream that is loaded may be retried by the player itself.
*
* Set from Rust on every load; see [StreamRetryDecision] for why the
* background-audio handoff transcode must answer no. (DR-203)
*/
private val streamRetry = StreamRetryDecision()
/**
* The default retry behaviour, except that a stream the player could only
* restart is not retried at all.
*
* `C.TIME_UNSET` makes `ProgressiveMediaPeriod.onLoadError` return
* `DONT_RETRY_FATAL` *before* it reaches `configureRetry`, which is the
* method that would otherwise reset the sample queues and re-request the URL
* from offset 0. The error then surfaces through [onPlayerError] as
* recoverable, and Rust re-opens the stream at the position playback
* actually reached (DR-129).
*
* TRACES: UR-040, UR-004 | DR-203
*/
private val loadErrorHandlingPolicy: LoadErrorHandlingPolicy =
object : DefaultLoadErrorHandlingPolicy() {
override fun getRetryDelayMsFor(
loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo
): Long {
if (!streamRetry.playerMayRetry) {
android.util.Log.w(
"JellyTauPlayer",
"Load error on a stream that cannot be resumed in place — " +
"declining the player's retry so the backend can re-open it: " +
"${loadErrorInfo.exception}"
)
return C.TIME_UNSET
}
return super.getRetryDelayMsFor(loadErrorInfo)
}
}
init {
// Configure audio attributes for music playback with audio focus handling
val audioAttributes = AudioAttributes.Builder()
@@ -273,6 +315,13 @@ class JellyTauPlayer(private val appContext: Context) {
//
// TRACES: UR-004, UR-006 | IR-008
exoPlayer = ExoPlayer.Builder(appContext)
// Decline the player's own load-error retry for a stream it could
// only restart (DR-203). Every other source keeps the default
// behaviour, which resumes the failed load where it stopped.
.setMediaSourceFactory(
DefaultMediaSourceFactory(appContext)
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or
// Bluetooth device disconnected). ExoPlayer listens for the system
@@ -336,6 +385,14 @@ class JellyTauPlayer(private val appContext: Context) {
val state = if (isPlaying) "playing" else "paused"
nativeOnStateChanged(state, currentMediaId)
// Hold the display awake for video, release it for a pause or for
// audio: the display timeout counts from the last user input, and
// watching something is exactly when there is none. (DR-202)
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(
isPlaying,
currentMediaType == MediaType.VIDEO
)
if (isPlaying) {
startPositionUpdates()
} else {
@@ -346,6 +403,33 @@ class JellyTauPlayer(private val appContext: Context) {
updatePlaybackServiceNotification(isPlaying)
}
/**
* A jump in the timeline nobody asked for.
*
* Logged rather than acted on: with the load-error retry declined for
* streams that can only be restarted (DR-203), a backwards
* `DISCONTINUITY_REASON_INTERNAL` here means the player rewound one
* anyway, and this line is what would show it.
*/
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
val message = "▶ Position discontinuity: ${oldPosition.positionMs}ms -> " +
"${newPosition.positionMs}ms (reason=$reason)"
if (reason == Player.DISCONTINUITY_REASON_INTERNAL) {
// The player moved the timeline of its own accord — the
// signature of the DR-203 rewind. Loud, because with the
// retry declined it should no longer be reachable.
android.util.Log.w("JellyTauPlayer", "$message — player-initiated")
} else if (newPosition.positionMs < oldPosition.positionMs - 1000) {
// Backwards, but asked for: a seek, or the re-prepare a
// stream resume does (reason REMOVE). Normal, so quiet.
android.util.Log.d("JellyTauPlayer", message)
}
}
override fun onPlayerError(error: PlaybackException) {
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
@@ -837,11 +921,16 @@ class JellyTauPlayer(private val appContext: Context) {
artworkUrl: String?,
durationMs: Long,
mediaType: String = "audio",
subtitlesJson: String = "[]"
subtitlesJson: String = "[]",
nonResumableStream: Boolean = false
) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
// Who owns recovery for this stream, decided in Rust (DR-203). Set
// before prepare(), since the first load error can arrive as soon as
// the player starts reading.
streamRetry.onLoad(nonResumableStream)
// Store metadata for notification updates
currentTitle = title
@@ -1027,6 +1116,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun release() {
mainHandler.post {
stopPositionUpdates()
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
coroutineScope.cancel()
releaseAudioEffects()
exoPlayer.release()
@@ -1324,6 +1414,10 @@ class JellyTauPlayer(private val appContext: Context) {
* TRACES: UR-003, UR-041 | DR-184
*/
private fun clearVideoSurface() {
// Whatever happens to the view, video is no longer what is on screen, so
// the display hold goes with it. Outside the let: the hold must be
// released even when no view was ever created. (DR-202)
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
videoView?.let {
exoPlayer.clearVideoSurface()
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
@@ -0,0 +1,50 @@
package com.dtourolle.jellytau.player
/**
* Whether the *player* is allowed to retry a failed load of what is currently
* loaded, or whether recovery belongs to the backend instead.
*
* Pure state, deliberately free of any media3 or Android type so the decision is
* unit-testable off-device the same shape as `ScreenWakeState` (DR-202).
*
* ExoPlayer resumes a failed load in place only when it knows where "in place"
* is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
* content length is known *or* the extractor produced a seek map with a
* duration, and otherwise assumes the source is live it resets every sample
* queue and re-requests the URL from offset 0.
*
* The background-audio handoff transcode (UR-040) satisfies neither condition:
* `/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http` is chunked, so
* there is no `Content-Length`, and a live mp3 encode carries no `Xing` header,
* so the duration is unset visible in logcat as every position tick reading
* `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so a
* restart from offset 0 drops playback back to where audio-only mode began and
* carries on from there, and because that is a successful *retry* rather than a
* failure, no error and no `STATE_ENDED` is ever reported: the app cannot see it
* happen. That is the bug this exists to prevent (DR-203).
*
* Rust decides which streams those are and says so on every load; this only
* remembers the answer for the load-error policy to read. Refusing the retry
* turns the silent rewind into a recoverable error, which the backend answers by
* re-opening the stream at the position playback actually reached (DR-129).
*
* TRACES: UR-040, UR-004 | DR-203 | UT-200
*/
class StreamRetryDecision {
@Volatile
private var nonResumableStream = false
/**
* Record what is being loaded.
*
* @param nonResumable whether re-requesting this stream would restart it
* rather than continue it `player_retry_restarts_stream` in Rust.
*/
fun onLoad(nonResumable: Boolean) {
nonResumableStream = nonResumable
}
/** True while the player may handle a load error by retrying it itself. */
val playerMayRetry: Boolean
get() = !nonResumableStream
}
@@ -0,0 +1,86 @@
package com.dtourolle.jellytau
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The screen-wake decision, isolated from the Activity window it is applied to.
*
* TRACES: UR-003 | DR-202 | UT-199
*/
class ScreenWakeStateTest {
@Test
fun `starts released`() {
assertFalse(ScreenWakeState().keepScreenOn)
}
@Test
fun `native video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `pausing native video releases the screen`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
/** Music with the screen off is the whole point of the audio path. */
@Test
fun `native audio playing does not hold the screen on`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = false)
assertFalse(state.keepScreenOn)
}
@Test
fun `webview video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `webview video paused releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = true, playing = false)
assertFalse(state.keepScreenOn)
}
/** The element going away must release even if it never reported a pause. */
@Test
fun `webview video going inactive while playing releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = true)
assertFalse(state.keepScreenOn)
}
/** The two rendering paths are independent holders; either one is enough. */
@Test
fun `one path releasing does not release while the other still plays`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = false)
assertTrue(state.keepScreenOn)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
@Test
fun `teardown releases both paths`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.reset()
assertFalse(state.keepScreenOn)
}
}
@@ -0,0 +1,47 @@
package com.dtourolle.jellytau.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Who owns recovery for the stream that is loaded.
*
* TRACES: UR-040, UR-004 | DR-203 | UT-200
*/
class StreamRetryDecisionTest {
/** Nothing loaded yet is an ordinary stream: the player retries as it always has. */
@Test
fun `starts allowing the player to retry`() {
assertTrue(StreamRetryDecision().playerMayRetry)
}
/**
* The reported bug: the length-less handoff transcode can only be "retried"
* from its beginning, which replays the episode from the handoff point
* without reporting anything. The player must not be allowed to try.
*/
@Test
fun `a non-resumable stream refuses the player its retry`() {
val decision = StreamRetryDecision()
decision.onLoad(nonResumable = true)
assertFalse(decision.playerMayRetry)
}
@Test
fun `an ordinary stream keeps the player retry`() {
val decision = StreamRetryDecision()
decision.onLoad(nonResumable = false)
assertTrue(decision.playerMayRetry)
}
/** The next load decides for itself — the handoff must not outlive its item. */
@Test
fun `loading an ordinary stream after a handoff restores the retry`() {
val decision = StreamRetryDecision()
decision.onLoad(nonResumable = true)
decision.onLoad(nonResumable = false)
assertTrue(decision.playerMayRetry)
}
}
+6 -1
View File
@@ -17,6 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use super::stream_end;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{audio_settings_jni_payload, AudioSettings};
use crate::utils::conversions::seconds_to_ticks;
@@ -348,6 +349,9 @@ impl PlayerBackend for ExoPlayerBackend {
let artwork_url = media.artwork_url.clone();
// Convert duration from seconds to milliseconds
let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
// A stream the player could only "retry" by restarting it must not be
// retried by the player at all — recovery is ours. (DR-203)
let player_retry_restarts_stream = stream_end::player_retry_restarts_stream(media);
// Update local state
{
@@ -454,7 +458,7 @@ impl PlayerBackend for ExoPlayerBackend {
let result = env.call_method(
&self.player_ref,
"loadWithMetadata",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Z)V",
&[
JValue::Object(&url_jstring),
JValue::Object(&media_id_jstring),
@@ -465,6 +469,7 @@ impl PlayerBackend for ExoPlayerBackend {
JValue::Long(duration_ms),
JValue::Object(&media_type_jstring),
JValue::Object(&subtitles_jstring),
JValue::Bool(player_retry_restarts_stream as u8),
],
);
+1 -2
View File
@@ -1670,8 +1670,7 @@ impl PlayerController {
/// audio-only handoff, the only place a length-less progressive transcode is
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
fn is_audio_only_video(item: &MediaItem) -> bool {
item.media_type == MediaType::Audio
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
stream_end::is_audio_only_video(item)
}
/// Claim a resume attempt for the current stream, returning the absolute
+131
View File
@@ -22,6 +22,8 @@
//! not a finish — and the right response is to re-open the stream where it died,
//! which is the "buffer and resume" the user expects.
use crate::player::media::{MediaItem, MediaSource, MediaType};
/// How far short of the item's runtime a stream may end and still count as a
/// natural finish.
///
@@ -45,6 +47,53 @@ pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
/// either the resume made progress, or a different item is loaded.
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
/// A video item played through the native *audio* path — i.e. the background
/// audio-only handoff, the only place a length-less progressive transcode is
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
///
/// TRACES: UR-040 | DR-129, DR-203 | UT-117, UT-200
pub fn is_audio_only_video(item: &MediaItem) -> bool {
item.media_type == MediaType::Audio
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
}
/// Would the *player's own* load-error retry restart this stream from its
/// beginning? If so the retry must be switched off and recovery left to
/// [`crate::player::PlayerController::recoverable_error_resume`].
///
/// ExoPlayer resumes a failed load in place only when it knows where "in place"
/// is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
/// content length is known *or* the extractor produced a seek map with a
/// duration, and otherwise treats the source as live — the data at the URL is
/// assumed to have changed, so it resets every sample queue and re-requests the
/// URL from offset 0.
///
/// The handoff transcode satisfies neither condition: it is chunked (no
/// `Content-Length`) and a live mp3 encode carries no `Xing` header, so the
/// player reports its duration as unset — visible in logcat as every position
/// tick reading `<position> / 0.0`. Its URL carries `StartTimeTicks` = the
/// handoff point, so restarting it from offset 0 restarts the *episode* at the
/// handoff point, and playback then runs on from there. Nothing surfaces: no
/// error, no `STATE_ENDED`, so neither the truncation path nor the error path of
/// DR-129 is consulted, and the app's only sign of it is a position that jumps
/// backwards. That is the "it randomly jumps back to where audio-only started"
/// the user sees, and how random it is depends on whether a network blip happens
/// to land while a load is in flight rather than while the ~50s buffer covers it.
///
/// A retry that can only restart the stream is worth less than no retry at all:
/// declining it turns the silent rewind into a recoverable error, which
/// `recoverable_error_resume` answers by re-opening the stream at the position
/// playback actually reached (`StartTimeTicks` rewritten, backoff and attempt
/// budget included). Every other source keeps the player's retry: a static file
/// and an HLS playlist both declare their timeline, so ExoPlayer resumes them
/// exactly where the load failed.
///
/// TRACES: UR-040, UR-004 | DR-203 | UT-200
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn player_retry_restarts_stream(item: &MediaItem) -> bool {
is_audio_only_video(item) && matches!(item.source, MediaSource::Remote { .. })
}
/// Did this end-of-stream happen far enough short of the item's runtime to be a
/// truncation rather than a finish?
///
@@ -202,6 +251,88 @@ impl ResumeTracker {
mod tests {
use super::*;
use std::path::PathBuf;
/// The background-audio handoff item, as `player_enter_background_audio`
/// builds it: the episode replayed as AUDIO off a remote stream URL whose
/// `StartTimeTicks` is the handoff point.
fn handoff_item() -> MediaItem {
MediaItem {
id: "ep2".to_string(),
title: "Episode 2".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: Some(1500.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: "http://s/Audio/ep2/universal?Container=mp3&StartTimeTicks=1250000000"
.to_string(),
jellyfin_item_id: "ep2".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: Some("series1".to_string()),
server_id: None,
}
}
/// The reported bug: a load error on the length-less handoff transcode let
/// ExoPlayer "retry" the only way it can — from offset 0 — which re-opens
/// the URL at its `StartTimeTicks` and drops playback back to the handoff
/// point, silently. This item must never be left to the player's own retry.
#[test]
fn test_handoff_transcode_must_not_use_the_players_own_retry() {
assert!(player_retry_restarts_stream(&handoff_item()));
}
#[test]
fn test_music_keeps_the_players_retry() {
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
// ranges, so ExoPlayer resumes it where the load failed.
let track = MediaItem {
item_type: Some("Audio".to_string()),
..handoff_item()
};
assert!(!player_retry_restarts_stream(&track));
}
#[test]
fn test_video_keeps_the_players_retry() {
// An HLS playlist declares its segments, so a failed segment load is
// retried at that segment, not at the start of the episode.
let video = MediaItem {
media_type: MediaType::Video,
..handoff_item()
};
assert!(!player_retry_restarts_stream(&video));
}
#[test]
fn test_downloaded_episode_keeps_the_players_retry() {
// A local file has no length problem and no network to lose.
let local = MediaItem {
source: MediaSource::Local {
file_path: PathBuf::from("/data/ep2.mkv"),
jellyfin_item_id: Some("ep2".to_string()),
},
..handoff_item()
};
assert!(!player_retry_restarts_stream(&local));
}
#[test]
fn test_end_near_duration_is_a_natural_finish() {
// Episode runtime 25:00, stream ended at 24:56 — that is the end.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.8.0",
"version": "0.8.2",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",