Commit Graph
13 Commits
Author SHA1 Message Date
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
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.
2026-08-09 16:38:07 +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 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
2026-08-03 20:37:43 +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 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 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 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
dtourolleandClaude Opus 4.8 342f95cac1 Wire up playback reporting, fix duration flash, hide video from audio mini player
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m14s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m3s
Playback reporting (position sync / resume-on-another-device):
- player_configure_jellyfin now builds a PlaybackReporter sharing the player
  controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every
  auth path (login/restore/reauth); previously they never did.
- The PlaybackReporterWrapper now shares the same Arc the controller and MPV
  progress loop report through, instead of a dead parallel Option.
- Android position callbacks now emit throttled progress reports (30s/item),
  mirroring the MPV backend.

Duration flash on pause:
- resolveDuration() prefers the live store duration for the already-loaded
  track over the runTimeTicks estimate, so pausing no longer clobbers the
  slider's max to 0 when runTimeTicks is missing.

Video leaking into audio mini player:
- isVideoItem() also checks the backend PlayerMediaItem mediaType
  discriminator, so a video started via player_play_item (no Jellyfin `type`,
  mediaType "video") no longer surfaces in the audio mini player.

Middle-truncation of long media names:
- New truncateMiddle util applied to track/episode/card/mini-player titles so
  distinguishing tails (episode numbers, suffixes) stay visible.

Adds regression tests for the duration and mini-player fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:52:27 +02:00
dtourolle 14e9d7e03a E frontend reconciliation (2/2): align consumers to generated bindings; svelte-check clean
Traceability Validation / Check Requirement Traces (push) Failing after 21s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m21s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m14s
Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):

- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
  (CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
  send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
  NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
  deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
  sessions.test.ts NowPlayingItem mock.

Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
2026-06-20 20:49:20 +02:00
dtourolle e8e37649fa Many improvemtns and fixes related to decoupling of svelte and rust on android.
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s
2026-02-28 19:50:47 +01:00
dtourolle e3797f32ca many changes
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
2026-02-14 00:09:47 +01:00
dtourolle cfddc1edea First working POC 2026-01-26 22:21:54 +01:00