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
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
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
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
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
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
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
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
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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
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
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
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
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
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.
The gitea-pages push step used ${GITHUB_SHA::8}, a bash-only substring
expansion. The Gitea runner executes run: blocks with /bin/sh (dash),
which rejects it with "Bad substitution" and exits 2, failing the job
after the site had already built successfully.
Use cut(1) to shorten the SHA instead, which is POSIX sh compatible.
bun is already baked into the jellytau-builder image (Dockerfile.builder),
so oven-sh/setup-bun@v1 was redundant. Fetching that GitHub-hosted action
from the self-hosted Gitea runner hangs the job before any steps run.
Removed from traceability-check, traceability, and publish-docs workflows;
build-and-test and build-release never used it and never stalled.
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
Needed to deploy over the CI-installed build on device: CI derives
versionCode as 1000 + major*10000 + minor*100 + patch, so the field is
already at 1000, while a local `tauri android build` writes the raw
patch number (15) and is rejected as a downgrade.
Cargo.toml is versioned independently (0.1.0) and is left alone.
Note: local builds still emit the raw code (16) - only CI applies the
1000+ formula, so deploying to a device with a CI build installed needs
gen/android/app/tauri.properties patched after Tauri regenerates it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add PiP for native (ExoPlayer) video on Android. Video renders into a
SurfaceView behind the WebView, so PiP is driven by the Activity shrinking
into a floating window rather than the HTML5 PiP API (which WebKitGTK does
not implement, hence Android-only).
- PictureInPictureManager.kt: enter PiP with the video's aspect ratio
(clamped to the 1:2.39-2.39:1 range Android accepts, outside which it
throws), plus a play/pause RemoteAction. Hides the WebView while in PiP -
it is opaque and sits above the surface, so it would otherwise occlude the
video entirely - and re-fits the surface on exit.
- MainActivity.kt: onUserLeaveHint auto-PiP, onPictureInPictureModeChanged,
and an AndroidPictureInPicture JS interface following the existing
AndroidAudioFocus pattern.
- pictureInPicture.ts + VideoPlayer.svelte: PiP button, rendered only when
the native bridge reports support.
- proguard: keep rules for @JavascriptInterface methods, which are only
referenced from JS and would be stripped in minified release builds.
Casting needs no special handling: canEnterPip() checks natively that a
local video surface is attached and playing, which a remote session lacks.
While wiring the manifest, found that three tracked files under
src-tauri/android/ were never reaching any build. Gradle reads only
gen/android/app/src/main/, and sync-android-sources.sh did not copy them:
- src/main/AndroidManifest.xml was a partial <application> fragment written
as if Tauri merged it. It does not - there is no manifest-merger hook
here, so its hardwareAccelerated flag never reached an APK. Promoted to
the complete authoritative manifest (folding in that flag) and synced.
- src/main/res/values/themes.xml (transparent status bar, fitsSystemWindows)
was never copied; the sync only globbed mipmap-*. Now synced.
- build.gradle.kts was a leftover com.android.library module config with
stale media3 1.5.1 deps. The live deps are in app/build.gradle.kts at
1.5.0. Deleted.
Verified: merged manifest now carries hardwareAccelerated,
supportsPictureInPicture, resizeableActivity and the density configChange;
themes.xml compiles into merged resources; Kotlin builds warning-free;
svelte-check clean; 537 frontend tests pass.
Not verified: PiP behaviour on a device, and the release keep rules against
a minified build. assembleUniversalDebug cannot complete in this
environment - the Rust step wants a dev-server addr file that only exists
under `tauri android dev`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The runner executes workflow steps with /bin/sh (dash), which has no
here-strings: `IFS='.' read -r MAJ MIN PAT <<< "$VERSION"` failed with
"Syntax error: redirection unexpected" and aborted the Android release build.
Parse the semver with `cut` instead, drop the GNU-only `\s` from the sed
expression in favour of [[:space:]], and default any missing component to 0 so a
malformed version can never emit versionCode 0. Verified under sh:
0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000 (monotonic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigation:
- Split conflated "back" into navigateUp (deterministic route parent) and a
history-safe navigateBack that tracks in-app depth via afterNavigate instead
of history.length. Fixes the resume-from-background trap where a stale WebView
stack left the header arrow stuck on the current page.
- /library self-corrects for music/tv/movies (which have dedicated landing
pages): a leftover currentLibrary no longer forces the inline content-list
view, so "up"/back shows the libraries overview. Live TV / channels / other
types still render inline.
Startup (unblock first paint):
- auth.initialize() no longer awaits security-status, player-config, or session
verification before flipping isInitialized. These run fire-and-forget after the
session is restored, so the library overview paints without waiting on several
serial IPC round-trips.
Versioning / CI:
- tauri.conf.json + package.json aligned to 0.0.15 (the tag series had drifted to
0.1.0, whose formula-derived versionCode 1000 outran the v0.0.x tags).
- Release workflow now pins a monotonic Android versionCode
(1000 + major*10000 + minor*100 + patch) so tagged builds never downgrade
below prior installs and always increase in semver order.
Tests: navigation (4), auth (29), playbackMode (23) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-and-test.yml built a full APK on every master push without running
sync-android-sources.sh, so it used the wrong (Tauri-default) sources, was
unsigned, and duplicated the ~15min build that build-release.yml does properly
on tags. Replace it with cargo check --target aarch64-linux-android (~1min),
which catches Android Rust breakage without linking, bundling, or signing.
The signed release APK remains a tag-only artifact from build-release.yml.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monochrome adaptive-icon layer produced a poor themed-icon rendering.
Remove the <monochrome> reference from mipmap-anydpi-v26/ic_launcher.xml and
delete the ic_launcher_monochrome.png files so Android always uses the color
adaptive icon (background + foreground). sync-android-sources.sh also drops any
monochrome layer Tauri regenerates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establish a decoupled player boundary so UI and backend interact with video
through one contract, with the HTML5 (Linux/interim-Android) and native
(ExoPlayer) providers as interchangeable primitive-executor adapters.
- PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the
adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource,
play/pause, setVolume, selectSubtitle); it never branches on strategy.
- Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track
return a strategy); the facade dispatches the chosen primitive to the active
adapter. Both providers share the one decision path — logic lives once, in Rust.
- Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets
backend control (lockscreen/remote/sleep) drive the webview <video> element.
- Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause
silently no-opping when the element was re-bound).
- Do not emit a "stopped" player state on natural end-of-video: it flipped the
player/mode to idle mid-handoff and suppressed next-episode auto-advance under
a sleep timer. Jellyfin progress reporting is preserved; the backend's
on_video_playback_ended owns the transition.
- VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter).
- Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.
- Make MediaSessionCompat the single source of truth; route all transport
commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
session poller (works while the screen is locked, unlike WebView timers) via
a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
prev/seek to the remote Jellyfin session; Stop while casting emits
RemoteDisconnectRequested, which the frontend handles by transferring to local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- music landing: diverse per-genre album sliders (online counts /
offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
The release-notes echo lines used unescaped backticks, which the shell ran
as command substitution; their output leaked control characters into
release_notes.md, so jq failed with 'Invalid string: control characters ...
must be escaped' when building the release payload.
- Escape the backticks so they are literal markdown.
- Remove emoji from the release-notes content (plain ASCII headings).
- Handle an already-existing release (HTTP 409) by reusing its id for
asset upload instead of failing.
The offline/online switch was janky because two independent systems decided
"online" and never communicated:
- ConnectivityMonitor owned is_server_reachable (drove the UI banner) but
learned reachability only from a standalone /System/Info/Public ping loop
and from auth/login calls.
- HybridRepository served all real data by racing cache-vs-server but never
read or wrote reachability.
So the banner reflected a side-channel poller, not the system the user actually
experienced: a successful ping could read "online" while authenticated data
calls 401'd or timed out, and three different timeout regimes (5s ping / 30s
data / 100ms cache race) flapped against each other.
Unify into a single source of truth:
- Extract a cheap, cloneable ConnectivityReporter that owns all reachability
transitions and event emission.
- OnlineRepository reports the outcome of every server request to the reporter,
classified via RepoError: Ok/Authentication/NotFound/Server => reachable
(the server answered), Network => offline candidate, Database/Offline =>
ignored (not a server signal).
- Time-window debounce (OFFLINE_CONFIRM_WINDOW = 5s): flip offline only after
sustained network failure; recover instantly on the first success.
- Demote the ping loop to an offline-only recovery probe (no online polling;
real traffic is the signal when online).
- Frontend: navigator.onLine is now advisory (triggers a recheck instead of
forcing offline); removed the dead markReachable/markUnreachable store methods.
Docs updated (README, 07-connectivity, 03-data-flow, 02-svelte-frontend) to
describe the new model and fix pre-existing drift (HTTP client is 30s timeout +
5s ping, not the documented 10s/base_url).
Tests: 12 connectivity tests (debounce, instant recovery, RepoError
classification through report_outcome). Full suite: 398 Rust + 384 frontend
passing, svelte-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JellyTauPlayer.kt references com.dtourolle.jellytau.VideoOverlayManager,
but the file existed only in the gitignored gen/android dir, so it
survived locally but vanished in CI (which regenerates gen/android via
'tauri android init'). sync-android-sources.sh copies top-level *.kt
from src-tauri/android, so adding it there gets it synced into the build.
Fixes: 'Unresolved reference: VideoOverlayManager' in
:app:compileUniversalReleaseKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tauri CLI requires '--apk true'; bare '--apk' fails with
"a value is required for '--apk <APK>'". The release workflow
only reached this step now that checkout/container issues are fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Standardize on bun: remove package-lock.json, add packageManager field,
gitignore non-bun lockfiles, fix stray npm install in android:build:clean
- Remove stale build logs and empty dirs (src-tauri/plugins, docs/tickets)
- Move android-dev.sh into scripts/
- Consolidate root docs into docs/ (docker/builder under docs/build/);
move the architecture overview to docs/architecture/README.md
- Extract Requirements Specification from README into docs/requirements.md
and slim README down to a project intro + docs index
- Fix internal references to the moved files
Remove unused imports and a dead test helper, and strengthen tests that
held unused bindings/fields so the values are actually exercised:
- drop unused imports (HttpConfig, MediaType, std::io::Write)
- remove unused TestEventEmitter::clear helper
- replace meaningless size_of asserts with real empty-manager checks
- assert on MockTrack.name in sorting tests
src-tauri/.cargo/config.toml hardcoded absolute NDK paths under
/home/dtourolle, which only exist on the dev laptop. In CI this made
ring's build script fail to find aarch64-linux-android34-clang. Tauri
derives the linker/CC paths from NDK_HOME automatically during
'android build', so the file is unnecessary there. Untrack it and
gitignore it; local dev copies are preserved on disk.
Set ANDROID_HOME/NDK_HOME/NDK_VERSION at the job level and add a step
that installs the NDK via sdkmanager when missing, so the Android init
step no longer fails when the builder image lacks NDK_HOME.
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.
- Register repository_get_subtitle_url and repository_get_video_download_url
in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
- VideoDownloadButton passed an un-awaited Promise as the stream URL.
- setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
enforce what they checked.
svelte-check: 0 errors. vitest: green. cargo test --lib: green.