🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m47s
Traceability Validation / Check Requirement Traces (push) Successful in 36s
Build & Release / Run Tests (push) Successful in 26m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m2s
Build & Release / Build Linux (push) Successful in 32m23s
Build & Release / Build Windows (push) Successful in 14m59s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 31s
Minor rather than patch: three user-visible behaviour changes — cloud/D2D backup disabled, the Android TV launcher entry withdrawn, and lockscreen skip scrubbing rather than advancing during background audio.
1184 lines
64 KiB
Markdown
1184 lines
64 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.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.
|
||
-->
|
||
|