69c2498cf7f94e4b0ecb216b639ac8e56868b3a0
175
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
adc460f35d |
fix(downloads): honor the selected bitrate (videoBitRate, capital R)
Downloading at a specific quality silently returned the full-size original. The download URL builder spelled the transcode params `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. The server discards it without error and then stream-copies the source, so picking "480p" produced an original-quality file with no failure surfaced anywhere. `maxHeight`/`videoCodec` were unaffected (case-insensitive binding covers them), which is why the height cap applied while the bitrate cap vanished. Also set `allowVideoStreamCopy=false` on the transcode presets to force a real re-encode. Video stream-copy is gated by `allowVideoStreamCopy`, not `enableAutoStreamCopy` — the latter governs audio only. `original` is unchanged: it stays a deliberate direct static copy, now pinned by a test. The pre-existing unit tests asserted the broken lowercase-r spellings, so they passed against broken code; corrected. Verified red -> green by extracting the pre-fix and post-fix builder bodies into an isolated harness: 15 assertion failures before, 0 after. TRACES: UR-071 | DR-123 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9d7cb085e9 |
fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in series order. A viewer who skipped the pilot but is three seasons deep was sent back to S1E1: the gap was a deliberate skip, not the place they stopped. This read as flaky rather than consistently wrong because rung 3 only fires when the server's Next Up (rung 2) yields nothing, and `resolve_current_episode` swallows that call's errors with `.unwrap_or_default()`. `HybridRepository::get_next_up_episodes` delegates unconditionally to the online repo, so any unreachable-server moment silently degraded to the empty vec — same series, same watch state, different answer depending on one request's outcome. Rung 3 now scans the ordered list from the end with `rposition(is_played)` and returns the episode after the furthest-watched one, falling back to the previous first-unwatched behaviour when nothing is watched or the series is finished. Season crossing comes free from the already-flat series ordering, and `season_rank` keeps specials last so a watched special cannot mark a show finished. Tests written first and confirmed failing (S1E1 where S3E4 was expected), covering the skipped-pilot case, rolling into the next season past a skipped episode, and the watched-special case. All 17 existing tests still pass. Note: cargo test could not run locally (javascriptcoregtk-4.1 / webkit2gtk-4.1 absent on this host). The pure policy half plus its verbatim test module were extracted into a standalone crate to get real red/green; the full crate suite still needs a run on a complete toolchain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e144e62b31 |
feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend overrides threw that answer away, so ExoPlayer's video path had never actually run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off). The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 — Linux cannot composite behind WebKitGTK, and promoting there would be a black screen. Two blockers the spec did not anticipate, both in code assumed to be merely unreachable rather than broken: - `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` bailed. The SurfaceView was created and wired to ExoPlayer but never added to the view hierarchy — video would have decoded to a surface that was never on screen, whatever the webview did. This also revives PiP on the video path, which gated on the same flag. - `createAdapter()` was not the real gate; it is never called in production. The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped the native backend `player_play_item` had just started. Both sites now route through `createAdapter()`. Compositing needs two independent opaque layers cleared, not one. Clearing only the page leaves the WebView widget opaque — audio over a black picture, exactly the symptom the old INTERIM comment described. `videoSurface.ts` toggles both: the widget background and window drawable from Kotlin, the page backgrounds via a `data-native-video` attribute keyed by app.css. Transparency lives in `tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the playback session so the launcher never shows through the rest of the app. Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on rotation. The mini-player transition remains unverified on device. Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a second copy of the Rust cfg gate free to drift from it. `player_get_capabilities` now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates. Tests: adapter selection covers the full matrix, including the regression guard that the flag off beats Rust. Written first and confirmed failing (2 of 7) before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07d10dfed7 |
docs(traceability): land the DR-149 requirement rows and settle a UT collision
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 19m31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 6m47s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m6s
Build & Release / Build Linux (push) Successful in 19m57s
Build & Release / Build Windows (push) Successful in 14m14s
Build & Release / Build Android (push) Successful in 30m26s
Build & Release / Create Release (push) Successful in 19s
The DR-149 row lost an index race with a parallel session's edit of the same file, so the previous commit carried the count assertion (DR 144, total 282) without the requirement it counts — a clean checkout of that commit failed `bun run test` against its own requirements.md. The parallel session also reached UT-143 and UT-147 for subtitle work, which collided with the UT-143 used for the client-side transcode tests. Those move to UT-148, in the table and in the device_profile TRACES comments, so no two requirements share an ID. |
||
|
|
acddcdd6fa |
fix(playback): force a transcode when the webview cannot decode the audio (DR-149, 0.4.8)
Advertising a webview-shaped profile (DR-148) was necessary but not sufficient. Probing the server directly showed Jellyfin 10.11.5 enforces a DirectPlayProfile's Container and VideoCodec — excluding either returns SupportsDirectPlay:false with TranscodeReasons=ContainerNotSupported / VideoCodecNotSupported — but ignores its AudioCodec entirely: an E-AC-3 track is still offered for direct play against a profile listing only aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the codec nor MaxAudioChannels:2 against a 6-channel track changes the answer, so no profile the client can send fixes this and the picture plays silent. The client therefore stops delegating a question it can answer itself. The negotiated source's audio is checked against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode regardless of the server calling direct play fine; direct_play and needs_transcoding are corrected to match so the frontend and the reporting path agree with the URL actually used. The track judged is the one that would be served — the default, else the first — since a supported track further down is not the one that plays. A source with no audio, or a codec the server did not name, is left alone rather than transcoded on a guess. Test-first: the new tests failed against the old behaviour before the decision existed. Verified on a motorola edge 30 by the audio HAL, not by ear — the same E-AC-3 episode logged isMusicActive=true once and 58 ACDB-LOADER lines under this build, against 0 and 0 on 0.4.6, where an AAC file in the same session produced 16 and 116. No FATAL EXCEPTION, so R8 on the signed release build is unaffected. Also carries in-flight subtitle-track work authored in a parallel session (subtitleTracks, VideoPlayer, player/media, bindings) at the user's request, so the tag matches the APK verified on device. |
||
|
|
6a712c46cb |
fix(player): send subtitle tracks to ExoPlayer on Android (UR-020)
Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.
VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".
PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.
Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.
The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.
The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.
No Kotlin change was needed.
Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.
TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
|
||
|
|
2c3955914e |
fix(playback): advertise only webview-decodable audio for video (DR-148, 0.4.7)
The audio codec list sent to Jellyfin comes from MediaCodecList, which describes ExoPlayer — but video does not play through ExoPlayer. Android force-renders every video in the webview <video> element (the interim override in VideoPlayer.svelte) and Linux always has, and Chromium/WebKit decode a far narrower set than the platform does. A motorola edge 30 ships /vendor/etc/media_codecs_dolby_audio.xml, so it reported ac3,eac3; the server direct-played an E-AC-3 track with static=true and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by capability rather than the lack of it, which is why a Fairphone and an Honor tablet play the same file on the same build: without the Dolby decoder they never claim the codec, so the server transcodes to AAC. Confirmed by A/B on the failing device — hevc+eac3 silent, hevc+aac audible, same session, same profile, same direct-play path, audio codec the only variable. video_audio_codecs narrows the platform list to the webview-decodable set for the video direct-play profile only. Audio-only playback really is the native player's, so that profile keeps the full list rather than transcoding music that plays perfectly well. A list with nothing decodable still claims aac, since a profile claiming nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio. Test-first: the tests failed against the old behaviour before the filter existed, including the case built from the phone's real codec list. The requirement-count assertion in extract-traces.test.ts moves 280 -> 281 for the added DR, which is the deliberate edit that test exists to force. Not yet verified on device — the 0.4.7 APK was still building. |
||
|
|
1b70926c36 |
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 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.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol 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.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
|
||
|
|
7b531a40be |
fix(player): pick a decodable track in the no-audio fallback (DR-146)
When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally. But the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. Scan the groups for the first isTrackSupported track and override to that. Also clear setTrackTypeDisabled(TRACK_TYPE_AUDIO), since audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track, log it as an error — the server was expected to transcode — instead of leaving a silent video with no explanation in the log. Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this tree has no Kotlin test source set, as noted in the previous commit. |
||
|
|
19bc265a8d |
fix(player): do not start a video before audio focus is granted (DR-145)
Video manages audio focus by hand (handleAudioFocus=false, since ExoPlayer's automatic handling is reserved for the audio path), and all three outcomes of the request were treated as success. AUDIOFOCUS_ REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly invites, and which means the system is withholding our audio until it calls back — and an outright REQUEST_FAILED were logged and then followed by playWhenReady = true. The picture rolled with no sound, which to the user is indistinguishable from a broken stream. Hold playback when focus is not granted and start it from the AUDIOFOCUS_GAIN callback. An explicit play() re-requests focus instead of resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. LOSS clears the pending flag so an unrelated later GAIN cannot start playback the user never asked for. Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this tree has no Kotlin test source set (the Gradle project lives in the generated, gitignored gen/ tree), so the logic cannot be exercised off device without restructuring the Android build. |
||
|
|
cc7f1cece0 |
fix(player): play downloaded video offline (DR-133, DR-134)
Offline video never started: the <video> element reported NETWORK_NO_SOURCE one millisecond after loadstart, which the UI mislabelled as "may need transcoding" even though nothing had been fetched. Two independent causes, both required for playback. The path was doubled. `downloads.file_path` is stored relative to the storage root while a download is queued, but the worker rewrites it to the absolute path it actually wrote once the transfer completes — so a completed row is already rooted. The player's offline branch rooted it a second time, producing /data/user/0/app//data/user/0/app/videos/x.mp4. Audio was unaffected because it resolves the same column through Rust's resolve_local_media_path, which does not re-root. The join is now absolute-aware (POSIX, Windows drive letters, UNC) so rows written before completion still resolve. The asset protocol was never enabled. convertFileSrc rewrites a path to http://asset.localhost/… unconditionally, but Tauri only answers that origin when the protocol-asset cargo feature is compiled in *and* app.security.assetProtocol.enable is set — neither was, so even a correct path resolved to nothing. This also silently defeated the cached-thumbnail path in imageCache, which fails soft to the server copy and so hid the breakage whenever the server was reachable. Scoped to $APPDATA/** — the storage root holding the database, downloads/ and the thumbnail cache — rather than an unrestricted grant. Diagnosed from logcat on device; UT-124 reproduces the doubled path. |
||
|
|
a53042fe80 |
fix(playback): bound the device profile by the audio route's channels (DR-141)
MediaCodecList answers "can this device decode 5.1", which is not the question that decides whether the user hears anything: a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. The DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to direct-play the multichannel track to a two-channel sink — silence or dialogue folded into surround channels that go nowhere, depending on the device. Report media3 AudioCapabilities.maxChannelCount for the current route over JNI alongside the codec lists, and bound the direct-play and transcoding profiles (and the HLS URL's TranscodingMaxAudioChannels, previously hardcoded to 2) by it. No codec is ever removed, so a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo. |
||
|
|
db520c6551 |
fix(playback): stop pinning the video stream as the audio track (DR-140)
Jellyfin's MediaStream.Index is global across every stream in a media source, so index 0 is the video stream on virtually all files. We sent AudioStreamIndex=0 as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL and the PlaybackInfo 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 the bug, which is why it surfaced as "some videos have no audio". Omit the parameter unless a track was actually chosen, so the server resolves the source's DefaultAudioStreamIndex. An explicit selection from player_switch_audio_track still passes through unchanged. Dropped outright from the static=true direct-play URL, which serves the original file untouched. |
||
|
|
1ef6180776 |
chore(release): bump to 0.4.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 6m1s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m26s
Build & Release / Build Linux (push) Successful in 19m41s
Build & Release / Build Windows (push) Successful in 13m57s
Build & Release / Build Android (push) Successful in 30m6s
Build & Release / Create Release (push) Successful in 24s
|
||
|
|
878ac5fa59 |
fix(player): make lockscreen transport reach background audio (DR-097)
Pausing from the lockscreen did nothing while a video's audio played in the background. The handoff starts native ExoPlayer audio and only then tears the WebView <video> down, and that teardown fires a DOM `pause` the frontend reports like any other — leaving html5_playing = Some(false). Transport therefore stayed aimed at the element: the lockscreen pause emitted a ControlCommand into a <video> that no longer existed while the native player carried on. The controller now tracks a background-audio handoff explicitly. Entering one hands transport authority to the native backend and drops the dying element's state/position/media-loaded reports, which also stop flipping the UI to paused and dragging the position backwards. Exiting restores the element as the player. A lockscreen pause also has to survive the return to the foreground: the video used to resume from a snapshot taken at handoff time, undoing the pause on the way back in. shouldResumeOnForeground() lets an explicit `paused` from the player override that snapshot. TRACES: UR-040, UR-005 | DR-052, DR-097 |
||
|
|
6aaa80ff92 |
chore(release): bump to 0.4.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m21s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m46s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 5m38s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 19m22s
Build & Release / Build Windows (push) Successful in 13m55s
Build & Release / Build Android (push) Successful in 30m12s
Build & Release / Create Release (push) Successful in 15s
|
||
|
|
f7bcfe521d |
fix(favorites): save server favourites through to the cache (DR-115)
The hybrid favourites read went straight to the online repository on a cache miss and dropped the result on the floor. Every other read path persists what it fetches, so this one made the favourites page re-query the server on every visit — and left the offline mirror (DR-114) empty on a fresh install, since this is the path that fills it. It now goes through get_favorites_server_only, which saves through on the way back. The command had a matching hole: with nothing cached it returned the empty result, painting "Nothing favourited yet" at a viewer whose favourites were simply marked on another client. It now asks the repository for a real answer instead of an empty state it would correct a round trip later. TRACES: UR-067 | DR-115 |
||
|
|
30dc3ba7f6 |
fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117 |
||
|
|
32f8de5c91 |
fix(player): survive a flaky stream, and don't read 0:00 at EOF
Two MPV-side fixes for the same failure story — a wifi blip during playback. The demuxer gave up the moment a read failed and MPV raised EndFile(ERROR), so a momentary outage killed the track outright. Enabling ffmpeg's reconnect options handles the common case entirely below our level, so most outages never reach the recovery path at all. Set non-fatally: their availability varies with the libmpv/ffmpeg build, and losing resilience is not a reason to refuse to play anything. Separately, `time-pos` and `duration` are live properties of the *loaded* file: at EOF MPV unloads it and both stop resolving. Reading them straight through returned 0.0/unknown at exactly the moment end-of-file handling needed to know where playback had reached, so the player appeared to rewind to 0:00 as a track ended. `ObservedTime` records the last reading seen while media was loaded and the accessors fall back to it. |
||
|
|
62873cab3d |
feat(search): answer search from a local index; tier downloads by lifetime
Search's 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. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently. |
||
|
|
c55ff45692 |
fix(android): clear the system bars and display cutout (UR-066)
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.
None of the app's safe-area handling was ever active, for two independent
reasons:
1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
resolved to 0px — the padding in app.css and BottomUi was a no-op.
2. Android WebView maps only the *display cutout* into `env()`; the status bar
and navigation bar are never reported. With enableEdgeToEdge() and
targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
spans them, so CSS could not learn about them by any route.
WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.
Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.
The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.
Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
|
||
|
|
58f2506966 |
feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md |
||
|
|
a26a853f01 |
fix(player): advance background audio-only episodes in the backend (UR-040)
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m33s
Traceability Validation / Check Requirement Traces (push) Successful in 25s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m25s
Build & Release / Run Tests (push) Successful in 6m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m38s
Build & Release / Build Linux (push) Successful in 19m23s
Build & Release / Build Windows (push) Successful in 13m43s
Build & Release / Build Android (push) Successful in 29m47s
Build & Release / Create Release (push) Successful in 19s
An episode played audio-only while the app was backgrounded stalled at the episode boundary instead of advancing, 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: load_and_play sets EndReason::NewTrackLoaded at every load and nothing clears it, so the first real end consumes it and the decision is always Stop. The call that actually decides is the frontend's echo of the resulting PlaybackEnded into player_on_playback_ended — and that path had no background-audio case at all, so it started a countdown whose advance is a webview goto() that cannot start audio while backgrounded. Both dispatchers now share PlayerController::auto_advance_to_next_episode, so they cannot drift apart again. The handoff base offset moves from the BackgroundAudioOffset Tauri state onto the controller, and the advance clears it: the next episode's stream is built without StartTimeTicks, so its timeline is already absolute and a stale base made player_exit_background_audio return old_base + position_in_new_episode. Unreachable until the advance actually worked. Tests (red before the fix): - test_auto_advance_background_audio_episode_advances_in_backend - test_auto_advance_foreground_video_episode_uses_countdown - test_advance_to_next_episode_audio_only_clears_handoff_base Bump to 0.2.9. |
||
|
|
9d099268b9 |
fix(player): make the video seek bar work by touch (DR-099)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m25s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 6m4s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
On Android, dragging or tapping the progress bar moved the thumb but playback stayed where it was. Two separate defects, both touch-only, which is why the mouse-driven scrub tests never caught either. 1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that land on a control, but handleTouchMove kept running. It measures against touchStartX/Y, which that early return leaves at the PREVIOUS gesture's values, so a seek-bar drag produced a huge bogus vertical delta: read as a brightness swipe, it dimmed the screen to the 0.3 floor and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at touchstart (playerGestureActive) and touchmove ignores anything unlatched — re-checking the move target cannot recover a start point that was never recorded. 2. Commit signal. 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. touchend/mouseup now commit too; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. seekRelative shares the same commitSeek entry point instead of fabricating a synthetic change event. Tests drive the slider with real touch events (UT-089, UT-090) and fail against the pre-fix component. |
||
|
|
b12e99b7e1 |
fix(player): keep double-tap seek working over the play overlay (DR-098)
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
The control-surface guard added in the previous commit killed double-tap-to-seek. The first tap pauses, which renders the full-screen <button> play overlay over the video, so the SECOND tap lands on a button — and the guard discarded it as "a tap on a control". Mark that overlay `data-player-surface`: visually it IS the video, so it must keep taking tap gestures despite being a <button>. The marker wins over the interactive-tag check in isControlSurfaceTouch. Adds VideoPlayer.tapSurface.test.ts, which renders the REAL component and dispatches real touch/click events at whatever element is genuinely on top. This is the gap that let four bugs ship in a row: the pure-unit tests over registerTap/isControlSurfaceTouch/isSynthesizedTouchClick all passed throughout, because each helper behaved exactly as specified — every bug was in the composition, i.e. which element actually receives a tap after Svelte re-renders. Modelling that DOM by hand in a test would just re-encode the same wrong assumption, so these render it instead. The new double-tap test was verified to fail with the fix reverted and pass with it applied, in both directions. |
||
|
|
dc8b732465 |
fix(player): controls bar taps are not player gestures (DR-098)
The bottom play/pause button did nothing. The gesture listener lives on the outer container and touch events bubble, so tapping the button ran handleTouchStart (toggle #1) and then the button's own onclick (toggle #2). The two cancelled out, leaving the control apparently dead. Ignore container-level gestures for touches that land on an interactive control: buttons, links, inputs (the seek bar), or anything inside the controls bar, now marked `data-player-controls`. The rule itself is a pure function over the ancestor chain (isControlSurfaceTouch), so it is unit tested without a DOM. Same root shape as the play-overlay bug in the previous commit: a second click target over the video that the gesture layer did not account for. |
||
|
|
b98a530f48 |
fix(player): guard the play overlay against the synthesized touch click
After the DR-098 tap rewrite, pausing became impossible while unpausing always worked — an asymmetry that pointed straight at the overlay. Pausing renders a full-screen play-overlay button over the video. The compatibility click Android synthesizes from the tap arrives ~30-130ms later, by which time that button exists, so the click lands on the OVERLAY rather than the <video>. Its onclick called togglePlayPause with no guard at all, resuming immediately. Unpausing was unaffected because it removes the overlay, leaving nothing to intercept the click. The suppression rule was only wired into the video element's handler. Extract it as isSynthesizedTouchClick() in tapGestures.ts (unit-tested) and use it from every click target layered over the video, the overlay included. Verified: 724 frontend tests pass, svelte-check clean. Bumped to 0.2.5 so the APK installs over 2004. |
||
|
|
b565c4ae6f |
fix(player): tap gestures act immediately, no deferral timer (DR-098)
Tapping the video surface pause-looped: it would unpause and bounce straight back to paused about a second later. Long-press unpaused fine, which is what pinned it to the tap path rather than the media pipeline. The gesture handler deferred the first tap's play/pause behind a 300ms timer so a second tap could cancel it and seek instead. But the timer callback cleared its own handle *before* invoking the toggle, and handleVideoClick used exactly that handle (`tapTimeout !== null`) to suppress the compatibility click Android's WebView synthesizes after a touch. So the guard was already open when the late click arrived, and it toggled a second time. Replace the deferral with immediate action — there are only first and second taps: 1st tap: toggle play/pause 2nd tap: seek, then toggle play/pause again The second toggle undoes the first, so a double tap seeks while leaving the play state exactly as it was: playing jumps and keeps playing, paused jumps and stays paused. No timer, no window race, no loop. Click suppression no longer depends on the timer: ignore detail === 0 and any click within 700ms of a touch tap, since Android can deliver the synthesized click late and with a real detail value. A swipe now undoes the touchstart toggle (latched on swipeGestureActive so it happens once, not per touchmove frame), keeping brightness swipes from changing the play state. UT-085..087 described the old deferred behaviour and are updated to the new contract. UT-091 is used for the DR-097 facade tests, since UT-089 and UT-090 were already claimed by extract-traces.test.ts. |
||
|
|
79e10d7485 |
chore(release): bump to 0.2.3
Android versionCode derives from this (0.2.3 -> 2003); required for the APK to install over the 2002 build already on the device. |
||
|
|
75cd07a5c0 |
fix(player): decide transport in Rust for webview media (DR-097)
Video on Android/Linux renders in a webview <video> element, and the frontend facade short-circuited play/pause/toggle straight into the adapter whenever one was registered. Html5PlayerAdapter.toggle() then 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. Two intents ~150ms apart therefore read *different* values and performed *opposing* actions — one playing, one pausing — which self-sustained a play/pause loop that needed no further input. On device this showed up as a fully healthy element (readyState=4, networkState=1, not seeking, not buffering, not ended) pausing itself roughly once a second, so unpausing or skipping ahead bounced straight back to paused. The root cause was that Rust held NO state for webview-rendered media: report_html5_state only re-emitted its argument, despite the comment above it claiming the controller was the single source of truth. It had nothing to decide a toggle from. Now report_html5_state tracks the reported state, and play/pause/toggle consult it and drive the element by emitting a ControlCommand — the same "backend decides, adapter executes the primitive" split player_seek_video already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer regain authority for music playback. Tests cover the loop signature directly (repeated toggles must alternate, never repeat or oppose) plus a guard that one intent yields exactly one ControlCommand — which matters on Windows, where the backend is itself webview-based and could otherwise be driven twice. |
||
|
|
64d07b8940 |
chore(release): bump to 0.2.2
Android versionCode is derived from this (0.2.2 -> 2002), so the bump is required for the APK to install over the 2001 build already on device. |
||
|
|
984e594006 |
chore(release): bump to 0.2.1
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Failing after 6m58s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
|
||
|
|
105cc082ea |
fix(search): move scope→item-type taxonomy into Rust (UR-049, DR-063)
Stage 1 of scoped-search-boundary-implementation.md — the query side.
scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.
Rust now owns the taxonomy:
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }
- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
over include_item_types, which stays for the non-search get_items
callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
paths diverge, so online and offline filter identically — the failure
mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
an explicit includeItemTypes list would silently drop People, folders,
and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.
8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.
The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.
Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.
Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
|
||
|
|
36ef231e2f |
chore(release): bump to 0.2.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m0s
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Run Tests (push) Successful in 11m20s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m20s
Build & Release / Build Linux (push) Failing after 16m33s
Build & Release / Build Windows (push) Successful in 13m46s
Build & Release / Build Android (push) Successful in 29m50s
Build & Release / Create Release (push) Has been skipped
Minor bump rather than patch: Android gains working equalizer, volume normalization and gapless playback, which are new user-facing capabilities. CHANGELOG entry written by hand rather than from `bun run release:notes`. The generated draft lists "Crossfade between audio tracks (UR-031)" as a feature of this release, which is false — the trace graph cannot distinguish code that plumbs a setting (settings.rs clamping, the backend.rs trait method, both legitimately tagged DR-034) from code that implements it, and crossfade is implemented nowhere. The release-notes tool documents its output as a reviewed draft; this is a concrete case of why. v0.1.3-v0.1.5 have no CHANGELOG entries; noted in the file rather than backfilled. |
||
|
|
cb79a376b3 |
feat(android): implement audio settings (EQ, normalization, gapless)
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
ExoPlayerBackend was the only backend not overriding the PlayerBackend trait's set_audio_settings/audio_settings defaults, so the Settings > Audio controls rendered on Android and silently did nothing — the default returns Ok(()) while applying nothing, so the failure was invisible. Rust owns what the values are (canonical 10-band ISO layout, preset curves, normalization presets); Kotlin owns when the AudioEffect objects exist, since that needs the live audio session id. - settings.rs: audio_settings_jni_payload() sanitises (crossfade clamped, band vector normalised) before serialising, so a malformed vector cannot reach the Kotlin parser. JSON rather than a wide JNI signature, matching how load() already passes subtitles — adding a field will not change the signature. - ExoPlayerBackend: set_audio_settings/audio_settings over JNI; ExoPlayerState gains the first command-side field (settings are pushed out, never reported). - JellyTauPlayer.kt: Equalizer, LoudnessEnhancer, and gapless via pauseAtEndOfMediaItems. Three details that are easy to get wrong: - Effects re-attach on onAudioSessionIdChanged. ExoPlayer rebuilds its audio sink on a format change, which invalidates effects bound to the old session; without this the EQ silently stops applying mid-queue. - All effect work is posted to mainHandler rather than run inline. AudioEffect construction from a player callback can re-enter the player and deadlock — the same shape as the AutoplayDecision lock-scrutinee bug. - Device equalizers expose a device-dependent band count (commonly 5) at fixed centres, so the canonical 10 bands are resampled by nearest centre frequency. resampleBands() is a pure @JvmStatic function so that mapping is testable without a device. Normalization is approximate, not parity: LoudnessEnhancer is a gain stage, not a true EBU R128 normalizer like MPV's dynaudnorm. Recorded as such rather than claimed as equivalent. Crossfade is deliberately excluded — unimplemented on every platform and blocked on mpv, so building it on Android alone would invert the parity gap. Tests written first and observed failing (cannot find function audio_settings_jni_payload) before the implementation: the payload contract is pinned by tests because a serde rename would otherwise silently break the Kotlin parser. Not yet verified on a physical device — AudioEffect availability and band layouts are device-specific. Requirements matrix marks these rows accordingly, and flipping the trait default to Err(not_implemented()) is deferred until that verification lands. |
||
|
|
37ffabee06 |
chore(release): bump to 0.1.5; regenerate traceability matrix
Build & Release / Run Tests (push) Successful in 4m35s
Build & Release / Build Linux (push) Successful in 18m25s
Build & Release / Build Windows (push) Successful in 13m27s
Build & Release / Build Android (push) Successful in 29m9s
Build & Release / Create Release (push) Successful in 16s
Registers UR-061/DR-092 (tap gestures) and UT-062 (background-audio bridge reporting), and regenerates the matrix — 313 TRACES across 299 files. |
||
|
|
13e0860401 |
fix(player): expired sleep timer stops without triggering autoplay
Stopping the backend makes the native player fire its ended callback, which lands in on_playback_ended. 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 runs, showing a next-episode popup (or advancing outright) right after the user's sleep timer expired. Record EndReason::UserStop before the stop reaches the backend. That is the honest label: the stop was user-initiated, just via the timer they set rather than the stop button. TRACES: UR-023, UR-026 | DR-029 |
||
|
|
e5d3cc06f2 |
fix(android): register WebView JS bridges once; stop audio-focus fight
Locking the screen killed audio on video playback even with the background-audio toggle armed. configureWebViewForMedia() ran from onCreate's delayed post AND from every onResume, re-calling addJavascriptInterface on each pass — five times in a 45s session. WebView binds injected objects at page-load time, so re-injecting over a live page leaves JS holding a stale proxy: the object stays truthy (passing the `bridge()?.` optional chain) while its methods vanish. Logcat showed 66 "WebView: Unknown object" errors and, in JS, "TypeError: setEnabled is not a function". So the toggle turned blue but never reached native. backgroundAudioEnabled stayed false, onStop never dispatched 'jellytau-background', the handoff never ran, and audio stopped the instant the screen locked. PiP and audio focus broke identically. - Register the bridges exactly once per WebView (identity-compared), and split the idempotent settings/chrome-client work into configureWebViewSettings() so it still runs on every resume. - Forward WebView console output to logcat as "JellyTauWeb". The frontend was previously invisible to adb, which is what made this bug so hard to place; keep it for the next boundary-spanning diagnosis. - setBackgroundAudioEnabled now reports whether native was actually reached instead of silently no-oping, so a dead bridge can never again masquerade as an armed toggle. Removing the re-injection revived a latent conflict it had been masking: the focus calls started working, and three AUDIOFOCUS_GAIN requesters inside one uid began fighting — MainActivity, ExoPlayer, and Chromium's own AudioFocusDelegate. The grant was followed ~45ms later by AUDIOFOCUS_LOSS, whose handler paused playback, so arming background audio (or just pressing play) paused the video in a loop. WebView already manages focus for <video>. Drop the redundant AndroidAudioFocus bridge, its listeners and its helpers entirely, and leave focus to whichever engine is actually rendering — consistent with the player-is-authoritative principle. Also drops the dead AndroidBackgroundAudio.isSupported() probe, unused since the button gate moved to platform(). TRACES: UR-040 | IR-025, DR-051 | UT-062 |
||
|
|
b9f026e215 |
chore(release): bump to 0.1.2
Adds CHANGELOG.md, which the release-notes template in docs/release-checklist.md already linked to but which had never been created. |
||
|
|
5927299c0f |
feat(search): rank results by match quality and split TV/People groups
Neither search backend orders by *where* the query matched, so a mid-word hit could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above "Parks and Recreation". Add `domain/search_rank.rs`, which sorts by match position (prefix → word-start → mid-word substring → no name match), then by media kind so a container outranks its own contents. The sort is stable, so each backend's own relevance still breaks ties it was never overruled on. `repository_search` applies it to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land. Ranking lives in Rust because "a better match" is domain vocabulary, not presentation. On the frontend, the combined `tvShows` result group splits into separate Shows and Episodes groups so a show no longer competes with its own episodes for a slot, and a People group is added so searching an actor's name reaches their bio. A stored `tvShows` order expands in place, keeping the position an upgrading user chose for it. |
||
|
|
4b9350c949 |
chore(release): bump to 0.1.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m25s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 3m52s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m52s
Build & Release / Build Linux (push) Successful in 17m32s
Build & Release / Build Windows (push) Successful in 13m14s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 13s
|
||
|
|
ee584aced2 |
fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a MediaType::Audio item, so autoplay's video-only checks stopped recognising it as an episode: playback simply ended at the episode boundary instead of continuing to the next one. - Carry episode identity (item_type, series_id) through the background-audio handoff so the backend queue item still knows it's an episode; is_episode_item now trusts item_type over the media_type heuristic, and the sleep timer's episode counter follows. - The frontend normally performs the advance by navigating to /player/<id>, which is unavailable while the WebView is suspended. advance_to_next_episode_audio_only drives it entirely in the backend: fetch the next episode, build its audio-only stream URL, and load it into the native audio player, preserving episode identity so the following boundary advances too. - Android's autoplay dispatch routes background-audio episodes to that backend advance and keeps the countdown path for the foreground. - get_audio_only_stream_url_for_video joins the MediaRepository trait (online delegates to the existing builder, offline errors) so the controller can reach it without a frontend round-trip. TRACES: UR-040, UR-023 | DR-052 | JA-032 |
||
|
|
742ad88a29 |
feat(build): cross-platform desktop packaging; bump to 0.1.0
Build & Release / Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Build Linux (push) Has been cancelled
Build & Release / Build Android (push) Has been cancelled
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Build & Release / Create Release (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
Adds Docker-based packaging for Linux desktop (deb/rpm), Arch
(.pkg.tar.zst via makepkg), and Windows. Windows cross-compiles from
Linux via the official Tauri path — the x86_64-pc-windows-msvc target
driven by cargo-xwin — and produces an NSIS installer (nsis via
tauri.conf.json targets, since the CLI rejects --bundles nsis on a Linux
host). Verified end to end: builds jellytau.exe + jellytau_x64-setup.exe.
Unifies everything on one registry builder image (Dockerfile.builder):
Android SDK/NDK, rpm/file, clang(+clang-cl)/lld/llvm/nsis, cargo-xwin and
the msvc target. Packaging tools sit in a trailing layer so tool changes
rebuild in ~1min instead of ~15. Desktop stages are thin FROM
${BUILDER_IMAGE} layers; Arch uses a separate archlinux image.
CLAUDE.md: CI must install no system toolchains — everything lives in the
image. Bumps version 0.0.18 -> 0.1.0 (Windows support + webview audio +
equalizer).
TRACES: UR-003, UR-005 | DR-004
|
||
|
|
d4e2cd120c |
feat(player): webview audio backend for platforms without a native one
Adds WebviewAudioBackend, used on non-Linux/non-Android targets (e.g. Windows) where there is no libmpv/ExoPlayer. Instead of decoding, it emits a WebviewAudioLoad event with the stream URL; a frontend <audio> element (WebviewAudioAdapter + webviewAudio service) plays it and reports state/position back through the existing player_report_* round-trip, so the Rust PlayerController stays the single source of truth. Play/pause/ seek reach the element via the existing ControlCommand event. All video already renders in the webview on every platform, so this completes audio-only playback for Windows (video via WebView2, audio via <audio>). Pure Rust + Tauri events, so it still cross-compiles from Linux. Regenerates bindings.ts (adds webview_audio_load; also carries the equalizer EQ bindings). TRACES: UR-003, UR-004, UR-005 | DR-004 |
||
|
|
c543f90ad3 |
feat(audio): graphic equalizer with presets and custom bands
Adds a 10-band graphic equalizer to AudioSettings (enabled flag + per-band dB gains, normalised to 10 entries and clamped to range). Presets return gain curves; the settings page gains EQ UI. libmpv applies the filter on Linux (Android parity pending). Old persisted settings without EQ fields load as disabled + flat. Also includes the requirements/traceability/ux-flows doc updates for this feature and the home long-press routing (UR-058/DR-087). TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082 |
||
|
|
e2c9d68311 |
docs(downloads): mark UR-055/056 Done; fix colliding UT ids
The browsable Downloaded library + Transfers split + on-disk usage
(
|
||
|
|
57b24f8c74 |
fix(offline): Downloaded browse groups by container and loads on large libraries
Two bugs on the Downloaded browse surface (UR-055/UR-056): 1. Grouping — browsing a downloaded *library* listed individual leaves (songs, episodes) instead of their containers. The library-level match in `get_downloaded_items` selected every downloaded item on the server; add a NOT EXISTS clause so the top level shows only albums/series/movies, with leaves still reachable by drilling in. Regression tests for music + TV. 2. "Loading your downloads…" hung on large libraries. The disk-usage partiality query did an OR-based self-join over the entire synced catalog (O(items^2), unindexable). Narrow it to downloaded containers first via a CTE, and add the missing idx_items_season index (migration 020 + base schema) — parent_id/album_id/series_id were already indexed. Also annotate the existing backend tests that cover the IT-016/IT-017 end-to-end offline-listing scenarios with their trace IDs. |
||
|
|
90f03dd142 |
fix(player): show background-audio button on all Android video playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m18s
Traceability Validation / Check Requirement Traces (push) Successful in 1m2s
Build & Release / Run Tests (push) Successful in 10m46s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m16s
Build & Release / Build Linux (push) Successful in 24m59s
Build & Release / Build Android (push) Successful in 33m5s
Build & Release / Create Release (push) Successful in 17s
The audio-only (background-audio) button was gated on the AndroidBackgroundAudio JS-bridge probe, resolved once as a const at mount. The bridge is injected into the WebView asynchronously and races component mount, so on some loads the probe returned false and never recovered, hiding the button on 'some videos' at random. Gate on platform() === 'android' instead (synchronous, stable), matching the convention in VolumeControl. toggleBackgroundAudio() already no-ops if the bridge is momentarily absent. Bump version to 0.0.18. |
||
|
|
1968c06172 |
domain: neutral StreamKind for media streams (phase 4d)
Add StreamKind enum (audio/video/subtitle/other) to the domain module with a total stream_kind_from_jellyfin mapper. MediaStream gains a kind field (dual-carry), populated at the mapping seam. Frontend VideoPlayer track/ subtitle selection and the channel-video check now use stream.kind instead of the Jellyfin stream.type string. Rust 456 (+ stream_kinds_map test), frontend 644, check clean. |
||
|
|
ec8a7610f5 |
domain: player/reporting ticks -> milliseconds (phase 4c)
Playback position now crosses the IPC boundary in milliseconds. Ticks survive only inside Rust (DB storage, Jellyfin API) and at the genuine remote-session boundary (session seek / transfer / RemoteControls). Rust command signatures (ms in, converted to ticks internally): - storage_update_playback_progress / _context: position_ms - repository_report_playback_start / _progress / _stopped: position_ms - PlaybackProgress.position_ticks -> position_ms (converted in the query) Frontend: - playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume: seconds*1000 / durationMs/1000 instead of tick math. - repository-client + syncService param names -> positionMs. - Tests updated to ms fixtures/assertions. Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks, sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the remote Jellyfin session API. Rust 456, frontend 644, check clean. |