Files
jellytau/CHANGELOG.md
dtourolle 440d7a01a9
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m2s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 32s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 6m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
chore(release): 0.6.0
Android native video renders a picture, and its transport works.

The path shipped once as audio with no picture and was reverted with the
compositing named as the suspect. It was not the compositing: five independent
defects sat between ExoPlayer and the screen, each able to produce that symptom
on its own — the app shell painting over the surface through a CSS rule aimed at
an attribute nothing set, a poster card with no way to lift on a path that
renders no <video>, JS bridges racing the page load and losing permanently, a
SurfaceView that was never detached, and a frontend that told Rust a webview
element was playing when none existed, so every play/pause intent was aimed at
something that was not there.

Native video stays opt-in. Turning it on surfaced a further unverified path —
the background-audio return is written only for the webview element — and
rotation still needs device confirmation.

Minor rather than patch: the player's touch behaviour changes for everyone (the
control bar now auto-hides on touchscreens, and the system bars go away with the
player), not only for those who opt into native video.
2026-08-16 21:14:08 +02:00

1070 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 30130ms later — by which time that button exists, so the click
landed on the overlay, which called toggle with no guard and resumed
immediately. Unpausing was unaffected because it removes the overlay: an
asymmetry that pointed straight at it. Then the bottom play/pause button did
nothing, because the gesture listener on the outer container and the button's
own handler both fired and cancelled out. Then the control-surface guard added
to fix *that* killed double-tap-to-seek, since the second tap lands on the
overlay. The overlay is now marked as player surface — visually it *is* the
video — and gesture rules live in pure, unit-tested functions. The suite gained
a test that renders the real component and dispatches real touch events at
whatever element is genuinely on top: the pure unit tests all passed throughout
these four bugs, because each helper behaved exactly as specified and every
defect was in the composition. (DR-098)
- **An HLS stall no longer produces an AbortError storm.** Every interrupted play
attempt was reported as a player error, but while a stream stalls hls.js nudges
the element to recover, cancelling the pending `play()` promise — transient, yet
it hit the error handler roughly once a second for the whole stall and left the
UI stuck reporting paused. The in-flight attempt is now memoised so the UI and
recovery share one call. (DR-096)
- **Seeks are clamped inside the media** to stop an end-of-stream pause loop.
(DR-095)
### 🔧 Internal
- `--device`/`--abi` build only the architecture actually needed. An on-device
test build compiled all four ABIs, throwing three of the four Rust compiles
away, which dominated iteration time against a connected phone.
## v0.2.1
### 🐛 Fixes
- **The traceability gate was dead and reported 158% coverage.** It divided traced
counts by hardcoded literals (UR/39, IR/24, DR/48, JA/3, total 114) that had
fallen out of date as requirements grew to 211 — JA alone printed 800% — so the
50% threshold was mathematically unreachable and the job could not fail.
Coverage could have collapsed to 30% behind a green tick. Real coverage was 86%:
the number was fine, the gate was not. Both sides of the fraction are now
derived from requirements.md, IDs are deduplicated (every UR is listed twice), a
TRACES comment naming a deleted requirement is reported as orphaned rather than
inflating the ratio, and a reading above 100% is a hard error rather than the
condition that hid this. Verified empirically — forcing the threshold to 99%
fails, adding a requirement moves coverage 86%→85%. (DR-093)
- **The search scope→item-type taxonomy moves into Rust.** The spec that diagnosed
this leak became the justification for the boundary rule, the `check:boundary`
tripwire and the spec-review checklist — and the fix itself was never built, so
the rule's own founding violation was still shipping. `SearchScope` now owns the
expansion, resolved once before the cache and server paths diverge so online and
offline cannot filter differently. `All` expands to no filter rather than the
union of the other scopes, which would silently drop People, folders and any
type nobody enumerated. Verified by hashing every `src/` file, adding a type to
the Music scope in Rust, and re-hashing: zero frontend files change — a
criterion that failed before this commit. (UR-049 → DR-063)
- **`check:boundary` passed on the very leak it was written for.** The pattern was
anchored to `includeItemTypes:` at the query site, so assigning the same array to
a named const one indirection away was invisible — through every green CI run.
It now matches an item-type array literal anywhere in `src/`, catching a const, a
Record value and a function return alike, with the deliberate limits kept so
single-type presentation stays legal. The allowlist is capped, so the next
exception forces a conversation rather than a one-line append, and the header now
names what the check still cannot see. (DR-094)
### 🔧 Internal
- Three orphaned traceability scripts are removed. All shared one root cause — an
unscoped `grep -r src-tauri/` walking ~40GB of build artifacts — and two hung
indefinitely while the third reported "Total Requirements: 1" and then printed
"All requirements have implementations!" from an empty result set. They were
salvageable, but read an undocumented second tag convention parallel to
`TRACES:`, and repairing them would have re-established the second source of
truth that let "1 requirement" and "211 requirements" coexist unnoticed.
- Five remediation specs from a design-principles audit of CLAUDE.md and the
architecture docs against the actual code. The principles with a working
automated check all held up; the two that had drifted are exactly the two whose
checks were broken or too narrow.
## v0.2.0
### ✨ Features
- **Audio settings now work on Android.** The equalizer, volume normalization
and gapless playback controls in Settings Audio previously rendered on
Android and did nothing — `ExoPlayerBackend` was the only backend that never
implemented `set_audio_settings`, and the trait's default silently reported
success while applying nothing. All three now take effect:
- **Equalizer** — the canonical 10-band ISO curve is resampled onto whatever
bands the device's equalizer actually exposes (commonly 5), by nearest
centre frequency.
- **Volume normalization** — via `LoudnessEnhancer`. Note this is a gain
stage, not a true EBU R128 normalizer like the Linux `dynaudnorm` path, so
it approximates rather than matches Linux behaviour.
- **Gapless playback** — honours the setting via `pauseAtEndOfMediaItems`
(ExoPlayer is gapless by default, so this disables it when you turn it off).
The effects re-attach automatically when ExoPlayer rebuilds its audio sink on
a format change, so the equalizer no longer stops applying part-way through a
queue. (UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036, IR-004)
⚠️ **Not yet verified on a physical device.** `AudioEffect` availability and
band layouts vary by device and OEM ROM; where an effect is unavailable it is
logged and skipped rather than crashing playback.
### 📋 Documentation
- **Playback backend unification investigation.** Six new specs in
[docs/specs/](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.
-->