docs(changelog): backfill every release, and date each fixed defect
CHANGELOG.md stopped at v0.5.0 and had gaps below it. Every tag from
v0.0.1 to v0.5.5 now has an entry, written from the commit bodies rather
than the subjects. Entries before v0.1.2 are shorter and marked as
reconstructed after the fact -- the commit messages of that era ("many
changes", "Playback fix") do not record causes.
docs/defect-windows.md is new: for each fixed defect, the releases it was
actually present in, with the evidence for the dating recorded per row so
a row can be disputed. Dated with `git log -S` on the defective token, not
by blaming the lines a fix removed -- that reliably lands on whatever last
touched the adjacent lines rather than on the defect's origin, and was
used only to shortlist.
Twelve defects date to the v0.0.1 proof of concept and shipped for seven
to eight weeks. They are not regressions but original assumptions nothing
exercised, four of them outright latent: the videoBitrate casing was
harmless until a quality picker existed to select against, and the
unconditional Range header was inert until that fix made transcoded
downloads actually transcode -- so DR-170's code dates to v0.0.1 while its
corruption window is the single release v0.5.1.
Three others are plumbing built and never connected: get_next_up_episodes
accepted a series_id with no caller until v0.3.0, the sync queue ran with
neither producer wired, and both watched-state backend halves sat unused.
No automated check sees these; the code is present, tested and reachable
in principle.
Also corrects the v0.5.5 entry. fa7cb6e9 and dcf08f30 are the same diff
off the same parent -- a local commit and its Gitea PR-merge twin -- and a
merge chain pulled the local one into master during v0.5.5. git log
v0.5.4..v0.5.5 therefore lists an autoplay fix that changed no file in the
release; nextEpisodeService.ts is byte-identical across the tag boundary.
That fix shipped in v0.0.2 and has not regressed. It is the one case where
reading the changelog off commit subjects would have produced a false
entry.
scripts/build-android.sh and src-tauri/src/repository/online.rs are also
modified in this tree by a concurrent session and are deliberately left
uncommitted.
This commit is contained in:
+751
-7
@@ -6,6 +6,295 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
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.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
|
## v0.5.0
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
@@ -88,6 +377,74 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
|||||||
so music is not transcoded needlessly.
|
so music is not transcoded needlessly.
|
||||||
(UR-004 → DR-148)
|
(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
|
## v0.4.1
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
@@ -168,6 +525,182 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
|||||||
|
|
||||||
- **Android system bars and display cutout are handled correctly.** (UR-066)
|
- **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
|
## v0.2.0
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
@@ -220,10 +753,45 @@ were wrong.
|
|||||||
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
||||||
by a spec rather than asserted as an upstream blocker.
|
by a spec rather than asserted as an upstream blocker.
|
||||||
|
|
||||||
<!--
|
## v0.1.5
|
||||||
Note: v0.1.3–v0.1.5 have no entries here. Their changes are in the git log
|
|
||||||
and docs/traceability.md.
|
_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
|
## v0.1.2
|
||||||
|
|
||||||
@@ -260,7 +828,183 @@ were wrong.
|
|||||||
**Linux:** 64-bit, GLIBC 2.29+
|
**Linux:** 64-bit, GLIBC 2.29+
|
||||||
**Android:** 8.0+
|
**Android:** 8.0+
|
||||||
|
|
||||||
## v0.1.1 and earlier
|
## 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.
|
||||||
|
-->
|
||||||
|
|
||||||
Released before this file existed — see the git history and the release notes on
|
|
||||||
each tag.
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Defect windows — which bugs were present when
|
||||||
|
|
||||||
|
For each fixed defect, the releases it was actually present in. Companion to
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
|
||||||
|
fault had been shipping before it did.
|
||||||
|
|
||||||
|
**"Present since"** is the first *release* containing the defective code, not the
|
||||||
|
first release where a user could hit it — those differ, sometimes by months, and
|
||||||
|
the gap is called out where it matters. **"How dated"** records the evidence, so a
|
||||||
|
row can be re-checked or disputed:
|
||||||
|
|
||||||
|
| Method | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
|
||||||
|
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
|
||||||
|
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
|
||||||
|
|
||||||
|
## Present since the first release
|
||||||
|
|
||||||
|
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
|
||||||
|
for between two weeks and seven weeks short of two months before anyone hit them.
|
||||||
|
That is the dominant pattern here: not regressions, but original assumptions that
|
||||||
|
went unexercised until a later feature leaned on them.
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||||
|
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||||
|
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||||
|
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||||
|
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
||||||
|
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
||||||
|
|
||||||
|
### Why they took so long to surface
|
||||||
|
|
||||||
|
Four of these were **latent until a later feature exercised them**, which is why
|
||||||
|
the fix lands so far from the cause:
|
||||||
|
|
||||||
|
- The `videoBitrate` casing was harmless while every download was `original`. It
|
||||||
|
became visible only once a quality picker existed to select against — and then
|
||||||
|
produced no error, just a full-size file, because Jellyfin discards an unbound
|
||||||
|
query key silently.
|
||||||
|
- The unconditional `Range` header was inert for the same reason: `original` is
|
||||||
|
the one rung served with a `Content-Length` and real byte-range support. It
|
||||||
|
started corrupting files in **v0.5.1**, the moment the casing fix made
|
||||||
|
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
|
||||||
|
*corruption* to v0.5.1 — a one-release window for the visible symptom.
|
||||||
|
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
|
||||||
|
item. Nothing re-opened one until quality switching, transcoded seek and
|
||||||
|
audio-track switching existed.
|
||||||
|
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
|
||||||
|
subtitle track is image-based, since that is what forces the server from
|
||||||
|
sidecar to burn-in.
|
||||||
|
|
||||||
|
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
|
||||||
|
was hidden by the thumbnail cache falling back to the server copy whenever the
|
||||||
|
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
|
||||||
|
silently correct an out-of-range index — which is exactly why it was reported as
|
||||||
|
"*some* videos have no audio" rather than as a bug in the client.
|
||||||
|
|
||||||
|
## Introduced by a feature, fixed later
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | How dated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
|
||||||
|
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
|
||||||
|
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
|
||||||
|
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
|
||||||
|
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
|
||||||
|
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
|
||||||
|
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
|
||||||
|
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
|
||||||
|
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
|
||||||
|
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
|
||||||
|
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||||
|
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||||
|
|
||||||
|
Three of these are worth separating out, because the defect is not a mistake in
|
||||||
|
the code so much as **plumbing that was built and never connected**:
|
||||||
|
|
||||||
|
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
|
||||||
|
written, and no caller passed one until v0.3.0.
|
||||||
|
- The sync queue and its drain were built, tested and running in v0.4.6 with
|
||||||
|
neither of its two would-be producers ever called.
|
||||||
|
- Both halves of the watched-state backend existed with no caller before v0.5.3.
|
||||||
|
|
||||||
|
An automated check cannot see any of these — the code is present, tested and
|
||||||
|
reachable in principle. Only tracing a requirement to a *call site* catches it.
|
||||||
|
|
||||||
|
## Short windows (one release or less)
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
|
||||||
|
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
|
||||||
|
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
|
||||||
|
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
|
||||||
|
|
||||||
|
## Fixed twice / never actually broken
|
||||||
|
|
||||||
|
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
|
||||||
|
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
|
||||||
|
local original). Both have the same parent `674c8e5c` and the same diff. A merge
|
||||||
|
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
|
||||||
|
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
|
||||||
|
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
|
||||||
|
boundary. The fix shipped in **v0.0.2** and has not regressed.
|
||||||
|
|
||||||
|
This is the one case where reading the changelog off `git log` subjects would
|
||||||
|
have produced a false entry, and it is a good argument for the project's
|
||||||
|
practice of deriving release notes from TRACES rather than commit subjects.
|
||||||
|
|
||||||
|
## Recurring shapes
|
||||||
|
|
||||||
|
Four causes account for most of the table:
|
||||||
|
|
||||||
|
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
|
||||||
|
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
|
||||||
|
default that was actively wrong, and in three of the four the server's choice
|
||||||
|
was more expensive than the one intended — burn-in forcing a full re-encode
|
||||||
|
being the extreme case.
|
||||||
|
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
|
||||||
|
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
|
||||||
|
dividing by a stale denominator.
|
||||||
|
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
|
||||||
|
the position, backend never seeked), end-of-playback dispatch (two paths, one
|
||||||
|
unreachable), and the surface/attach split in v0.5.0's native video.
|
||||||
|
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
|
||||||
|
timer handle the callback had already cleared; the HTML5 toggle keyed
|
||||||
|
play-vs-pause on `el.paused`, which flips while buffering.
|
||||||
|
|
||||||
|
## Reproducing this
|
||||||
|
|
||||||
|
The pickaxe rows can be re-derived directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
|
||||||
|
git tag --contains <sha> | sort -V | head -1 # first release with it
|
||||||
|
```
|
||||||
|
|
||||||
|
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
|
||||||
|
across many commits but was **not** used for the rows above: it reliably lands on
|
||||||
|
whichever commit last touched the adjacent lines, which is usually not the commit
|
||||||
|
that introduced the defect. It was used only to shortlist candidates.
|
||||||
Reference in New Issue
Block a user