Files
jellytau/CHANGELOG.md
T
dtourolle 30a9cb32f5
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 27s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
Build & Release / Run Tests (push) Successful in 18m0s
Build & Release / Build Linux (push) Failing after 17m37s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 31m6s
Build & Release / Create Release (push) Skipped
chore(release): v0.10.0
Two user-visible features -- the app can update itself, and it can hand
you a redacted diagnostics bundle -- plus the supply-chain, release
integrity and build work behind them.

A minor bump rather than a patch, matching how v0.9.0 was cut off v0.8.2
for a single new user requirement. This one carries two (UR-077,
UR-078), both with UI in Settings.

The CHANGELOG entry is the release body now: build-release.yml publishes
the `## v0.10.0` section and fails if it is missing, instead of the fixed
block of install instructions that every release from v0.0.1 to v0.9.1
carried verbatim.
2026-08-21 22:32:04 +02:00

77 KiB
Raw Blame History

Changelog

All notable changes to JellyTau are documented here.

Entries are grouped by the capability they change, not by commit. Requirement IDs in parentheses point at docs/requirements.md; the generated trace matrix lives in docs/traceability.md.

For how long each fixed defect had been shipping before it was found, see docs/defect-windows.md.

v0.10.0

Two things you can see, and a great deal of work on how this project builds and ships itself. The app can now update itself, and it can tell you what it did when something goes wrong — both of which existed as gaps rather than as bugs, which is why they lasted so long.

Changes

  • JellyTau can update itself. Anyone who installed an AppImage or ran the Windows installer was frozen on that version permanently: nothing in the app ever mentioned that a newer one existed, and the release page was the only announcement. Settings → Updates now checks, shows what changed, and installs and restarts on request. Each download is verified against JellyTau's signing key before anything is installed, so a substituted file is refused rather than run. Android is deliberately not wired to this — an app may not replace its own APK, that is the system installer's job — and is given a link to the releases page instead of a button that would fail. (UR-077 → DR-217)

  • You can export a diagnostics bundle. Until now the app forgot everything it had done the moment it closed. Logs went to standard output, which nobody sees when launching from a desktop icon, and on Android went nowhere at all — so the backend was invisible on the platform where the hardest playback bugs live. A crash left nothing behind. Logs are now kept in a size-capped file that survives a restart, a crash is recorded before the app dies, and Settings → Diagnostics exports the lot as one file to attach to a bug report. Access tokens and passwords are stripped before anything is written to disk, not merely before it is exported. Nothing is transmitted anywhere; you attach the file yourself. (UR-078 → DR-218)

  • Linux gets an AppImage again. The release notes have advertised one for months while the build never produced it — the packaging step looked for the file, found nothing, and said nothing. (DR-217)

🐛 Fixes

  • Releases no longer ship every Windows installer ever built. Every release from v0.1.0 to v0.8.2 carried its predecessors': sixteen installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. The build directory is never cleaned and the build machine reuses it, so each release collected whatever was left behind. It went unnoticed for eight months because nothing looked wrong — the files were real and the page merely looked busy. The stale files have been removed from the published releases, the build now clears that directory first, and a check refuses to publish a release containing an artifact from a different version. (DR-220)

  • Release notes now say what changed. All 35 previous releases published the same block of generic install instructions, whose "What's New" section was a link to a file that does not resolve from a release page. Every release page now carries its own entry from this changelog, and the past ones have been filled in. (DR-219)

🔒 Security and supply chain

  • Dependencies are now checked against a vulnerability database on every build. They never had been. The first run found eight vulnerabilities and one unsoundness in the Rust dependency graph — all of them fixed by an update nobody had a reason to run. Licences are checked against an allow-list too, so nothing gets redistributed inside a release that does not permit it. (DR-216)

  • Every release publishes checksums and a bill of materials. SHA256SUMS lets you verify a download (sha256sum -c SHA256SUMS); the SBOM lists what went into the build, so "does this release contain ?" has an answer that is not "rebuild it and find out". (DR-216)

  • Builds are reproducible again. Every CI job named a container image tag that was rewritten in place, so rebuilding an old release did not necessarily rebuild the same thing. Jobs now pin an immutable tag. The one dependency that comes from a git branch rather than a package registry is pinned to an exact revision, closing a path by which new upstream code could arrive unreviewed in a library linked into the player. (DR-216)

🧹 Under the hood

  • Formatting, linting and type-checking now run in CI. All three were configured and enforced by nothing: 199 files did not match the project's own formatter, a type error could sit on the main branch until somebody cut a release, and the test-coverage command had been broken for months by a dependency mismatch. Coverage now has a floor that only moves up. (DR-215)

  • The traceability matrix counts requirements implemented by configuration. Several carried the necessary annotations and were being counted as uncovered because the extraction tool only read source files. (DR-215)

  • The project now has a security policy, contribution guide, code of conduct, issue and pull-request templates, and an operations document covering the builder image, the release secrets, and what losing the signing key would mean.

  • The app framework moved from Tauri 2.9.5 to 2.11.5. Nothing about this is visible in use, but it is worth recording that it did not go quietly: the windowing layer beneath Tauri quietly stopped publishing the Android JavaVM and application handle that this app's credential storage had been reading for its whole life. Nothing here had changed; a side effect several dependencies down had simply gone away, and the app aborted on launch on every Android device. JellyTau now sets that handle itself rather than relying on someone else to do it. Caught by installing on a real tablet before release — no test suite runs the app. (UR-012 → DR-223)

v0.9.1

A one-line fix to the home screen, released on its own because it is the kind of small wrongness you notice every time.

🐛 Fixes

  • Swiping the hero banner now buys you a full six seconds. The rotation timer was started once when the banner appeared and then left alone, so a swipe, arrow or dot tap inherited whatever was left of the running countdown — swipe five and a half seconds in and the banner moved on half a second later, before you had read the title. Any manual change now restarts the countdown from that moment. (UR-034 → DR-038)

v0.9.0

An audit release. One new setting you asked for, two naming bugs that only ever showed in builds a developer never looks at, and a large amount of tidying that should be invisible in use.

Note for anyone upgrading a Linux package: the Debian/RPM package is now called jelly-tau rather than jellytau (the packager derives it from the app name). It declares the rename, so apt/dnf will replace the old package rather than install a second copy. The command is still jellytau.

Changes

  • You can now hide library folders from music browsing. Pick the folders to exclude in Settings; they disappear from albums, artists, genres, search and the home rows alike. This replaces a filter that dropped anything named "Podcasts" — one person's library layout compiled into the app, which meant an album genuinely called "Podcasts" vanished while a podcast folder named anything else stayed. Exclusion now matches on the folder itself, is decided in one place rather than at the six screens someone remembered to filter, and defaults to excluding nothing. (UR-076 → DR-209)

  • The app is called JellyTau again. The Android release build showed jellytau under its icon, and the Linux and Windows packages carried the same lowercase name. The debug build has always overridden the label to "JellyTau Debug", so the install a developer looks at every day was the only correctly cased one and nobody saw it. (DR-214)

  • The RPM package is published. It has been built by every release since Linux packaging was added, and never copied out of the build — so it existed, cost build time, and reached nobody. (DR-214)

  • Linux and Windows packages carry their own metadata. Publisher, copyright, category, description and licence were all absent, so the packages installed with no maintainer and no description. The hand-written Arch package had all of it; only the generated packaging was missing it. (DR-214)

🔒 Hardening

None of these were reachable in normal use — the app refuses plain-http servers, Android blocks cleartext, and the webview runs under a CSP that bars inline script — so they are consistency fixes rather than incidents. Each one had the correct pattern already in the same file, a few lines away.

  • Thumbnail cache writes stay inside the cache directory. The filename was built from three values but only one was sanitised, and joining a path does not fold .. or keep the base when handed an absolute path. (DR-210)

  • Download paths stay inside the download directory. A correct sanitiser already existed, but the command that queues a download accepted a raw path, so the guard could be routed around rather than being absent. (DR-211)

  • Query and URL values are bound and encoded, not pasted in. The offline item-type filter built SQL by string formatting while its sibling query used placeholders, and browse URLs left values unencoded while the genre parameter next to them was encoded properly. Volume is also range-checked at the command boundary instead of relying on each player backend. (DR-212)

🛠 Development

Nothing here changes the app, but the previous release's audit found the tooling claiming more than it delivered, and this is the repair.

  • The frontend has a real logger. 484 console calls shipped to users and ran on every device; the Rust half has had levelled logging with a runtime override since the beginning. There is now a matching facade — quiet in release builds, verbose in debug ones, with warnings and errors never suppressed and localStorage able to turn the volume up in a shipped build to diagnose a problem. (DR-204)

  • The traceability matrix is navigable. Every one of its ~2,800 file links was broken: the generator wrote repo-root paths into a file that lives in docs/. The document the whole traceability system exists to produce could not be clicked through, and had no test. Both are fixed, and a link checker now fails the build on a dead documentation link. (DR-093, DR-208)

  • The Rust toolchain is pinned. Developer machines and CI were five releases apart, which meant a clean cargo clippy locally proved nothing about CI — the same tree measured zero warnings on one and three on the other. With both sides on the same compiler, clippy is now a hard gate instead of advisory. (DR-206)

  • The frontend has a linter and formatter, its first — the Rust half has had cargo fmt --check and clippy in CI for a while. A pre-commit hook runs the fast checks, so the "before committing" list is enforced rather than remembered. (DR-205, DR-207)

  • Containerised builds no longer leave root-owned files in the working tree, which had accumulated to the point of breaking cargo clean and, eventually, cargo build itself. (DR-213)

  • Removed: a webdriverio end-to-end suite that had not run in seven months and was wired into nothing, and a frontend validation module whose six exported functions had no caller outside their own tests — which made it read as covered input validation while guarding nothing.

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 requirements and against current Android/Tauri practice. Most of it is invisible in use; three things change behaviour you can see, listed first.

Changes

  • The app no longer backs its data up to your Google account. It never should have: allowBackup was on by default, which sent the library catalogue and watch history off the device — and the credentials went with it in a form that could never be read again, because they are encrypted under an Android Keystore key and Keystore keys are never backed up. Restoring onto a new phone therefore produced ciphertext with no key: an authentication failure with no explanation. Backup is now off, for device-to-device transfer as well as cloud (a separate channel with the identical failure), and an unreadable credential blob is now treated as "logged out" rather than an error, so the next sign-in repairs it. (UR-012 → DR-135)

  • The app no longer offers itself as an Android TV app. (This is about the app icon on a TV device's home screen — your TV shows library is untouched.) It advertised a leanback launcher entry without any of what makes a TV app work — no D-pad focus model, no banner, and a missing touchscreen declaration that fails Play's TV validation. Launching it on a TV would have landed you in a UI you could not navigate. It can be re-declared when TV support is actually built.

  • Lockscreen skip scrubs a film instead of leaving it. While a video's audio plays in the background, the skip buttons jump 30 seconds forward and 10 seconds back, rather than advancing to the next episode. There is no "next track" inside a film, and pressing skip to re-hear a line should not eject you from what you are watching. Music is unchanged: skip still moves through the queue. (UR-040, UR-006 → DR-201)

🔒 Security

  • The webview now runs under a Content-Security-Policy. It had none, so any script reaching the web layer inherited the full IPC surface. script-src is now 'self' with no inline or eval, and plugins and frames are refused outright. (UR-071 → DR-198)

  • The webview stops undoing the network security config. It set a blanket cleartext opt-in by hand, along with file and content access it never used — defeating the config that exists to block exactly that, and whose own comment warned against it. (UR-071 → DR-199)

  • The asset protocol no longer reaches the database or the credential store. Its scope was the whole app data directory; it is now the one subdirectory it serves. (UR-012, UR-071 → DR-198)

🐛 Fixes

  • A credential store that could not be read is now recoverable. The decrypt failure surfaced as a hard error rather than a logged-out state, so the app got stuck instead of offering the login screen. (UR-012 → DR-135)

🔧 Internal

  • CI now enforces the checks the contributor rules already required — cargo fmt --check and clippy — neither of which had ever run there. The traceability gate was also raised from 50% to 82%, a floor low enough that half the matrix could rot before it fired, and a new check fails the build on a requirement ID that no longer exists.
  • Twelve requirements marked "Done" carried no implementation trace at all; they are now tagged, and stale integration requirements that named a backend never built have been re-scoped to the ones that actually deliver them. Coverage moved 86% → 90%.
  • The Rust lint backlog is cleared (51 warnings → 0), and a flaky test that intermittently reddened CI is fixed — it was paying a cold module-transform cost inside a test body, not waiting on a timer.

v0.7.0

Changes

  • Native Android video is now the default. Video decodes on the device's hardware decoder instead of the built-in web player, which is easier on the battery and lets picture-in-picture show the video rather than the app. The default had been held back deliberately since the picture defects were fixed, because returning from background audio left playback dead on that path; both blockers below are fixed and verified on a device, which is the standard this default has been held to since it last shipped early. The Settings toggle remains, now as the fallback to the web player, and an explicit choice still wins in both directions — anyone who turned it off keeps it off. (UR-003, UR-004 → DR-188)

🐛 Fixes

  • The letterbox bars stop showing things that are no longer there. With native video on, the padding around the picture kept whatever had last been drawn in it: the previous frame flashing on rotation, a ghost copy of the control bar stranded at the top of the screen, each new clock digit drawn over the one before it, and the sleep-timer and quality menus leaving their imprint after closing. One cause under all of it — nothing painted those bars. The window surface is opaque, and for an opaque surface Android's renderer skips clearing the damaged region and assumes the view hierarchy covers every pixel; the video view covers only the letterboxed rect, so the bars were the window background's alone to paint, and enabling compositing had cleared that background to transparent. Three earlier attempts missed because they aimed at the window's rotation animation and at video-frame retention — which is also why the artefact reproduced standing still, with no rotation involved. (UR-003, UR-066 → DR-194)

  • Returning from background audio brings the picture back. On the native path, coming back from the lockscreen left a black screen: a play overlay pinned at 0:00 and a play button that did nothing. Nothing had crashed — the transition was simply dropped. The two render paths resume by different means, and only one of them was performed: the web player reloads from its stream URL, while the native player owns no element and nothing watches that URL on its behalf, so it has to be handed the item again explicitly. It now is, at the position the audio reached. (UR-040, UR-003 → DR-196)

  • Next Up stops repeating what Continue Watching already shows. The same episode could occupy both home rows at once. (UR-023 → DR-197)

v0.6.0

🐛 Fixes

  • Android native video actually shows a picture. It shipped once as audio with no picture and was reverted with the compositing named as the suspect (DR-172). The compositing was not at fault; five independent defects sat between ExoPlayer and the screen, each able to produce that symptom alone. The app shell painted over the video surface through a CSS rule targeting [data-app-shell], an attribute no component had ever set in any commit (DR-185). The poster/title card had no way to lift on a path that renders no <video> element, so a black card covered the surface for the whole session (DR-182). The JavaScript bridges were installed by a 500 ms tree walk that raced the page load — and lost permanently when it lost, because the re-injection guard then declined to retry — so setTransparent(true) could never arrive (DR-183). The SurfaceView was never detached, leaking one per video and leaving picture-in-picture's gate stuck open (DR-184). Verified on a device: logcat now carries WebView transparent = true and Marking media ready with video on screen, the pair the original investigation went looking for and could not find. (UR-003, UR-004, UR-041 → DR-182, DR-183, DR-184, DR-185)

  • Play and pause reach the player that is actually rendering. Transport did nothing on the native video path — from the on-screen tap, from the control bar, and from a direct command invocation — while seek and skip kept working, because those decide elsewhere. Rust routes play/pause to the webview <video> whenever it believes one is active, and the player route mirrored element state into that belief unconditionally, including from a ten-second progress interval. So on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every intent was emitted at something that was not there. The mirror now lives where useHtml5Element is known. This also explains the flashing transport controls, since they key off the play state that was being contradicted on every tick. (UR-005, UR-003 → DR-193, DR-195)

  • The player's controls hide themselves on a touchscreen. The auto-hide timer was armed only from mousemove, which a touch device never fires, so the control bar stayed over the video for the whole film. It is now armed on entry and on every touch, and pinned open while paused, seeking, or with a menu open. (UR-003, UR-066 → DR-189)

  • The system bars go away with the player. Immersive mode had exactly one caller — the fullscreen button — so opening a video left the status and navigation bars painted over it until the user pressed a control most never press. (UR-066, UR-003 → DR-187)

🔬 Internal

  • Native video presents through a TextureView rather than a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it, and Android's own graphics documentation warns that overlays do not composite reliably above one. (UR-003, UR-004 → DR-192)

  • Native Android video remains opt-in, and is not yet the default. Turning it on surfaced a further unverified path: returning from background audio is implemented only for the webview element, so playback stays dead on the native path (DR-190, proposed). Rotation still needs device confirmation (DR-194). (UR-003 → DR-188)

v0.5.5

Features

  • Library artwork is laid out as a mosaic instead of cropped to one box. The library overview and the home shortcut strip showed three different artwork shapes — square music covers, 16:9 backdrops, 2:3 posters — in grids that pick one box and crop everything to it; the home strip lined its row up by cutting the music covers down. Both surfaces now justify rows to a shared height with each tile as wide as its own artwork, packing from the decoded aspect ratio and committing one debounced batch so the grid does not reshuffle as artwork lands. The last row is deliberately left unstretched, so one leftover tile does not inflate into a banner. Favourites also gain a tile per category beside the library it belongs to — which collection type maps to which category is Jellyfin vocabulary, so it is derived in Rust rather than rebuilding the exact leak SearchScope::item_types was extracted to close. (UR-075, UR-067 → DR-163, DR-164)

🐛 Fixes

  • The server no longer burns subtitles into the picture. Reported as "subtitles are shown even when off", with no toggle in the app clearing them — because they were never the app's subtitles. PlaybackInfo omitted SubtitleStreamIndex, which does not mean "none": the server then honours the source's own default flag, and on the reported episode that default was a PGS bitmap track, which cannot go out as a sidecar. So it composited the track onto every frame. The cost landed on the video: burn-in rules out remuxing, so an HEVC stream that needed only its audio transcoded was re-encoded frame by frame, which the server could not sustain — playback stalled every few seconds and seeks took five to nine seconds to draw a frame. The negotiation and the stream URL now both ask for -1 and advertise every text format the app can render as External, and the picker offers only subtitles the app can actually draw, with the codec verdict decided in Rust and carried across the boundary. Nothing is lost: the app already fetches text tracks and draws them itself. (UR-020, UR-004 → DR-176)

  • Switching bitrate mid-film no longer stalls playback. Jellyfin keys a transcode job by device and play session, but every stream URL carried the same hardcoded DeviceId and no PlaySessionId at all — so a second stream for an item was indistinguishable from the first and nothing ever stopped the old ffmpeg. The server served the new playlist and then answered 400 for its segments. Re-opening a stream is not rare: a quality switch, a transcoded seek and an audio-track switch all do it. Each open now mints a session id and stops the job it supersedes, in the URL builder so every re-open path is covered by construction. Two client faults that made the same incident worse go with it: the fatal-HLS-error handler double-counted the transcode seek offset, so past roughly halfway through a film any transient network error read as end-of-stream and autoplay skipped to the next item; and the HTML5 reload primitive resolved on its own timeout, reporting success for a reload the server never served. (UR-074, UR-004 → DR-177)

  • Downloading an album gets the whole album. download_album read its track list from the local catalog cache, but Jellyfin does not return AlbumId on every listing endpoint, so tracks cached from one of those were invisible to the query — three albums in the reported database had it NULL on every track. The frontend then resolved stream URLs from its own list and paired them with the returned rows by position, so a row could be handed another track's URL and anything past the end of the shorter list never started. The same missing link hid downloaded tracks under their album offline. The operation now belongs to Rust end to end — the server is asked what the album contains, the album link is written onto every track queued, URLs resolve in the backend scoped to the rows just queued, and each track gets its own file so a title repeated across two discs stops overwriting itself. Re-tapping download on a broken album heals it. (DR-173)

  • Playback positions reported to Jellyfin are real ones. Returning to the foreground before the background-audio stream had started playing handed the frontend 0.0s, so the episode restarted from the beginning and the stop report wrote that zero to the server as the resume point. The same blind spot covered webview-rendered media, whose native position is a permanent 0 — 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s after the frontend had correctly reported 15:22 for the same episode. Position is now the maximum of the backend's reading, the last position webview media reported and the handoff base (at most one is ever meaningful); zero-position stop reports are withheld, since a zero is never information and only ever destroys a real resume point; and progress is reported from the controller's own ticks — /Sessions/Playing/Progress had previously been requested zero times in those 35 minutes. A finished audio-only episode is also reported stopped at its runtime so Jellyfin's 90% rule marks it played, which nothing else could do once the webview was suspended. (UR-005, UR-025, UR-040, UR-071 → DR-178, DR-179, DR-180)

  • The streaming quality button uses a speedometer icon, not the cloud-download glyph that read as a download action.

v0.5.4

🐛 Fixes

  • Native Android video is opt-in again — enabling it by default shipped sound with a blank screen. The decode path was never at fault: ExoPlayer ran and fed a live SurfaceView the whole time, behind an opaque page. The step that clears the layers above it never took effect — the WebView was logged going transparent = false and never = true. This is precisely what the flag existed to contain, and v0.5.3 had turned it on so picture-in-picture would have a real surface to shrink. Reverting costs nothing that matters: PiP drives from the WebView <video> (DR-160) and working video outranks PiP showing a native surface. The flag stays in Settings, described as incomplete rather than as a performance win. Fixing the compositing is the prerequisite for trying the default again. (DR-172)

v0.5.3

Features

  • Streaming bandwidth can be capped at a chosen bitrate ceiling. Video streams opened at a fixed allowance nobody could change — 20 Mbps on the HLS URL and in the negotiation, and a device profile that let the server direct-play a source of any size — so on a metered or slow connection there was no way to spend less. StreamingQuality is a ladder (Original, 20/10/8/4/2/1 Mbps, 720 kbps) where each step bundles the total ceiling, the audio share of it and the resolution that budget can carry; those are Jellyfin encoding vocabulary, so they live in Rust and the frontend only names a variant. The cap reaches the negotiation, not just the transcode URL — max_static_bitrate is what makes the server refuse to direct-play a file fatter than the cap, and without it a 30 Mbps remux is handed over untouched and every downstream parameter is moot. Settings holds the durable default (persisted, unlike the rest of VideoSettings — a limit set for a metered connection must not silently revert on the next launch); the in-player menu is the "this film, this connection" override, which re-opens the stream and resumes at the current position. (UR-074 → DR-162)

🐛 Fixes

  • Four separate defects behind "downloads are still flaky". Libraries mixed their media — cached items carry no link back to their library, so the library branch matched a clause asserting only that the library exists, listing films under Music and albums under TV; the query deciding which libraries appear already had the right rule, and the two now share one constant (DR-167). Pause and resume did nothing: pause_download wrote a status and stopped there with no cancellation anywhere in the stack, so the streaming task ran on and overwrote the row, and resume_download flipped a row to pending without pumping a queue that is not a poller. A per-download stop flag now really stops the worker, keeping the .part file that resume continues from (DR-168). Partial files were never reaped, because the writer named its sidecar with with_extension("part")movie.mp4 became movie.part — while every cleanup path deleted movie.mp4.part (DR-169). And bitrate downloads corrupted themselves: a transcode is served chunked and cannot byte-seek, so the server ignored Range and answered 200 with the whole stream while the worker appended it anyway, concatenating a full copy per retry. The response now decides — append only on 206, otherwise truncate and start over (DR-170).

  • A downloaded video keeps audio the device can actually decode. original quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD track came down untouched and the webview had nothing to play it with. The download URL is also built against the media source actually chosen rather than the item's default. (DR-171)

  • A batch of reported UI and playback bugs. Pages no longer inherit the previous page's scroll position — the shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change, while SvelteKit restores a window scroll this app never uses; offsets are now recorded per route and per container, reset going forward and restored on Back (UR-072 → DR-156). Full-screen video on Android hides the system bars: requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video (UR-066 → DR-157). A watched toggle appears on the episode row, season header, series and movie hero and the Episode Focus View — both backend halves already existed with no caller (UR-073 → DR-158). The background-audio handoff stops leaking its relative timeline: the correction was applied in two display-only places while progress reports, the frontend and media3's own seeks all treated the relative timeline as absolute, each crossing losing exactly the base (DR-159). And picture-in-picture works on the path that actually plays video — it had demanded a native ExoPlayer surface, which sat behind a flag defaulting to off, and now accepts the WebView <video> (DR-160).

  • 0.5.x can install over v0.5.2 on Android. v0.5.2 shipped versionCode 5002 under an earlier minor*1000 scheme; the minor*100 formula that replaced it yields 1502 for that same version and 1503 for 0.5.3 — lower than what is already installed, so Android refuses the update as a downgrade, and every 0.5.x release built from that script was un-installable for anyone on v0.5.2. This is the exact failure the guard was written to prevent; its floor had gone stale, because the highest code the formula produces is not the same as the highest code in the field. The scheme widens to 10000 + major*1000000 + minor*1000 + patch, and the guard test now pins "clears what shipped" rather than a literal that can go stale again.

📋 Documentation

  • The traceability matrix is regenerated (87% coverage, 265/303, no orphaned IDs), and three comments still describing native video as defaulting to off are corrected — one of them load-bearing, sitting directly above a load() that returned true.

v0.5.2

🔧 Internal

  • Gradle ships in the builder image instead of being downloaded per build. The release APK job died at the Gradle wrapper step after the 11-minute Rust compile had already succeeded, on a socket exception mid-transfer. tauri android init regenerates a wrapper pointing at services.gradle.org, so every Android job re-downloaded ~130MB — slow on a good day, a hard build failure when the CDN drops the connection, and a standing violation of the rule that every build tool already lives in the image. The sync script now repoints the regenerated wrapper at the local distribution, parsing the version the wrapper actually requests so a future Tauri bump logs a miss instead of pointing at a missing file. Dev machines are untouched.

v0.5.1

🐛 Fixes

  • Resume position crosses devices. The resume check reads the local user_data row and nothing else, but the only path by which server UserData lands in that table mirrored is_favorite alone and returned early whenever that field was absent — exactly the shape of an ordinary watched episode. The position was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever this device last saw, or offered no resume at all. The mirror now carries the position under the same conflict rule, so a local position still waiting to be pushed is never pulled backwards. Mirroring alone was not enough: get_item — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit, which is why browsing a season picked up other devices' state while opening the episode directly did not. (DR-155)

  • A watch position the server could not be told about is queued rather than lost. The sync queue and its drain were built, tested and running, but the stop-report path never fed them — the hybrid repository passed reporting straight through to the online repository, and on failure the error surfaced to a frontend catch whose own comment read "could queue, but for now just log". Closing a video while the server was unreachable lost the resume point outright. The pending row for an item is superseded in place rather than appended to, since progress reports every 10s would otherwise add a row per tick — the unbounded queue the drain exists to prevent. Queueing is best-effort and never fails the command: the local position is already saved. (DR-154)

  • Android's native video path resumes at the saved position. Two layers each assumed the other did the seek: the only code acting on initialPosition was an HTML5 <video> event handler, and canplay never fires where there is no <video> element; the native adapter's own branch merely recorded the number, claiming the backend seeks internally, which it does not; and the player never called that branch at all. The frontend therefore believed it had resumed — the seek bar showed the resume point — while ExoPlayer played from the beginning. Live streams are excluded, since seeking knocks the HLS window off its live edge.

  • Downloading at a chosen quality honours it. The download URL builder spelled the transcode parameters videoBitrate/audioBitrate, but Jellyfin binds videoBitRate/audioBitRate — with a capital R. Query-key binding is case-insensitive, so this is not a casing preference: the lowercase-r form is a different token that fails to bind, and the server discards it without error and stream-copies the source. Picking "480p" produced an original-quality file with no failure surfaced anywhere, while maxHeight and videoCodec were unaffected — which is why the height cap applied and the bitrate cap vanished. The presets also set allowVideoStreamCopy=false to force a real re-encode. The pre-existing unit tests asserted the broken spellings, so they passed against broken code. (UR-071 → DR-123)

  • A series resumes after the furthest-watched episode, not at the first gap. A viewer who skipped the pilot but is three seasons deep was sent back to S1E1 — the gap was a deliberate skip, not where they stopped. It read as flaky rather than consistently wrong because that rung only fires when the server's Next Up yields nothing, and its errors are swallowed, so any unreachable-server moment silently degraded to an empty list: same series, same watch state, different answer depending on one request's outcome. Season crossing comes free from the already-flat series ordering, and specials stay last so a watched special cannot mark a show finished.

  • Volume control returns to the local speaker when a remote session stops. player_stop's remote branch sent Stop to the session and returned without touching the playback mode, so the manager stayed in Remote; and volume routing was torn down at a single call site, so every other exit from remote mode leaked the Android volume provider. Routing is now derived from the transition itself, covering the frontend disconnect and local-playback-start paths too.

  • A new album appears once in Recently Added, not once per track. Importing a 14-track album filled the whole row with that one album. Both code paths had the same symptom from separate causes: online, Jellyfin's /Items/Latest defaults to GroupItems=false; offline, the downloaded-items CTE deliberately matches leaves and their container, which is right for browsing and wrong here. Items with no container are unaffected either way.

  • Uniform card heights in the home Your Libraries row. Artwork aspect ratio is derived from the item, so a music library rendered square (144px) next to video libraries at 16:9 (81px), leaving the row ragged. The per-type ratios elsewhere are unchanged.

v0.5.0

Features

  • Android video can render on the device's own video surface. Settings → Video Playback → Native Video (experimental, off by default) hands decoding to ExoPlayer, which draws into a surface composited behind a transparent WebView, with the player controls layered on top of it.

    The backend had reported "this platform has a native video surface" on Android all along, but the frontend threw that answer away in two separate places, so the path had never actually run. Both are lifted. The setting can only ever suppress the backend's choice, never override it upward: turning it off forces the web player even where native is available, and turning it on does nothing on platforms whose backend never offered it — Linux cannot composite behind its webview, so it stays on the web player either way.

    Verified playing on a physical device. Still unverified: the mini-player transition, audio-track switching on the native path, and whether hardware decoding measurably improves battery or CPU — so the toggle stays off by default. (UR-003, UR-004 → DR-150)

🐛 Fixes

  • The video surface now reaches the screen at all. The player built its video surface, handed it to ExoPlayer, and then never added it to the view hierarchy, because the Activity reference it needed was never supplied — so native video would have decoded to a surface nobody could see, whatever else was fixed. This also silently disabled picture-in-picture for video, which gated on that same never-attached surface. (UR-003, UR-041 → DR-151)

  • Platform playback support is no longer guessed from the browser user agent. The frontend re-derived "does this platform decode audio natively" by string-matching navigator.userAgent — a second copy of a decision the backend already makes, free to drift out of step with the backends it was describing. The backend now reports its own capabilities and the frontend consumes them. (UR-003, UR-005 → DR-152)

🔧 Internal

  • The git tag is now the single source of truth for a release version. The version lived in four files that had to be edited in lockstep, and the release workflow rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, and the Linux job, which had no version step at all, shipped whatever happened to be committed. scripts/set-version.sh now writes all four from one argument and every release job calls it with the tag. The Android versionCode is derived in the same place, guarded by tests for the property that actually matters: it must increase monotonically and stay above the value already installed in the field, or Android silently refuses the update. (DR-153)

v0.4.8

🐛 Fixes

  • Video with an undecodable soundtrack now transcodes instead of playing silent. Advertising a webview-shaped profile (v0.4.7) turned out not to be enough: Jellyfin 10.11.5 enforces a direct-play profile's container and video codec but ignores its audio codec, offering an E-AC-3 track for direct play against a profile listing only AAC — and no CodecProfile or channel limit changes that. The client now checks the track it would actually be served against what its renderer can decode and forces the h264/AAC HLS transcode when it cannot, rather than trusting the negotiation. (UR-004 → DR-149)

v0.4.7

🐛 Fixes

  • Video plays with sound on devices that ship a Dolby decoder. The audio codec list sent to Jellyfin came from MediaCodecList, which describes ExoPlayer — but video does not play through ExoPlayer: it renders in the webview <video> element, which decodes far less. A phone whose vendor licenses Dolby therefore advertised ac3/eac3, got a direct play, and showed full picture with no audio, while a leaner device claimed neither codec, received an AAC transcode, and played the same file correctly. The video direct-play profile is now narrowed to what the webview can decode; audio-only playback is genuinely the native player's and keeps the full list, so music is not transcoded needlessly. (UR-004 → DR-148)

v0.4.6

Features

  • Downloaded video plays offline. Four separate defects each stopped it on their own. A completed download's file_path is already absolute — the worker rewrites it on completion — but the player rooted it a second time and handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4; audio was unaffected because it resolves the same column through Rust, which is why this read as a video-only fault (DR-133). The asset protocol was never enabled at all: convertFileSrc rewrites a path to asset.localhost unconditionally, but Tauri only answers that origin when the cargo feature and the config are both present, and neither was — which also silently defeated the cached-thumbnail path, whose soft fallback to the server copy hid the breakage whenever the server was reachable (DR-134). Tauri's asset protocol then answers a range-less request by reading the whole file into memory and only advertises Accept-Ranges from inside its range branch, so the first request never learns ranges exist and Chromium gave up after ~31s; local media now comes from a loopback HTTP server streaming bounded 4 MiB chunks, confined by a per-session token and to the app data directory, because loopback is shared between apps on Android (DR-137). And release builds set usesCleartextTraffic=false, so Android rejected the request before any I/O — a network-security config now exempts 127.0.0.1 only, and a remote server must still be HTTPS (DR-138).

    Known limitation: a download taken at original quality is a byte copy, so it can be any container — an AVI holding XVID is served correctly and refused by the webview regardless.

🐛 Fixes

  • A video queued from a media card no longer downloads as audio. download_item never recorded media_type, and the reconnect resolver read that NULL as 'audio', so a movie's URL was resolved by the audio builder and completed as an audio-only transcode. The item's own type now decides, and rows already downloaded that way are requeued on reconnect — prevention alone leaves them reading "downloaded" and still unplayable. (DR-135, DR-136)

  • Some videos no longer play with no sound. Jellyfin's MediaStream.Index is global across every stream in a media source, so index 0 is the video stream on virtually all files — and AudioStreamIndex=0 was sent as "the first audio track" on the HLS transcode URL, the background-audio handoff URL, the direct-play fallback and the negotiation body, asking the server to use the video stream as audio. Servers that honour it produce a picture with no sound; only those that silently correct the index hid it, which is why it surfaced as "some videos have no audio". The parameter is now omitted unless a track was actually chosen. (DR-140)

  • A multichannel track is no longer direct-played to a two-channel sink. MediaCodecList answers "can this device decode 5.1", which is not the question that decides whether anything is audible: a phone decodes AC-3 5.1 happily and still has two channels to play it out of. The profile carried no MaxAudioChannels, so the server was free to hand over the multichannel track — silence, or dialogue folded into surround channels that go nowhere. The route's actual channel count now bounds the profile; no codec is ever removed, so a device with genuine surround output keeps direct-playing it. (DR-141)

  • Video waits for audio focus instead of rolling silently. Video manages focus by hand, and all three outcomes of the request were treated as success — including REQUEST_DELAYED, which means the system is withholding our audio until it calls back. The picture rolled with no sound, indistinguishable from a broken stream. (DR-145)

  • The no-audio fallback picks a track the device can decode. When ExoPlayer selected no audio track, recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded here, so the override reinstated the silence it was meant to fix. (DR-146)

v0.4.1

🐛 Fixes

  • The lockscreen pause works while a video's audio plays in the background. The handoff starts native audio and only then tears the WebView <video> down — and that teardown fires a DOM pause the frontend reports like any other, which left the controller believing webview media was still the player. Transport stayed aimed at it: pressing pause on the lockscreen sent a control command to a <video> that no longer existed while the native player carried on, and the element's parting position report dragged the displayed time backwards. A handoff is now tracked explicitly, so it hands transport to the native backend and ignores what the dying element still reports. A pause made from the lockscreen also survives the return to the app, instead of being undone by the play state captured when the handoff began. (UR-040, UR-005 → DR-052, DR-097)

v0.4.0

Features

  • Favourites, across libraries. A /library/favorites page renders favourites from every library with All / Movies / Shows / Music scope tabs, reusing the standard grid so card shape still follows the media — a mixed All tab reads as posters, squares and thumbnails side by side. Home carries favourite rows below Recently Added, and a row with no items does not render at all, so a fresh install shows no empty rows. Server favourite state is mirrored into the local database as results are cached, so offline browsing sees the same favourites as the server; a toggle made offline is never overwritten by a stale server value before it has been pushed. (UR-067, UR-069 → DR-113, DR-114, DR-115, DR-117, DR-118)

  • Search answers from the local index. The instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag, so search and browse cannot diverge. The index also gained MusicArtist, Playlist and People — the very groups search sorts results into. Re-indexing moved from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog. (UR-065 → DR-108, DR-110, DR-111)

🐛 Fixes

  • Playback no longer restarts an episode at random on a flaky connection. Background audio-only playback of a video streams a progressive mp3 transcode over plain HTTP, which is chunked and so declares no length: when the connection dropped mid-episode, ExoPlayer saw end-of-input and reported STATE_ENDED, indistinguishable from the real end. The app ran its end-of-episode logic mid-episode and playback parked in STATE_ENDED, where the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — surfacing as "the episode randomly restarted". The item's runtime is now what decides: an end reported well short of it re-opens the stream where it stopped. (UR-040 → DR-129)

  • A network hiccup no longer kills playback outright. Music and video declare a length, so a cut connection reaches them as an error rather than a phantom end — and every error stopped the player. A recoverable error now gets one bounded attempt at re-opening the stream where it stopped, with a growing backoff, leaving the rest of the queue intact. On Linux, MPV additionally reconnects inside the demuxer so ordinary blips never surface at all, and EndFile(ERROR) — previously a bare log line that left playback halted while the UI still showed "playing" — is now reported and recovered. (UR-004, UR-040 → DR-129, DR-130)

  • The player no longer reads 0:00 as a track ends on Linux. MPV exposes time-pos and duration as properties of the loaded file, so at EOF it unloads and both stop resolving — reporting zero at exactly the moment end-of-file handling asks where playback reached. The last reading seen while media was loaded is now kept and used as the fallback. (UR-005 → DR-130)

  • Server-side deletions propagate to the local catalog. DELETE FROM items existed nowhere, so items removed on the server lingered locally forever. A post-crawl mark-and-sweep now removes them, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl. Separately, items_fts grew a full duplicate index on every catalog pass; it is now a real upsert, with a migration rebuilding existing indexes. (DR-110)

  • Android system bars and display cutout are handled correctly. (UR-066)

v0.3.0

Features

  • Opening a series lands on the current episode, not season 1. The viewer was dumped at the top of season 1, and the Play button played nothing at all: it resolved the first season by SortName and navigated to /player/<seasonId>, which the player route bounced straight back to the library. The backend could already answer "where is this viewer in this show" — get_next_up_episodes had accepted a series_id since it was written and no caller had ever passed one. pick_current_episode now resolves in progress → Next Up → first unwatched → the premiere, with the third rung serving offline where Next Up is always empty, and specials sorted after the numbered seasons. Seasons collapse to the current one, the current episode is badged and scrolled into view, and the hero button reads Resume S2E4 / Play S1E1. Seasons stop being a destination — a season URL redirects into the series — and the "More Episodes" strip spans the whole series, so a finale offers the next premiere instead of dead-ending. Six video routes collapse to two via ?view= tabs. Clear-history is wired to Jellyfin's recursive mark-unplayed, and refuses to run offline rather than diverging state the next sync would undo. (UR-062, UR-063, UR-064 → DR-101, DR-102, DR-103, DR-104, DR-105, DR-106, DR-107)

🐛 Fixes

  • Re-entering a video no longer opens the audio player. Closing a webview-rendered video deliberately emits no "stopped" state — that would break the autoplay handoff — and the direct-play path does not stop the backend on unmount, so the controller still reported that item as its loaded media. Re-entering took the "already playing, just show the UI" shortcut, which returns before a stream URL is fetched, and the render fell through to the audio surface. Mostly visible on Android, where video direct-plays; Linux transcodes and stops the backend on unmount. (DR-100)

v0.2.9

🐛 Fixes

  • A backgrounded audio-only episode advances instead of stalling. It stopped at the episode boundary and ExoPlayer parked in STATE_ENDED, where any later play intent — lockscreen, headset, Bluetooth reconnect — replays the ended item, surfacing as the episode randomly restarting. End-of-playback is dispatched from two places and they disagreed: the Android JNI callback carried the background-audio branch but can never reach it, because every load sets EndReason::NewTrackLoaded and nothing clears it, so the first real end consumes it and the decision is always Stop. The path that actually decides is the frontend's echo, which had no background-audio case at all and started a countdown whose advance is a goto() that cannot start audio while backgrounded. Both dispatchers now share one auto_advance_to_next_episode. (UR-040)

v0.2.8

🐛 Fixes

  • The video seek bar works by touch. Dragging or tapping the progress bar moved the thumb while playback stayed where it was — two touch-only defects, which is why the mouse-driven scrub tests never caught either. handleTouchMove kept running for touches the tap guard had already excluded, measuring against the previous gesture's start point, so a seek-bar drag produced a bogus vertical delta: read as a brightness swipe, it dimmed the screen to the floor and fired a spurious play/pause correction mid-drag. And the seek was committed only from change, which Android's WebView does not reliably fire for a touch interaction on a range input — so the thumb moved to the tapped position and no seek ever ran. (DR-099)

v0.2.7

🐛 Fixes

  • Video stopped pausing itself roughly once a second. The frontend facade short-circuited play/pause straight into the adapter, whose toggle() decided play-vs-pause by reading el.paused off the DOM — so the Rust controller never saw the intent and could not serialise competing ones. el.paused flips transiently while an element buffers or settles a seek, so two intents ~150ms apart read different values and performed opposing actions, a loop that needed no further input to sustain itself. On device the element was fully healthy at every pause (readyState=4, not seeking, not buffering, not ended), which is what ruled out a stall. The root cause was that Rust held no state at all for webview-rendered media, despite the comment above report_html5_state claiming the controller was the single source of truth. (DR-097)

  • Tap gestures act immediately, with no deferral timer. Tapping the video surface pause-looped — it unpaused and bounced back about a second later, while long-press unpaused fine, which pinned it to the tap path rather than the media pipeline. The handler deferred the first tap behind a 300ms double-tap window, but the timer callback cleared its own handle before invoking the toggle, and the click-suppression guard keyed on exactly that handle — so the guard was already open when Android's synthesized compatibility click arrived. There are only first and second taps: the first toggles, the second seeks and toggles back, so a double tap seeks while leaving the play state exactly as it was. A swipe now undoes the touchstart toggle, keeping brightness swipes from changing the play state. (UR-061 → DR-092, DR-098)

  • Three follow-on tap defects, each a second click target over the video. Pausing renders a full-screen play-overlay button, and Android's synthesized click arrives 30130ms later — by which time that button exists, so the click landed on the overlay, which called toggle with no guard and resumed immediately. Unpausing was unaffected because it removes the overlay: an asymmetry that pointed straight at it. Then the bottom play/pause button did nothing, because the gesture listener on the outer container and the button's own handler both fired and cancelled out. Then the control-surface guard added to fix that killed double-tap-to-seek, since the second tap lands on the overlay. The overlay is now marked as player surface — visually it is the video — and gesture rules live in pure, unit-tested functions. The suite gained a test that renders the real component and dispatches real touch events at whatever element is genuinely on top: the pure unit tests all passed throughout these four bugs, because each helper behaved exactly as specified and every defect was in the composition. (DR-098)

  • An HLS stall no longer produces an AbortError storm. Every interrupted play attempt was reported as a player error, but while a stream stalls hls.js nudges the element to recover, cancelling the pending play() promise — transient, yet it hit the error handler roughly once a second for the whole stall and left the UI stuck reporting paused. The in-flight attempt is now memoised so the UI and recovery share one call. (DR-096)

  • Seeks are clamped inside the media to stop an end-of-stream pause loop. (DR-095)

🔧 Internal

  • --device/--abi build only the architecture actually needed. An on-device test build compiled all four ABIs, throwing three of the four Rust compiles away, which dominated iteration time against a connected phone.

v0.2.1

🐛 Fixes

  • The traceability gate was dead and reported 158% coverage. It divided traced counts by hardcoded literals (UR/39, IR/24, DR/48, JA/3, total 114) that had fallen out of date as requirements grew to 211 — JA alone printed 800% — so the 50% threshold was mathematically unreachable and the job could not fail. Coverage could have collapsed to 30% behind a green tick. Real coverage was 86%: the number was fine, the gate was not. Both sides of the fraction are now derived from requirements.md, IDs are deduplicated (every UR is listed twice), a TRACES comment naming a deleted requirement is reported as orphaned rather than inflating the ratio, and a reading above 100% is a hard error rather than the condition that hid this. Verified empirically — forcing the threshold to 99% fails, adding a requirement moves coverage 86%→85%. (DR-093)

  • The search scope→item-type taxonomy moves into Rust. The spec that diagnosed this leak became the justification for the boundary rule, the check:boundary tripwire and the spec-review checklist — and the fix itself was never built, so the rule's own founding violation was still shipping. SearchScope now owns the expansion, resolved once before the cache and server paths diverge so online and offline cannot filter differently. All expands to no filter rather than the union of the other scopes, which would silently drop People, folders and any type nobody enumerated. Verified by hashing every src/ file, adding a type to the Music scope in Rust, and re-hashing: zero frontend files change — a criterion that failed before this commit. (UR-049 → DR-063)

  • check:boundary passed on the very leak it was written for. The pattern was anchored to includeItemTypes: at the query site, so assigning the same array to a named const one indirection away was invisible — through every green CI run. It now matches an item-type array literal anywhere in src/, catching a const, a Record value and a function return alike, with the deliberate limits kept so single-type presentation stays legal. The allowlist is capped, so the next exception forces a conversation rather than a one-line append, and the header now names what the check still cannot see. (DR-094)

🔧 Internal

  • Three orphaned traceability scripts are removed. All shared one root cause — an unscoped grep -r src-tauri/ walking ~40GB of build artifacts — and two hung indefinitely while the third reported "Total Requirements: 1" and then printed "All requirements have implementations!" from an empty result set. They were salvageable, but read an undocumented second tag convention parallel to TRACES:, and repairing them would have re-established the second source of truth that let "1 requirement" and "211 requirements" coexist unnoticed.

  • Five remediation specs from a design-principles audit of CLAUDE.md and the architecture docs against the actual code. The principles with a working automated check all held up; the two that had drifted are exactly the two whose checks were broken or too narrow.

v0.2.0

Features

  • Audio settings now work on Android. The equalizer, volume normalization and gapless playback controls in Settings Audio previously rendered on Android and did nothing — ExoPlayerBackend was the only backend that never implemented set_audio_settings, and the trait's default silently reported success while applying nothing. All three now take effect:

    • Equalizer — the canonical 10-band ISO curve is resampled onto whatever bands the device's equalizer actually exposes (commonly 5), by nearest centre frequency.
    • Volume normalization — via LoudnessEnhancer. Note this is a gain stage, not a true EBU R128 normalizer like the Linux dynaudnorm path, so it approximates rather than matches Linux behaviour.
    • Gapless playback — honours the setting via pauseAtEndOfMediaItems (ExoPlayer is gapless by default, so this disables it when you turn it off).

    The effects re-attach automatically when ExoPlayer rebuilds its audio sink on a format change, so the equalizer no longer stops applying part-way through a queue. (UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036, IR-004)

    ⚠️ Not yet verified on a physical device. AudioEffect availability and band layouts vary by device and OEM ROM; where an effect is unavailable it is logged and skipped rather than crashing playback.

📋 Documentation

  • Playback backend unification investigation. Six new specs in docs/specs/ record why the playback backends cannot be unified onto a single engine: every candidate (mpv, GStreamer, libVLC) fails the same webview-compositing constraint, because WebKitGTK/WebView2/Android WebView each own their compositor surface and native video cannot interleave with HTML. Audio can unify; video cannot. Also specifies the Android native-video spike, a Windows native audio backend, and the libmpv2 migration.

🐛 Corrected requirement statuses

These were documented as working and were not. No behaviour changed — the docs were wrong.

  • Crossfade (UR-031, DR-034) was marked "Done (Linux only)". It is implemented nowhere, and is architecturally blocked on mpv: its audio chain is single-stream, and FFmpeg's acrossfade requires two inputs. Real crossfade would need two libmpv instances.
  • The platform parity matrix listed crossfade as a Linux/Android gap (it is neither) and omitted the equalizer (which was a genuine gap, now closed).
  • nativeAdapter.ts cited tauri#10152 as blocking native Android video. That issue is a stale feature request; the capability shipped in September 2024. What remains unproven is SurfaceView-behind-WebView compositing, now tracked by a spec rather than asserted as an upstream blocker.

v0.1.5

v0.1.3 and v0.1.4 were never tagged; their work is included here.

Features

  • A single tap is deferred so a double tap does not also toggle pause. A tap cannot be classified when it lands — it may still turn out to be the first half of a double tap — so play/pause waits for the 300ms window to close and is cancelled if a second tap arrives. Forward skip moves from 10s to 30s; back stays 10s. (Superseded in v0.2.7, where the deferral turned out to race the WebView's synthesized click.) (UR-005, UR-061 → DR-092)

🐛 Fixes

  • Locking the screen no longer kills audio during video playback, even with the background-audio toggle armed. configureWebViewForMedia() ran from both the delayed post in onCreate and every onResume, re-registering the JS bridges each pass — five times in a 45s session. A WebView binds injected objects at page-load time, so re-injecting over a live page leaves JS holding a stale proxy: still truthy, and every method gone. The toggle turned blue and never reached native, so the handoff never ran. Bridges are now registered exactly once per WebView, and setBackgroundAudioEnabled reports whether native was actually reached, so a dead bridge can never again masquerade as an armed toggle. Removing the re-injection then revived a latent conflict it had been masking — three audio-focus requesters inside one uid, with the grant followed ~45ms later by a loss whose handler paused playback. The WebView already manages focus for <video>, so the redundant bridge is dropped entirely, consistent with the player-is-authoritative principle. WebView console output is now forwarded to logcat, which is what made this diagnosable at all. (UR-040 → IR-025, DR-051)

  • An expired sleep timer stops without triggering autoplay. Stopping the backend makes the native player fire its ended callback, and the timer thread cancels the timer first — so by the time the callback inspects it the mode reads Off, the sleep-timer branch is skipped, and the episode path ran, showing a next-episode popup right after the user's sleep timer expired. The stop is now recorded as user-initiated before it reaches the backend, which is the honest label: via the timer they set rather than the stop button. (UR-023, UR-026 → DR-029)

v0.1.2

Features

  • Search results are ordered by how well they match. A name that starts with the query now outranks one matching mid-word — typing "parks" finds "Parks and Recreation" before "Sparks of Love" — and at equal match quality a container outranks its contents, so a series lands above its own episodes. Ranking is applied to the instant cached results and to the merged cache+server list alike, so the list no longer reshuffles when server results arrive. (UR-060, DR-090)
  • Separate Shows, Episodes and People result groups. The combined "TV Shows" group splits into Shows and Episodes so a show never competes with its own episodes for a slot, and a new People group means searching an actor's name reaches their bio page. Default order is Shows → Episodes → Movies → Songs → Albums → Artists → People; a group order saved before the split keeps the position it was dragged to. (UR-060, DR-091)

🐛 Bug Fixes

  • The library header search bar works on every library page. It previously searched in place and depended on /library rendering results inline, so on any other /library/** route the results were fetched and never shown. /search is now the single surface that renders results, and the header bar hands its query and scope over via the URL. (UR-049, DR-063)
  • Video smaller than the window is scaled up to fit. Sizing only ever shrank oversized media, so a 480p source on a 1080p display played as a small picture in the middle of a black frame. The picture now fits whichever axis constrains it, in both directions, preserving aspect ratio. (UR-005)

📋 Requirements

Linux: 64-bit, GLIBC 2.29+ Android: 8.0+

v0.1.1

🐛 Fixes

  • "More Episodes" is populated for series without season folders. The strip collapsed to just the current episode on some series, for two reasons: a series exposing episodes directly as children rather than under season folders yielded an empty season fetch, and isCurrentEpisode over-matched, because episodes with no season or episode number compared equal (undefined === undefined) and every one of them looked like the focused episode. Flat children are now grouped by season number under synthesized headers, and the strip's logic is extracted so both behaviours are unit-tested. (UR-058 → DR-087)

  • Autoplay advances in background audio mode. An episode handed off to the audio-only path is a MediaType::Audio item, so autoplay's video-only checks stopped recognising it as an episode and playback simply ended at the boundary. Episode identity is now carried through the handoff, and because the frontend's usual advance is a navigation that is unavailable while the WebView is suspended, the backend performs it directly — fetching the next episode, building its audio-only URL and loading it into the native player, preserving identity so the following boundary advances too. (UR-040, UR-023 → DR-052)

Features

  • Skipping an episode marks it watched rather than paused. Skipping left a mid-episode resume point behind, so the skipped episode reappeared in Continue Watching with a partial progress bar — but skipping means "done with this one", not "stopped here". A one-shot suppression keeps the player's post-navigation unmount stop report from overwriting the 100% progress with the partial one, and Continue Watching now drops resume entries superseded by Next Up. (UR-059 → DR-088, DR-089)

📋 Documentation

  • CLAUDE.md states the failing-test-first rule explicitly: write a test that reproduces the bug and watch it fail before applying the fix, and extract buried logic into a plain .ts module so it can be unit-tested. A test written against already-fixed code can pass for the wrong reason.

v0.1.0

Features

  • Cross-platform desktop packaging, with a Windows NSIS installer built on tag.
  • A webview audio backend for platforms without a native one.
  • A graphic equalizer with presets and custom bands.
  • Home cards distinguish tap from long-press — tap opens detail, long-press plays.

🐛 Fixes

  • Downloaded browse groups by container and loads on large libraries.

v0.0.18

  • The background-audio button shows on all Android video playback.

v0.0.17

This release carried the largest single body of work before v0.1.0 — the provider-neutral domain model and the boundary rule that still governs the frontend.

Features

  • A provider-neutral media model. The frontend was moved off Jellyfin's own vocabulary in phases: item-type strings give way to a neutral kind, Jellyfin ticks become milliseconds end to end (catalog, then player and reporting), primaryImageTag becomes imageId, media streams get a neutral StreamKind, and the user-facing type badge becomes a kind label. This is the work the frontend/backend boundary rule was written to protect, and the tripwire script (check:boundary) lands here with it.
  • Context-scoped search with filter chips and group order.
  • A browsable downloaded library with on-disk usage, and WiFi-only, network-type-aware download gating.
  • A shared account menu and global app header.
  • A reworked settings page.

🐛 Fixes

  • Library listing is gated to downloaded-only when offline.

📋 Documentation

  • Specs, requirements, UX flows and traceability for the above, plus the written boundary rule and spec workflow.

v0.0.16

Features

  • Background-audio handoff for video, alongside a repository/player refactor.
  • Android picture-in-picture (and three dead Android config files corrected).
  • An mdBook docs site, its publish workflow, and the release-notes tooling that turns TRACES into grouped notes.

🐛 Fixes

  • Resuming video playback after background-audio-only mode.

v0.0.15

  • Navigation splits up from back; faster startup; a POSIX-sh-compatible CI versionCode step.

v0.0.14

  • Layout and remote-playback fixes.

v0.0.13

  • Layout and search fixes.

v0.0.12

  • Offline mode fixes; the Android build uses the signing key.

v0.0.11

  • Offline mode and layout fixes; the per-commit Android APK build is replaced with a fast compile check.

v0.0.9 / v0.0.10

Both tags point at the same commit.

  • The PlayerAdapter contract is introduced, moving the decision logic into the shared Rust backend — the origin of the unified player boundary the architecture docs describe.
  • CI APK build fixed; incremental builds enabled.

v0.0.8

  • Android playback fixes.

v0.0.7

  • JRay support, including actor mugshots.
  • Playback reporting wired up; the duration flash fixed; video hidden from the audio mini player.
  • Android lockscreen and media controls kept in sync with playback.
  • Sleep-timer and menu-return fixes.

v0.0.6

Re-tag of v0.0.5 — no commits between the two.

v0.0.5

  • Server-side channel plugins and HLS streaming.
  • JellyLMS zones can be fused and unfused into synchronized multi-room groups, addressed by MAC.

v0.0.4

  • Genre sliders, artist links and navigation utilities.
  • Audio can move between remote players.

v0.0.3

  • Focused music, TV and movie landing screens, and a self-draining download queue.

v0.0.2

  • Autoplay resets time to zero and ignores its trigger if the episode has already started. (The same defect returns in v0.5.5 — see docs/defect-windows.md.)

v0.0.1

First working proof of concept: the Tauri shell, the Rust repository and player layers, and the initial Svelte frontend.