Commit Graph
328 Commits
Author SHA1 Message Date
dtourolle a5535f2941 fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.

Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.

Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.

Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.

Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.

docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
2026-08-15 23:52:02 +02:00
dtourolle d49d027020 docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had
claimed for picture-in-picture in the meantime. Renumbered to DR-162
across the Rust and frontend TRACES comments (the PiP tags in
VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160)
and regenerated bindings.ts.

Adds the requirement rows the tags point at: UR-074 for the user need, and
DR-162 covering why the cap has to reach the PlaybackInfo negotiation and
not only the transcode URL, why the ceiling is process-wide, and why the
Settings default persists while the in-player override does not. Notes
that this gives UR-070 its resume-at-the-same-point mechanism while the
server-offered rendition list that requirement also asks for stays
proposed. UT-156/157 record what the tests pin.

docs/specs/streaming-bitrate-cap.md carries the layer assignment — the
step definitions, the video/audio split, the resolution pairing and the
reload decision are all Rust; the frontend holds a serde token and the
labels it was handed.

TRACES: UR-074 | DR-162 | UT-156, UT-157
2026-08-15 16:39:53 +02:00
dtourolle 9c352fdb77 Merge branch 'fix/android-versioncode-floor' 2026-08-15 16:35:27 +02:00
dtourolle dda2ff86a3 feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.

StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.

The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile 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 URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.

The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.

Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — unlike the rest of VideoSettings,
because a limit set for a metered connection that silently reverts to
uncapped on the next launch spends the user's data with no changed
setting to see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.

Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.

TRACES: UR-074 | DR-160 | UT-156, UT-157
2026-08-15 16:34:56 +02:00
dtourolle 8ad3dc5c4f fix(android): raise the versionCode floor so 0.5.x can install over v0.5.2
v0.5.2 shipped Android versionCode 5002, from an earlier `minor*1000` scheme.
The `minor*100` formula that replaced it yields only 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. Every 0.5.x release built from this script was
un-installable for anyone already on v0.5.2.

This is the exact failure the block was written to prevent; its floor simply
went stale. The floor tracked "codes below 1000 are already in the field", which
was true when written, but a 5002 build has shipped since — and the highest code
this formula has *produced* is not the same as the highest code in the field.

Widen the multipliers and raise the floor past 5002:

    code = 10000 + major*1000000 + minor*1000 + patch

    0.0.14 -> 10014    0.5.2 -> 15002    0.6.0 -> 16000
    0.1.0  -> 11000    0.5.3 -> 15003    1.0.0 -> 1010000

Still strictly monotonic across the upgrade sequence. The guard test gains a
case pinning 0.5.3 above the 5002 in the field, so the floor is expressed as
"clears what shipped" rather than a literal that can silently go stale again.
2026-08-15 16:31:38 +02:00
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00
dtourolleandClaude Opus 5 50934e2ac6 ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:

    Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
    java.net.SocketException: Unexpected end of file from server

`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.

Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.

sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.

Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.

Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.5.2
2026-08-13 07:47:43 +02:00
dtourolleandClaude Opus 5 8fbc080733 Merge branch 'fix/android-resume-position'
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m2s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m58s
Build & Release / Run Tests (push) Successful in 8m22s
Build & Release / Build Linux (push) Successful in 19m40s
Build & Release / Build Windows (push) Successful in 8m6s
Build & Release / Build Android (push) Failing after 13m33s
Build & Release / Create Release (push) Skipped
Resume-playback fixes across the three layers where the position was lost:

- DR-150 path: the Android native (ExoPlayer) surface never applied the
  resume seek, so resume always played from the start on device.
- DR-154: a stop-report the server could not be told about was logged and
  dropped, even though sync_queue and its drain were built and running.
- DR-155: the server's watch position was never mirrored into the local
  user_data row the resume check reads, so resume never crossed devices.

Also carries concurrent fixes merged in from parallel work: download
bitrate, series resume ordering, Recently Added grouping, remote-session
volume handoff, and home library card heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.5.1
2026-08-12 22:14:44 +02:00
dtourolleandClaude Opus 5 ba5fd55204 fix(sync): mirror the server's watch position so resume crosses devices (DR-155)
The resume check reads the local user_data row and nothing else, but
mirror_user_data -- the only path by which server UserData lands in that
table -- mirrored is_favorite alone, and returned early whenever that
field was absent, which is exactly the shape of an ordinary watched
episode. playback_position_ticks was therefore 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. Same user-visible symptom as the Android bug fixed earlier on this
branch, from an unrelated cause -- which is why resume read as broadly
flaky rather than as one defect.

The mirror now carries the position alongside the favourite flag under
the same pending_sync = 0 conflict rule, so a local position still
waiting to be pushed is never pulled backwards by a server that has not
yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the
stored value for a field the server omitted rather than nulling it, and
a row with neither field is still skipped rather than fabricated as
zeroes.

Mirroring alone was not sufficient. 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 via race_with_refresh, the
reusable form of what get_items already did inline. That asymmetry is
why browsing a season picked up other devices' state while opening the
episode directly did not. The refreshed value lands for the next read;
the cache-first race still answers immediately.

The DR/total counts in extract-traces.test.ts are updated for DR-154 and
DR-155 -- that edit is the test's intended signal that the CI gate's
denominator is live rather than frozen.

Verified red->green in the jellytau-builder image: both new tests failed
before the fix. Full Rust suite passes (634), cargo fmt clean, clippy
adds no new warnings; frontend suite (933) and svelte-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:20 +02:00
dtourolle fec4b7ae8c Merge branch 'fix/download-bitrate-param' into HEAD 2026-08-12 20:11:12 +02:00
dtourolleandClaude Opus 5 2ca2174cea fix(player): return volume control to the local speaker when a remote session stops
Stopping a remote session left Android stuck on the remote volume slider
with no way back to the device speaker.

Two causes:

1. `player_stop`'s remote branch sent "Stop" to the session and returned
   without touching the playback mode, so the manager stayed in Remote.
   It now drops to Idle, mirroring what the local branch already does.

2. Volume routing was torn down at a single call site
   (`transfer_to_local_inner`), so every *other* exit from remote mode
   leaked the Android VolumeProviderCompat. Routing is now derived from
   the transition inside `set_mode`: entering remote attaches control,
   any exit from remote hands it back to the local media stream. This
   also covers the frontend `disconnect()` path (Remote -> Idle) and the
   local-playback-start paths (Remote -> Local).

Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable
off-device — the real implementation is Android JNI. Tests cover
remote->idle, remote->local, remote->remote (re-arms, never releases),
and that local/idle transitions leave routing untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:09:17 +02:00
dtourolleandClaude Opus 5 0ca2857c3a fix(catalog): show a new album once in Recently Added, not once per track
Recently Added listed every newly-added track individually, so importing a
14-track album filled the whole row with that one album and buried everything
else. Both code paths that build the row had the same symptom from separate
causes:

- Online: Jellyfin's /Items/Latest defaults to GroupItems=false, returning each
  new leaf on its own. Send GroupItems=true so the server collapses children
  into the container that was added.
- Offline: the downloaded-items CTE deliberately matches leaves *and* their
  container (right for browsing, wrong here), so a downloaded album returned the
  album plus each of its tracks. Drop a leaf only when its own container is in
  the same result.

Items with no container (movies, standalone tracks) are unaffected in both
paths. The online URL is extracted into build_latest_items_endpoint so it can be
asserted without an HTTP server, matching build_favorites_endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:57 +02:00
dtourolleandClaude Opus 5 1f32e4040b Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from 3619f71 with no
overlapping files:

  remote: uniform card heights; resume after furthest-watched episode
  local:  Android native-path resume; queued watch-position sync (DR-154)

Merged cleanly with no conflicts. The series_progress policy tests pass
against the merged file (19/19).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:21:18 +02:00
dtourolleandClaude Opus 5 e4632bb2b2 fix(sync): queue a watch position the server could not be told about (DR-154)
sync_queue and its drain (DR-131) were built, tested and running, but the
stop-report path never fed them, so closing a video while the server was
unreachable lost the resume point outright.

HybridRepository::report_playback_stopped is a bare pass-through to the
online repository ("Playback reporting goes directly to server"), and on
failure the error surfaced to a frontend catch whose own comment read
"Server error - could queue, but for now just log". Both producers that
would have queued it -- PlaybackReporter::queue_for_sync in Rust and
syncService.queuePlaybackProgress on the frontend -- have no callers on
the playback path. user_data.pending_sync was dutifully set to 1, but
nothing drains that flag for positions the way favourites do (DR-120).

The command layer now enqueues a report_playback_stopped row whenever the
push fails; the existing drain already parses and replays that operation.

The pending row for an item is superseded in place rather than appended
to: progress is reported every 10s, so a server that stays down would
otherwise add a row per tick, all obsoleted by the newest -- the
unbounded queue DR-131 exists to prevent. Only pending/failed rows are
superseded, since reviving an abandoned row restores that same growing
counter. Queueing is best-effort and never fails the command: the local
position is already saved, so a failed queue write must not be reported
as a lost position.

Verified red->green in the jellytau-builder image: the four new tests
failed to compile (enqueue_playback_stopped not found) before the fix.
Full Rust suite passes (627 tests), cargo fmt clean, clippy adds no new
warnings; frontend suite (933) and svelte-check also clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:18:11 +02:00
dtourolleandClaude Opus 5 2d50744320 fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so
"resume from where you left off" always played from the start on Android.

Two layers each assumed the other did the seek:

- The only code acting on `initialPosition` was handleCanPlay, an HTML5
  <video> event handler. The native path has no <video> element, so
  `canplay` never fires and that seek never ran.
- NativePlayerAdapter.load() had an initialPosition branch, but it only
  recorded the number, claiming "the native backend performs the actual
  seek internally". It does not: PlayItemRequest carries no start
  position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0.
- VideoPlayer never called adapter.load() at all, so even that branch was
  unreachable.

The frontend therefore believed it had resumed (the seek bar showed the
resume point) while ExoPlayer played from the beginning.

NativePlayerAdapter.load() now issues the backend seek, excluding live
streams (no resume point; seeking knocks the HLS window off its live
edge). VideoPlayer calls it on the native branch and marks the initial
seek as performed so the existing $effect does not fire a duplicate.
The HTML5 path is untouched: seeking before metadata is clamped to 0,
which is exactly what handleCanPlay waits for.

Verified red->green: the new test failed with "Number of calls: 0"
before the fix. Full frontend suite passes (933 tests); svelte-check
and check:boundary are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:51:37 +02:00
dtourolleandClaude Opus 5 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>
2026-08-12 18:44:54 +02:00
dtourolleandClaude Opus 5 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>
2026-08-12 18:23:26 +02:00
dtourolleandClaude Opus 5 85bd227714 fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music
library rendered aspect-square (144px tall at w-36) next to video
libraries at aspect-video (81px), leaving the home row ragged.

Add an optional `aspect` prop that overrides the derived ratio, and pass
aspect="video" from the home Libraries strip. Unset, behaviour is
unchanged, so the /library overview grid and the media carousels keep
their per-type ratios. Artwork already uses object-cover, so square music
art crops rather than distorts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:13:27 +02:00
dtourolleandClaude Opus 5 3619f71aba build: make the git tag the single source of truth for the version (DR-153)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 7m36s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m57s
Build & Release / Build Linux (push) Successful in 20m4s
Build & Release / Build Windows (push) Successful in 8m42s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 17s
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore 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 is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.

The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.

Two edge cases the previous inline version got wrong:

- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
  The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
  is still a full ref. That reached the validator verbatim and would have failed
  every untagged Android build; a non-tag ref now falls back to git describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.5.0
2026-08-11 21:21:58 +02:00
dtourolle 8e081845d0 Merge pull request 'Feat/android native video' (#13) from feat/android-native-video into master
Reviewed-on: #13
2026-08-11 19:03:45 +00:00
dtourolleandClaude Opus 5 5fa74d9e34 docs: renumber to DR-150/151/152 after rebase onto master
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 7m56s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 2m49s
master landed DR-148 and DR-149 for unrelated audio-decode work (0.4.7/0.4.8)
while this branch was in flight, and both sides claimed the same two IDs. The
native-video requirements move to DR-150 (native rendering behind the flag),
DR-151 (the severed SurfaceView attach chain) and DR-152 (capabilities reported
by Rust). UT-090 was likewise already taken by the seek-bar test, so the adapter
selection test moves to UT-149 and is registered in the table.

The spec header also cited DR-023/DR-024, which are the subtitle and audio-track
selection UI requirements — unrelated to this work. Corrected, with a note so the
wrong IDs are not reintroduced from the draft.

extract-traces.test.ts asserts the live requirement counts on purpose, so adding
three DRs moves DR 144→147 and total 282→285.

Subtitles on the native path are not a regression from this branch: master's
6a712c4 already fixed the root cause (MediaItem.subtitles was hardcoded to
vec![], so ExoPlayer always received zero SubtitleConfigurations) and that fix is
now underneath these commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 c480276a97 docs(spec): native video confirmed working on device
The spike's central question — can a SurfaceView be composited behind a
transparent Tauri WebView on Android — is answered yes, verified on a physical
device. No upstream issue blocked it and none demonstrated it; this appears to
be the first working instance.

Marks DR-148 done behind the flag and records what is confirmed versus what is
still open: playback and positioning are verified, but the individual native
controls (seek, audio-track, subtitle), the mini-player transition, and the
MediaCodec hardware-decode claim are not yet each measured. The mini-player
transition is called out as the known gap, since it is the one case where the
fullscreen assumption behind "no rect plumbing needed" does not hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 ca490c34ec docs(traceability): regenerate matrix for the native-video requirements
DR-148/149/150 now resolve; coverage 86% (243/283), no orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 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>
2026-08-11 20:57:58 +02:00
dtourolle 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.
v0.4.8
2026-08-11 20:11:22 +02:00
dtourolle 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.
2026-08-11 20:07:11 +02:00
dtourolle 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
2026-08-11 20:03:19 +02:00
dtourolle 211792947d fix(player): render subtitle tracks on the Linux HTML5 path (UR-020)
Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.

The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.

Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.

Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).

Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.

Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.

Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.

Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.

TRACES: UR-020 | DR-023 | UT-143, UT-144
2026-08-11 19:25:39 +02:00
dtourolle 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.
2026-08-11 19:13:46 +02:00
dtourolle 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.
v0.4.6
2026-08-09 16:38:07 +02:00
dtourolle 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.
2026-08-09 15:07:18 +02:00
dtourolle 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.
2026-08-09 15:06:49 +02:00
dtourolle 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.
2026-08-09 15:05:16 +02:00
dtourolle 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.
2026-08-09 15:01:57 +02:00
dtourolle 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.
2026-08-09 14:47:03 +02:00
dtourolle 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
v0.4.1
2026-08-05 12:31:16 +02:00
dtourolle 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
2026-08-05 12:26:25 +02:00
dtourolle 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
v0.4.0
2026-08-04 20:22:01 +02:00
dtourolle 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
2026-08-04 20:20:36 +02:00
dtourolle 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
2026-08-04 20:15:28 +02:00
dtourolle 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.
2026-08-04 19:39:14 +02:00
dtourolle 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.
2026-08-04 17:35:17 +02:00
dtourolle 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.
2026-08-04 14:45:10 +02:00
dtourolle 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
v0.3.0
2026-08-03 20:37:43 +02:00
dtourolle a818fee297 fix(player): re-entering a video no longer opens the audio player (DR-100)
Leaving a video and returning to it rendered the movie/episode in
AudioPlayer. 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 Rust
controller still reported that item as its loaded media. Re-entering the
route therefore 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.

Both decisions move into playerSurface.ts as pure functions:
shouldReuseActivePlayback excludes video, so video always takes the full
load path and gets its stream URL and resume position;
resolvePlayerSurface maps video-without-a-stream-URL to "pending"
(spinner) rather than falling through to audio.
2026-08-03 18:12:37 +02:00
dtourolle 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.
v0.2.9
2026-08-02 18:10:18 +02:00
dtourolle 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.
v0.2.8
2026-08-01 10:41:23 +02:00
dtourolle e381d626c1 docs(requirements): UR-061/DR-092 no longer describe the removed deferral
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Failing after 7m23s
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
Both still described the 300ms deferred-tap design that DR-098 replaced
with immediate action, so the generated release notes advertised
behaviour the code no longer has.
v0.2.7
2026-07-30 16:13:29 +02:00
dtourolle 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.
2026-07-30 15:44:38 +02:00
dtourolle 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.
2026-07-30 15:27:01 +02:00