Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m5s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 15m20s
Build & Release / Build Linux (push) Successful in 21m22s
Build & Release / Build Windows (push) Successful in 16m11s
Build & Release / Build Android (push) Successful in 31m49s
Build & Release / Create Release (push) Successful in 1m3s
The tag message counted twelve defects while the changelog described ten. Both of the missing ones are user-visible and belong in the user-facing artifact: a quality ceiling that outlived the episode it was chosen for, and a seek that outlived the file it was meant for. The third review fix — collapsing two identical URL helpers — is not here on purpose. Nobody using the app can tell.
1555 lines
83 KiB
Markdown
1555 lines
83 KiB
Markdown
# 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](docs/requirements.md); the
|
||
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.11.0
|
||
|
||
Video can play through the native renderer on Linux, and the machinery every
|
||
platform's playback goes through was rebuilt around one contract. Nine defects
|
||
fell out of doing it — each one a capability the code had written down as a
|
||
fact about the platform rather than asking the thing that would know.
|
||
|
||
### ✨ Changes
|
||
|
||
- **Video can decode natively on Linux, without the server re-encoding it.**
|
||
Until now every video played on the desktop was transcoded by Jellyfin to
|
||
h264 and handed to the browser engine, whatever the file actually was — so the
|
||
server burned CPU on every play, and quality was capped by that conversion.
|
||
mpv can now draw the picture directly, composited beneath the interface so the
|
||
controls, subtitles and overlays still sit on top of it. Direct play means the
|
||
original file, hardware decoding, and no server work at all. This is off by
|
||
default while it settles: set `JELLYTAU_NATIVE_VIDEO=1` to try it. The browser
|
||
path is untouched and remains what you get otherwise. (UR-080 → DR-231 …
|
||
DR-237)
|
||
|
||
- **Playback speaks one language across every player.** Linux, Android and
|
||
Windows each drove their engine through a different set of calls, and a rule
|
||
learned on one did not reach the others — which is why several of the fixes
|
||
below existed on one platform and not another. All three now go through a
|
||
single contract, and one suite of behaviours runs against every engine,
|
||
including ExoPlayer on a real device. An engine is either correct or visibly
|
||
failing. Nothing about this is visible while it works, which is the point.
|
||
(UR-081 → DR-242 … DR-247)
|
||
|
||
### 🐛 Fixes
|
||
|
||
- **Resuming a film starts where you left it, instead of at the beginning.**
|
||
Asking a player to open a file and asking it to start at a position were two
|
||
separate steps, and the second was issued before the first had finished — so
|
||
it failed, was discarded, and playback began at zero. It affected resume and
|
||
any skip on a stream the server was converting. The position is now part of
|
||
opening the file, so there is no gap for it to fall into. (DR-241)
|
||
|
||
- **Skipping works on films the server is converting.** A skip was routed by the
|
||
*shape* of the stream rather than by what the player could do with it. That
|
||
happened to be right while one particular player handled those streams and
|
||
became wrong the moment another did — after which skipping simply did nothing,
|
||
silently. Players now say what they can do and are asked. (DR-238, DR-246)
|
||
|
||
- **The play and pause button follows the player again.** The code that reacted
|
||
to pausing was never subscribed to the event it was waiting for, so the button
|
||
stayed where it was while playback did something else. (DR-239)
|
||
|
||
- **Fullscreen fills the screen.** It expanded the page rather than the window,
|
||
which was invisible while the picture was drawn inside the page and obvious as
|
||
soon as it was not. (DR-240)
|
||
|
||
- **The seek bar knows how long the film is.** A player that had not yet worked
|
||
out the duration reported zero, and zero was believed — leaving the bar with
|
||
no scale and nothing to drag against, even though the length had been known
|
||
since the library listed it. (DR-251)
|
||
|
||
- **Leaving the player stops the sound.** The stop was aimed at whichever
|
||
renderer the app believed was in charge. Enabling background audio hands over
|
||
to a different one, so afterwards the app stopped something that was no longer
|
||
playing and the film carried on as an audio track in the mini player. Closing
|
||
now stops everything, regardless of who was in charge. (DR-250)
|
||
|
||
- **Coming back from background audio no longer leaves a black screen.** The
|
||
stream that plays while the app is hidden has no fixed length, and the value a
|
||
player uses to say so is a very large negative number. Converting it crashed
|
||
the playback engine outright, which looked like a dead player with no
|
||
controls. (DR-252)
|
||
|
||
- **Android builds again.** A rule that only applied to Linux stayed attached to
|
||
code that had stopped being Linux-only, and the Android build had not compiled
|
||
since. (DR-247)
|
||
|
||
- **A quality you chose for one episode no longer caps every episode after it.**
|
||
Dropping the quality mid-episode is meant to describe that episode. When the
|
||
next one started in the background, nothing reset it — so the ceiling stayed
|
||
in force indefinitely, with nothing in the interface saying why later episodes
|
||
looked worse. (DR-254)
|
||
|
||
- **Skipping to the next item no longer starts it part-way through.** Scrubbing
|
||
near the end of a converted stream re-opens it, and the position being waited
|
||
for was not discarded if you skipped onward first — so the next item began
|
||
wherever you had dragged to in the previous one. (DR-253)
|
||
|
||
### 🧹 Under the hood
|
||
|
||
- The conformance suite can be run on its own: `bun run test:player` for the
|
||
desktop engines, `bun run test:player:android` for ExoPlayer on a connected
|
||
device. Both build a test fixture rather than carrying media in the
|
||
repository.
|
||
|
||
- [docs/native-player-verification.md](docs/native-player-verification.md)
|
||
records what to check before a release, including the exact sequences that
|
||
found two of the defects above — both of which passed every automated test.
|
||
|
||
### Known limitations
|
||
|
||
- Resume reads progress saved on the device, not from the server, so a fresh
|
||
install or a second device will not offer to resume something watched
|
||
elsewhere.
|
||
- Native video on Linux is opt-in and is not yet the default.
|
||
|
||
## v0.10.1
|
||
|
||
A single fix, for something that had been quietly overriding a choice you made.
|
||
|
||
### 🐛 Fixes
|
||
|
||
- **Locking the screen no longer keeps playing a video's audio unless you asked
|
||
it to.** The player has a background-audio button: turn it on and the sound
|
||
carries on when you lock the screen or leave the app, turn it off and playback
|
||
stops. It stopped working when video moved to the native renderer — which plays
|
||
through a media service designed to keep going while the app is hidden — and
|
||
nothing was left to stop it. So the audio continued whether the button was on
|
||
or off, and there was no way to make it behave otherwise. The button governs it
|
||
again: with it off, locking the screen pauses the video and unlocking resumes
|
||
where you were; with it on, the audio continues as before. Music is untouched —
|
||
it keeps playing when backgrounded, as a music player should — and a video in a
|
||
picture-in-picture window keeps playing too, because the window is still on
|
||
screen. If you had already paused before locking, it stays paused.
|
||
(UR-040 → DR-224)
|
||
|
||
## 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 <vulnerable library>?" 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.
|
||
|
||
<!--
|
||
Two commits in this range (fa7cb6e9, 1e599627) are a June 2026 duplicate of the
|
||
v0.0.2 autoplay fix — same parent, same diff, a second commit object created by
|
||
the Gitea PR merge. A merge chain dragged them into master's history here; they
|
||
changed no file in this release. Deliberately not listed.
|
||
-->
|
||
|
||
|
||
## 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 30–130ms 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/](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](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.
|
||
|
||
<!--
|
||
Entries for v0.1.1 and earlier were reconstructed from the git history after
|
||
the fact, so they are shorter and less specific than later ones — the commit
|
||
messages of that era did not record causes the way the current convention does.
|
||
-->
|
||
|