Commit Graph
311 Commits
Author SHA1 Message Date
dtourolle 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.
2026-07-28 23:03:31 +02:00
dtourolle b11188e9dd docs(player): backend unification findings + correct false parity claims
Investigation into unifying the playback backends (Linux/MPV, Android/ExoPlayer,
Windows/webview) onto one engine with hardware acceleration. Conclusion: video
cannot be unified onto a native engine; audio can.

The blocker is not mpv-specific. WebKitGTK, WebView2 and Android WebView each
draw into their own compositor surface, so a native video surface sits either
entirely above or entirely below the webview and cannot interleave with HTML.
GStreamer and libVLC fail identically. mpv would additionally regress streaming:
it has no adaptive bitrate, while the current hls.js path does.

Six specs added:
- playback-backend-unification: the analysis and decision, with evidence
- android-audio-settings-parity: set_audio_settings on ExoPlayerBackend
- android-native-video-spike: timeboxed test of SurfaceView compositing
- windows-native-audio-backend: replace the webview <audio> shim with libmpv
- libmpv2-migration: dead libmpv git pin -> libmpv2, plus a LICENSE file
- playback-docs-corrections: the requirement-status fixes applied here

Corrections to requirements.md, all verified against source:
- UR-031/DR-034 claimed crossfade was "Done (Linux only)". It is implemented
  nowhere (mpv_backend.rs has a bare TODO) and is architecturally blocked on
  mpv, whose single-stream audio chain cannot feed acrossfade's two inputs.
- Parity matrix listed crossfade as a Linux/Android gap; it is neither.
- The matrix omitted the equalizer, which has the same Linux-only shape.
- The suggested ConcatenatingMediaSource is deprecated in current Media3.

nativeAdapter.ts cited tauri#10152 as an upstream blocker for native Android
video. That issue is a stale feature request, dead since 2024-07-01; the
capability shipped in tauri 27d01834 (2024-09-02), and the related
black-screen bug was fixed in wry 0.39.4 (we ship 0.55.x). What is genuinely
unproven is SurfaceView-behind-WebView compositing, which the spike now tracks.
2026-07-28 23:03:17 +02:00
dtourolle f636b6b151 Merge: Android WebView bridge + audio-focus fixes, tap gestures, sleep timer (v0.1.5)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m44s
2026-07-28 01:33:58 +02:00
dtourolle 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.
v0.1.5
2026-07-28 01:33:20 +02:00
dtourolle 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
2026-07-28 01:33:10 +02:00
dtourolle d1c01a6bc3 feat(player): defer single tap so a double tap doesn't also toggle pause
A tap cannot be classified when it lands — it may still turn out to be
the first half of a double tap. Play/pause is therefore deferred until
the 300ms double-tap window closes, and cancelled outright if a second
tap arrives, so a double tap seeks without also toggling pause.

Forward skip moves from 10s to 30s (back stays 10s), for both double tap
and the keyboard arrows.

The timing rules live in tapGestures.ts so they are unit-testable
without mounting the player. Rapid double taps now chain off a
still-in-flight seek target instead of all resolving against the same
not-yet-updated position.

TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
2026-07-28 01:33:04 +02:00
dtourolle 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
2026-07-28 01:32:57 +02:00
dtourolle 5759a97289 chore: ignore Arch packaging build artifacts
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m49s
Build & Release / Build Linux (push) Successful in 17m46s
Build & Release / Build Windows (push) Successful in 13m20s
Build & Release / Build Android (push) Successful in 29m14s
Build & Release / Create Release (push) Successful in 15s
A local `scripts/build-arch.sh` run leaves a vendored cargo cache
(`.cargo-arch/`), a makepkg workdir (`packaging/arch/pkg/`, `src/`) and the
built package in the tree — tens of thousands of untracked files that bury real
changes in `git status`.
v0.1.2
2026-07-25 15:53:10 +02:00
dtourolle 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.
2026-07-25 15:21:51 +02:00
dtourolle b7a7037194 docs: add UR-060 search relevance requirement; regenerate matrix
Records the search relevance and grouping behaviour as UR-060, with DR-090
(Rust relevance ranking) and DR-091 (Shows/Episodes split, People group,
stored-order migration). DR-066 now points at DR-091 for the current group set
instead of restating a default order that has since changed.
2026-07-25 15:13:52 +02:00
dtourolle 124da29fc7 fix(search): route the library header search to /search
Typing in the desktop header search bar ran library.search() in place and
relied on /library rendering the results inline. On every other /library/**
route nothing rendered them, so the search bar looked broken: results were
fetched and never shown.

Make /search the single surface that renders results. The header bar becomes a
navigator — it hands the query and route-derived scope to /search via ?q= and
?scope=, which seed the page and run the search on arrival. The inline result
block and the header's scope chips are removed; the chips live on /search,
which owns the results. The empty `all` scope is omitted from the URL, and
typing while already on /search does not push a history entry per keystroke.
2026-07-25 15:13:45 +02:00
dtourolle 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.
2026-07-25 15:13:32 +02:00
dtourolle 7650efcb7f fix(player): scale video to fill the player viewport
The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.

Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
2026-07-25 15:12:53 +02:00
dtourolle 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
v0.1.1
2026-07-25 09:25:52 +02:00
dtourolle d01c1216b8 docs: red-green rule for bug fixes; regenerate traceability matrix
CLAUDE.md now states the failing-test-first rule explicitly: write a test
that reproduces the bug and watch it fail before applying the fix, and
extract buried logic into a plain .ts module so it can be unit-tested. A
test written against already-fixed code can pass for the wrong reason.
2026-07-25 09:21:22 +02:00
dtourolle fb967433f0 fix(library): populate "More Episodes" for series without season folders
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
2026-07-25 09:21:15 +02:00
dtourolle 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
2026-07-25 09:21:08 +02:00
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00
dtourolle c3ead64748 ci(release): build Windows NSIS installer on tag
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m37s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m13s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m36s
Build & Release / Build Linux (push) Successful in 17m58s
Build & Release / Build Windows (push) Successful in 22m16s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 15s
Adds a build-windows job to the release workflow, cross-compiling the
Windows NSIS installer from Linux via the builder image (MSVC target +
cargo-xwin, no toolchain installs). Wires its artifacts into
create-release alongside Linux and Android.

TRACES: UR-003 | DR-004
v0.1.0
2026-07-25 00:02:33 +02:00
dtourolle 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
2026-07-24 23:49:50 +02:00
dtourolle 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
2026-07-24 23:49:23 +02:00
dtourolle 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
2026-07-24 23:49:13 +02:00
dtourolle 589f08b873 feat(home): tap opens detail, long-press plays from home cards
Home carousel cards route a tap to the item's detail / Episode Focus
View and a ~500ms long-press to a confirm-then-play flow. MediaCard gains
an onLongPress prop with pointer-based detection (cancelled on >10px move
so carousel scroll is unaffected, trailing click suppressed). Episode
taps route to /library/<seriesId>?episode=<id>; the bare-episode detail
page links back to its parent series/season.

TRACES: UR-058 | DR-087
2026-07-24 23:49:03 +02:00
dtourolle e2c9d68311 docs(downloads): mark UR-055/056 Done; fix colliding UT ids
The browsable Downloaded library + Transfers split + on-disk usage
(f25deba, plus today's grouping/perf fixes) fully implement UR-055 and
UR-056, but the requirements doc still listed them and DR-081..085 as
Planned. Flip to Done.

Also fix UT-id collisions: the downloaded-browse and formatBytes tests
reused UT-046..050 (already assigned to smart-cache/playlist tests in the
matrix). Reassign to UT-071..078 and register them in §4, including the
new music/TV container-rollup and orphan-leaf regression tests.
2026-07-24 22:22:43 +02:00
dtourolle 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.
2026-07-24 21:42:12 +02:00
dtourolle 6391720d23 docs(offline): mark UR-052 offline-listing feature and its tests Done
The DR-079/DR-080 root-cause fixes for issue #10 landed in 8f4f651; the
requirements doc still listed UR-052 (and DR-078/079/080, UT-068/069/070,
IT-016/017) as Broken/Partial/Pending. Flip them to Done and regenerate the
traceability matrix. Existing backend tests already cover the IT-016/017
end-to-end scenarios (annotated with their IDs in the code commits).
2026-07-24 21:41:57 +02:00
dtourolle 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.
v0.0.18
2026-07-24 20:32:08 +02:00
dtourolle 514e42fccb Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s
v0.0.17
2026-07-23 22:18:59 +02:00
dtourolle 9b1c9b3c91 feat(settings): rework settings page; remove unused SkeletonLoader/StorageManagement
Settings page refactor plus supporting docs (requirements, ux-flows,
traceability) and the frontend-domain-model spec with implementation-status
banner. Removes SkeletonLoader and StorageManagement components (no remaining
references).
2026-07-23 22:18:37 +02:00
dtourolle 1780109fb1 domain: flip last missed catalog .type reader (movies page) to .kind
library/movies/+page.svelte handleItemClick still read item.type === "Folder"
(missed in the phase-2a sweep). Now item.kind === "folder". Final sweep
confirms zero catalog .type/runTimeTicks/primaryImageTag/playbackPositionTicks
reads remain anywhere in src/.

Decision on dropping the dual-carry legacy Rust fields: KEPT. They are now
internal-only (no frontend reader), but internal Rust still depends on
item_type (SQL WHERE item_type='Audio', player audio filter, player-queue
passthrough) and the DB stores ticks. Removing them needs a DB/query-layer
migration with real regression risk and zero frontend benefit — out of scope
for the frontend-model goal, which is met.

Frontend 626, check clean.
2026-07-23 22:15:15 +02:00
dtourolle 3a18ad060b domain: delete dead jellyfinFieldMapping; scope playbackUnits to session boundary (phase 4e)
jellyfinFieldMapping.ts (SORT_FIELD_MAP friendly->Jellyfin sort names) had
zero consumers — sort code passes raw Jellyfin field names directly — so it
and its test are deleted.

playbackUnits.ts can't be removed: its tick<->seconds helpers are still the
correct converters for the remote Jellyfin *session* boundary
(SessionInfo.playState.positionTicks, NowPlayingItem.runTimeTicks), which
legitimately arrives in ticks. Documented that narrowed role; formatTime/
calculateProgress remain neutral seconds-based presentation helpers.

Note (out of scope): sortBy still passes raw Jellyfin field names
("SortName", "CommunityRating") — a separate sort-taxonomy leak that would
need its own Rust SortKey, like the search-scope work.

Frontend 626 tests (jellyfinFieldMapping's 18 removed with it), check clean.
2026-07-23 22:13:54 +02:00
dtourolle 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.
2026-07-23 22:11:31 +02:00
dtourolle 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.
2026-07-23 22:02:29 +02:00
dtourolle 93d198ce21 domain: primaryImageTag -> imageId end-to-end (phase 4a/4b)
Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry),
populated from primary_image_tag at every construction/conversion site.
Regenerated bindings.

Frontend: all catalog + player + merged readers now use imageId. The
NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote
session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the
neutral kind/durationMs/imageId. Types that are genuinely out of scope
(Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag.

Rust 456, frontend 644, check clean.
2026-07-23 21:40:58 +02:00
dtourolle 2b42b74912 domain: replace user-facing Jellyfin type badge with kind label (phase 3b)
The library detail page showed the raw Jellyfin item_type string
("MusicAlbum") to users. Add utils/mediaKind.ts with kindLabel(), a
presentation-only MediaKind -> human label map, and use it for the badge.
Flip the two remaining .type debug logs to .kind.

This removes the last user-visible Jellyfin vocabulary on the catalog
surface. primaryImageTag -> imageId rename (naming-only, ~40 sites across
catalog + player/merged types needing a Rust round-trip) intentionally
deferred as the lowest-value slice.

Frontend 644 tests, check clean.
2026-07-23 21:31:51 +02:00
dtourolle 7660a33dfc domain: catalog frontend off Jellyfin ticks -> milliseconds (phase 3a)
The catalog surface now speaks milliseconds, the app's neutral time unit.
Ticks no longer reach library/home components.

Rust:
- UserData gains playback_position_ms (dual-carry), populated from ticks
  at the offline mapping seam via domain::ticks_to_ms.

Frontend:
- formatDuration(duration.ts) and the two local copies now take ms, not
  ticks; all callers pass item.durationMs.
- Progress bars (EpisodeRow, EpisodeFocusView, MediaCard, LibraryListView)
  compute playbackPositionMs / durationMs — unit-consistent, no tick math.
- PlaylistDetailView totalDuration sums durationMs.
- duration.test.ts + TrackList.test.ts fixtures updated to ms.

Deferred: player/session/reporting tick math (Queue, SessionCard,
RemoteControls, playbackReporting, playerEvents) — those cross the
storage/Jellyfin command boundary in ticks and need command-signature
changes (phase 3b). Display {item.type} badge -> kind label (phase 4).

Rust 456, frontend 644, check + check:boundary clean.
2026-07-23 21:30:04 +02:00
dtourolle 3e962a202c test: update playerVisibility fixtures to .kind (phase 2a)
isVideoItem now reads item.kind, so the mini-player visibility fixtures
must set kind (track/movie/liveChannel) instead of the old Jellyfin
type strings. Renames channelItem -> liveChannelItem to match its kind.

All 644 frontend tests pass.
2026-07-23 21:16:42 +02:00
dtourolle 43ddc5a889 domain: flip isVideoItem in player store to .kind (phase 2a completion)
The catalog MediaItem check in stores/player.ts isVideoItem() was missed
in the phase 2a sweep (excluded by a player-path filter). It reads the
catalog MediaItem, so it flips like the rest: type Movie/Episode/TvChannel
-> kind movie/episode/liveChannel.

Verified: no catalog .type === "<JellyfinType>" comparisons remain in the
frontend (only stream.type in VideoPlayer, deferred to phase 4). check clean.
2026-07-23 21:14:45 +02:00
dtourolle 772e9ca6d5 domain: flip catalog frontend off Jellyfin item-type strings (phase 2a)
Migrate catalog MediaItem consumers from stringly item.type ("Audio",
"MusicAlbum", …) to the neutral item.kind enum across all classification
logic: home, library detail, player routing, artist/person/related/genre
components, tv store.

Model refinements found during migration (each a real distinction the
flat item_type collapsed):
- MediaKind::LiveChannel — live TV (playable, non-seekable) vs
- MediaKind::ChannelItem — channel VOD leaf (playable, seekable) vs
- MediaKind::Channel — channel container (drill-in).
  TvChannel->LiveChannel, non-folder ChannelFolderItem->ChannelItem.

RelatedItemsSection and GenreTags props migrated from Jellyfin type
strings to MediaKind; MediaKind re-exported from api/types.

Deferred by design: display {item.type} text, ResultsCounter labels,
Person.type (role), stream.type (phase 4), and all runTimeTicks/tick math
(coupled to playbackPositionTicks — phase 3). Old fields still dual-carried
so nothing breaks.

Rust 456 + 7 domain tests, frontend 644 tests, check clean.
2026-07-23 21:12:20 +02:00
dtourolle 55fa26377a domain: introduce provider-neutral media model (phase 1)
Establish src-tauri/src/domain/ as the single source of truth for the
media model, with all Jellyfin translation isolated in from_jellyfin.rs.
Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem
as additive, defaulted dual-carry alongside the legacy Jellyfin-named
fields, so nothing breaks while the frontend migrates off them.

- domain/media.rs: canonical MediaKind (closed enum, replaces stringly
  item_type), Default = Other so unknown/defaulted items are inert.
- domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind
  classification (all audited types + person subroles) and ticks->ms.
- MediaItem gains kind/duration_ms/image_id, populated at both mapping
  seams (online to_media_item, offline cached_item_to_media_item) and
  the synthesized-album/person sites.
- Regenerated bindings.ts: frontend now HAS the neutral model available.

Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour
change yet; wire shape is a superset of before.

Rust 456 tests, frontend 644 tests, check + check:boundary all green.
2026-07-23 20:53:47 +02:00
dtourolle f89b241ad6 docs: document frontend/backend boundary rule and spec workflow
Add the "domain vocabulary lives in Rust" principle, the
check:boundary pre-commit gate, and a spec-writing section pointing at
the spec template and review checklist.
2026-07-23 20:04:54 +02:00
dtourolle 8b028b6b60 docs: specs, requirements, ux-flows and traceability for new features
Add specs for the account menu, downloads-as-offline-library, offline
downloaded-only filter, and scoped search (+ boundary revision). Add the
new UR/DR entries to requirements.md, update ux-flows, and regenerate the
traceability matrix.

TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
2026-07-23 20:04:35 +02:00
dtourolle dd9d4191f1 chore(ci): add frontend boundary tripwire script
Grep-based tripwire flagging multi-type includeItemTypes literals in the
Svelte frontend — the machine-detectable signature of the item-type
taxonomy leaking into presentation. Wire it into the Gitea build-and-test
workflow and a check:boundary package script. Motivated by
docs/specs/scoped-search-boundary.md.
2026-07-23 20:04:35 +02:00
dtourolle bacb9ca0bb docs(traces): tag episode focus view and library detail with TRACES
Add TRACES comments linking the episode focus view and library detail
page to their existing requirements.

TRACES: UR-035, UR-038, UR-048 | DR-043, DR-061, DR-062
2026-07-23 20:03:16 +02:00
dtourolle cf9472f04f feat(chrome): shared account menu and global app header
Move account actions (Settings, Downloads, Display preferences, Sign
out) out of the library-only header into a shared AccountMenu anchored in
a global AppHeader, available on every authenticated non-immersive
screen. Add a layoutShell helper deciding where chrome shows, expose
serverName/serverUrl auth stores, and a display view-mode preference. The
settings page also gains the UR-053 WiFi-only toggle.

TRACES: UR-054 | DR-075, DR-076, DR-077
2026-07-23 20:03:12 +02:00
dtourolle f25deba824 feat(downloads): browsable downloaded library with on-disk usage
Replace the flat download list with a Downloaded browse surface that
reuses the online grids/cards/detail pages, filtered to on-device media,
plus a demoted Transfers tab. Add repository browse commands
(getDownloadedLibraries/Items, disk usage) with offline/hybrid
implementations, a downloadedCatalog service, formatBytes helper, and
per-item/device disk-usage labels on cards and grids. Regenerated
bindings.

Also carries the inseparable UR-052 offline-filter hunks in
offline.rs/hybrid.rs.

TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
2026-07-23 20:02:55 +02:00
dtourolle 8f4f651bac fix(offline): gate library listing to downloaded-only when offline (#10)
The connectivity store now drives the "downloaded only" view so an
offline library page shows just on-device media, with the server catalog
revealed only when "Show all server media" is toggled.

TRACES: UR-052 | DR-078, DR-079
2026-07-23 20:02:33 +02:00
dtourolle c175378f38 feat(search): context-scoped search with filter chips and group order
Add a search scope (all/music/shows/movies) resolved from the entry
route and adjustable via filter chips, threaded through the library
store's search() into includeItemTypes. Results group by type in a
user-configurable order, editable from settings.

TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
2026-07-23 20:02:15 +02:00
dtourolle e083b53ee8 feat(downloads): WiFi-only network-type-aware download gating
Add a metered/cellular network detector so downloads honour a "WiFi
only" preference. Android reports network type via NetworkTypeMonitor;
Rust exposes it through download/network.rs and holds the queue pump when
on a metered connection, emitting a queue-wide waitingForNetwork event.
The frontend surfaces this via the networkType service and a
waitingForNetwork store flag.

TRACES: UR-053 | DR-074
2026-07-23 20:02:07 +02:00
dtourolle 8f8433eebe ci: remove superseded traceability workflow
traceability.yml duplicated the coverage check now owned by
traceability-check.yml, running the same extraction on every push and PR
to master/main/develop. Dead CI: nothing references it, and keeping both
doubled runner time for one result.
2026-07-23 19:17:40 +02:00