Commit Graph
907 Commits
Author SHA1 Message Date
edde746 a910aa95cb refactor(tvos): publish menu passthrough directly, trim untuned remote-touch knobs
TvosMenuPolicyPublisher batched an idempotent fire-and-forget publish behind a transaction-depth counter that could never exceed one across its three non-nesting call sites — the call sites now publish directly and the pure predicate keeps its test coverage. AppleTvRemoteTouchService exposed 14 injectable knobs of which only six are exercised anywhere (verified per knob); the other eight are private constants or internal construction now.
2026-08-17 19:02:20 +02:00
edde746 4436efea72 refactor(models): drop parse-only wire fields nothing reads
Four model surfaces parsed, coerced, and re-serialized data with zero readers: PlayQueueResponse's selectedItemIndex/offset/sourceURI/version (version stays as a parse-time validity gate), PlexUserProfile's seven non-track-selection account fields, MediaSubscriptionCreateRequest's hints/params/providers, and the whole CatalogPlayState pipeline (parsed by the Plex catalog source, merged and round-tripped on every CatalogItem, read by nobody).
2026-08-17 19:02:19 +02:00
edde746 8e9c81fe47 refactor(media): plain LocalPlayQueue, trimmed MediaItem factory, one library-content entry point
PlayQueue was a freezed union whose Plex variant was never constructed (server queues flow through PlayQueueResponse), whose pattern getters had zero call sites, and whose backendId was written but never read — it is now a plain four-field LocalPlayQueue. The MediaItem compatibility factory re-listed the entire ~65-field union surface twice while every caller passes at most 22 fields; it now declares exactly that union. fetchLibraryContent had zero production callers (the UI pages through fetchLibraryPagedContent), so the interface method and both implementations are gone, with Jellyfin's drain folded into its paged entry point.
2026-08-17 19:02:19 +02:00
edde746 093c0596f1 refactor(jellyfin): declare shared client internals once, make live-TV negotiation private
Two part mixins re-declared members owned by siblings (five playback methods with full 17-parameter signatures, _safeFetchItemsArray with four lint suppressions), violating jellyfin_client.dart's own 'declared exactly once' contract — they now live on _JellyfinClientInternals, with _safeFetchItemsArray declared as its positional core so no suppressions survive. LiveTvSupport.resolveStreamUrl's only caller anywhere was Jellyfin's own startPlayback, so it is now a private negotiation step and Plex drops its permanent null stub; the negotiation tests observe the same contract through startPlayback and the heartbeat wire. JellyfinApiCache's unpinForOffline/isPinnedItemId (zero callers, kept only for Plex symmetry) are gone, as are MediaSubscriptionCreateRequest's never-populated hints/params/providers expansions in the Plex live-TV serializer.
2026-08-17 19:02:19 +02:00
edde746 8033a308ce refactor(connection): drop dead status/kind enums, share the Plex account build pipeline
Connection.status was write-only health state (only the Jellyfin refresh wrote it, nothing read it — MultiServerManager owns real server status), ConnectionKind duplicated MediaBackend line for line, the token-to-PlexAccountConnection pipeline existed three times (sign-in, dev-token seed, legacy migration) with drifting label/dedup policy, and JellyfinConnectionAuthService.validate/refresh/signOut had no production callers.

Connection.kind is now MediaBackend (persisted 'plex'/'jellyfin'/'emby' strings are identical, no migration); buildPlexAccountConnection in plex_account_setup.dart owns identity resolution with the three callers keeping only their genuine deltas; the test-only auth trio and its jellyfinSignOut timeout constant are gone.
2026-08-17 19:02:18 +02:00
edde746 68c328b469 feat(search): label results with their source server and library
One server with overlapping libraries (movies in both an HD and a 4K
library, shows duplicated per language) returns search results that
cannot be told apart, forcing users to open each copy to find the
right one (#1970).

Each search row now carries a source line under the summary: backend
icon, server name, and library name, shown whenever the owning server
has more than one library. Plex rows name their section inline, with
sectionKey-only rows back-filled from the loaded libraries; Jellyfin
and Emby send no library field on search hits, so search always runs
the per-library scoped fan-out that previously only served
hidden-library exclusion and stamps every hit with its library. The
Jellyfin detail lookup adds a best-effort /Items/{id}/Ancestors stamp
so full metadata stays attributed, row refreshes merge instead of
dropping the stamp, and the mapper no longer misreads SeriesStudio or
ParentId as library identity.

Episode rows trade one summary line for the source line so the
wide-thumb row keeps its height, and the badge paints slightly below
the line-box center to sit on the text's optical middle.

close #1970
2026-08-17 17:13:26 +02:00
edde746 01f04eda42 fix(ui): show loading feedback while play actions fetch
Several Play entry points gave no feedback while their network round
trips ran: a show or season Play button silently awaited a seasons
fetch (with a 10 s completer window) and a first-episode fetch, and
Plex collection/playlist launches showed no spinner at all. Where a
spinner did exist, the first request was gated on the dialog's first
frame instead of running concurrently with it.

Extract executeWithLoading's dialog plumbing into
ScopedLoadingDialogController (mount-aware dismissal, idempotent,
never pops a foreign route) and reuse it for the show/season Play
path. Start the launcher operation before awaiting the dialog frame so
the first round trip overlaps the render, and show the loading
indicator for Plex list launches too.
2026-08-17 13:47:00 +02:00
edde746 be9197eda6 fix(playback): serve playback metadata from fresh cache instead of refetching
Videos take noticeably long to start. On Plex, tapping Play refetched
/library/metadata/{id} network-first even though the detail screen wrote
a strict superset of that exact payload under the same cache key seconds
earlier. On Jellyfin/Emby, playback start issued a full-detail item GET
before the PlaybackInfo negotiation, and the video controls issued the
same heavy GET again while the stream was opening (#1784) — the most
expensive queries a small home server serves, paid two to three times
per start.

Add ApiCache.getIfFresh over the existing cachedAt column and serve
rows younger than playbackMetadataCacheFreshness (5 min) without a
round trip. Plex guards with the strict stream-detail check
(_plexMetadataHasStreamDetail) so a thin row written by
getPlaybackExtras' lean fetch still goes to the network; Jellyfin's
row has a single full-shape writer, so fetchPlaybackBundle and
fetchPlaybackExtras share the new fetchItemFreshCacheFirst. Any miss,
stale, thin, or malformed row falls through to the unchanged
network-first path, and offline behavior is untouched.
2026-08-17 13:46:33 +02:00
edde746 58b510a6c4 fix(music): handle hardware media keys while the app is foreground on Android
Media buttons on HID remotes (USB/Bluetooth keyboards, common on Android
TV) are delivered as key events to the focused window instead of the
MediaSession, so they only worked while the app was backgrounded. A
global handler now routes play/pause, next/previous, stop, and
fast-forward/rewind to the live music session anywhere in the app and
consumes the key burst, so a press can neither leak to Android's
fallback MediaSession dispatch nor start a focused library item. Same
lifecycle as the OS media session; video playback never coexists with it
because claiming video disposes the music session first.

close #1948
2026-08-16 16:47:06 +02:00
edde746 25b2939f0a fix(player): stop same-day Specials from hijacking up next
close #1952

Shows whose Season 0 holds aftershow featurettes (e.g. House of the
Dragon "Inside the Episode") share air dates with the episodes they
accompany, so the air-date interleave from #1416 queued them between
regular episodes: playing S03E04 offered S00E76/S00E84 as up next.

Air date alone cannot separate canon Specials from featurettes, so
Specials placement is now a three-way preference, defaulting to
"follow server order":

- respectServer: Plex keeps its server-built /allLeaves queue (aired
  order, Specials interleaved, as before); the Jellyfin queue now
  preserves the /Shows/{id}/Episodes response order, which is
  Jellyfin's native watch order — Specials placed only via explicit
  AirsBefore* metadata per the server-wide DisplaySpecialsWithinSeasons
  setting (the endpoint ignores SortBy except Random). Client-side
  selections with no server order to respect (offline next/prev,
  download "next N", offline OnDeck) fall back to Specials-last.
- airDate: the #1416 aired interleave on every surface.
- specialsLast: Specials strictly after the regular seasons (#1414);
  Plex builds its show queue from /children.
2026-08-16 15:31:47 +02:00
edde746 d1d393eec0 fix(plex): stop timeline heartbeats once the server terminates the session
Pausing past the server's paused-session limit (or an admin stop) removed
the session on PMS, but Plezy kept sending paused timeline heartbeats,
re-registering it as a zombie session the server could no longer clear.

Plex signals the termination with terminationCode/terminationText on the
MediaContainer of the next timeline reply. Detect it, close the reporting
session with one final stopped report at the current playhead (which
removes the session row), and suppress paused heartbeats plus the
transcode keepalive until playback actually resumes, which opens a fresh
server session.

close #1916
2026-08-16 08:58:57 +02:00
edde746 be863a0c1c fix(linux): remove the Flutter-texture fallback permanently
The display-agnostic texture renderer restored by 9cdfe759 is deleted
again, this time for good: it is a second, SDR-only rendering stack
(isolated EGL context on Flutter's display plus EGL-image handoff) kept
alive solely to host sessions that cannot bring up the Wayland plane -
X11/XWayland or a failed plane bootstrap - and it was the source of the
native lifecycle and EGL state-churn fixes of the 9f2e0507 era. Linux
video now requires a Wayland compositor; a session that cannot host the
plane fails initialization with VIDEO_PLANE_UNSUPPORTED instead of
silently rendering through the second stack.

Reverts the restore commit's machinery: mpv_texture.cc/.h and
mpv_gpu_bootstrap.cc/.h deleted, the plugin's texture-registrar,
bootstrap, and waitForVideoReady paths removed, MpvPlayer's texture-mode
render-context API dropped, and the Dart-side renderMode setting, its
settings tile, translation keys, and the Player textureId member
withdrawn. The #1874 HDR diagnostic work is unaffected.
2026-08-15 01:20:44 +02:00
edde746 7937a7e30a style(dart): apply dart format to the Linux rendering changes 2026-08-14 19:30:32 +02:00
edde746 9cdfe7591e feat(linux): restore the Flutter-texture path as the SDR fallback
2.13.0 deleted the display-agnostic EGL/Flutter-texture renderer and made
the native Wayland plane the only path, hard-rejecting every session that
cannot host one - X11, XWayland (SteamOS Gaming Mode runs native apps
through Gamescope's XWayland), or a plane whose EGL bootstrap failed. This
restores the 2.11.0 texture path from git history as the fallback:

- Re-add mpv_texture.cc/.h and mpv_gpu_bootstrap.cc/.h (2.11.0 verbatim):
  an FlTextureGL whose populate renders mpv into an offscreen FBO sampled
  by Flutter via an EGL image.
- MpvPlayer gains the texture-mode render-context API (InitRenderContext,
  HasRenderContext, GetEglDisplay/Context, Render(w,h,fbo)) beside the
  plane's InitRenderContextForSurface/RenderToSurface. The isolated ES 2.0
  context on Flutter's display and the X11 display param are exactly the
  2.11.0 configuration hardware decode demonstrably worked in.
- initialize now tries the plane first and falls back to the texture path
  when it cannot be brought up, returning the texture id (Dart's 2.11.0
  'result is int' contract) with waitForVideoReady gating playback until
  the GPU bootstrap settles. Both paths share one mpv core, so the
  plane/texture decision precedes render-context creation.
- hdr-enabled/hdr-tone-mapping are intercepted in texture mode (no plane,
  no HDR); the HDR toggle hides itself via isHDRSupported.
- New Linux setting 'Video rendering mode' (Automatic / Texture) forces
  the fallback - the user-visible workaround for plane-only trouble and
  for the hwdec interop regression, plus translations in all locales.

SDR only on the fallback, matching 2.11.0; the plane path is unchanged.
2026-08-14 18:16:32 +02:00
edde746 8f9ffc76eb fix(emby): show scrub previews from the /Videos/{id}/index.bif transport
Scrubbing an Emby video showed no preview thumbnails on the timeline
while Plex and Jellyfin both worked: Emby's scrubThumbnails capability
was off, so the player never attempted a load. It was off because the
4.9.5 test server answered the preview endpoints with empty payloads —
its extraction task had not run.

Emby's preview transport is a Roku-format BIF at
/Videos/{id}/index.bif?Width=320, the same wire format Plex serves, so
the existing BIF parser handles it unchanged. Enable the capability and
route Emby previews through the shared BifThumbnailService, whose load
now takes a bytes callback instead of a Plex-typed client. MediaBrowser
sources also carry videoAspectRatio from the video stream so the
tooltip sizes itself to the stream. A server without extracted frames
still answers a header-only BIF, which parses to zero frames and keeps
the tooltip suppressed.

close #1930
2026-08-14 13:36:29 +02:00
edde746 cd4432f27c fix(auth): keep polling Plex PIN after transient network errors 2026-08-14 12:20:20 +02:00
edde746 5f6d9dc610 fix(navigation): keep the sidebar Libraries section collapsed across restarts
Collapsing Libraries in the sidebar only lasted until the app closed. The
next launch always came back expanded, which buries the rest of the nav
menu for anyone running a lot of libraries.

The expansion flag was plain widget state on SideNavigationRailState. It
survived tab navigation only because MainScreen pins the rail with a
GlobalKey, and died on relaunch, profile switch or a layout change. It now
lives in the librariesSectionExpanded preference, so the rail reads and
writes the persisted value and follows a settings reset or import too.

close #1896
2026-08-13 21:44:25 +02:00
edde746 922789c014 fix(tv): stop the server session when a backgrounded TV releases the player
On Fire OS, standby never freezes a backgrounded app, so the paused
heartbeats that kept a suspended session "alive and resumable" pinned it
in the server dashboard indefinitely. The live session bought nothing:
the restore path already performs a fresh playback decision.

When the TV background grace expires, stop the heartbeat timer (its
paused tick also keeps a Plex transcode session alive) and report the
session stopped before releasing the native pipeline, since stop()
resets the player state the report reads. Standby entry can drop Wi-Fi
into power-save and mutations never fail over, so a failed terminal
report is redelivered on a bounded schedule, gated on actual backend
delivery and on the suspend still standing so a restored session is
never reported stopped.

close #1911
2026-08-13 20:34:12 +02:00
edde746 6663353895 fix(player): retry episode advances that fail on a transient server blip
An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.

Three changes:

- A failed in-place reload now records its classified failure reason, and
  an advance that ran with the completion latch set re-presents the Play
  Next prompt when that reason is serverUnavailable. With auto-play
  enabled the countdown re-fires the advance up to two times before the
  prompt goes manual-only; Watch Together sessions and mid-episode Next
  presses (whose rolled-back stream is still valid) keep the existing
  handling. playNextRetryPresentation owns the decision and is unit-tested.

- Committing adjacency now best-effort prefetches the next episode's full
  metadata row through fetchItem, which writes the exact row playback
  initialization falls back to on both backends (Plex: same cache key and
  full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
  playback bundle reads). A warm row turns a blip at the transition into a
  normal start.

- JellyfinClient.fetchItem's documented "pure transport error -> cached
  row" fallback was dead code: the HTTP layer wraps transport errors into
  MediaServerHttpException, which the first catch rethrew unconditionally.
  Status-less, non-cancelled failures now take the fallback; answered
  requests (401/403/5xx) and cancellations surface unchanged.

Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.

close #1867
2026-08-11 09:08:07 +02:00
edde746 3a704a2b9b fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the
HLS start URL, waited for the readiness probe to touch the segment at T, and
then had mpv seek to T anyway. mpv's stream probing always reads segment zero
first, and a Plex segment request is a seek, so the transcoder was dragged
through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured
against PMS 1.43, a segment response that races such a restart can be left
open with headers sent and no data or error, and ffmpeg's HLS segment reads
have no default timeout, so playback buffered forever after the first frame
(issue #1859). Starting the session plain and letting the player's start=T
request the resume segment performs the one unavoidable transcoder seek.

The offset request parameter, the readiness probe, and the probe-only
getStatus HTTP helper are removed; live TV time-shift keeps its own offset
path. Transcode opens now also set an explicit network-timeout with
demuxer-level reconnect options: mpv's stream-layer reconnect settings never
reach ffmpeg's HLS segment fetches, so a silently hung segment response now
times out after 20s and is re-requested on a fresh connection instead of
buffering indefinitely. Verified against a live PMS (resume plays from the
requested position) and a stall harness (hung segment re-requested at 20s
with no content skip).
2026-08-10 23:04:35 +02:00
edde746 d19ec625cd fix(tv): remove the background Watch Next refresh
2.13.0's ShelfRefreshWorker boots a second headless FlutterEngine in
the app process to refresh the launcher row every six hours. Its
foreground guard is checked only once at worker start, so launching the
app during a run leaves two engines sharing a low-RAM TV for up to 90
seconds, and a failed run retries with backoff. Suspected of
destabilizing the compositor on the 32-bit TCL panel in #1862. The tvOS
Top Shelf live fetch is unaffected and stays.

The foreground sync pipeline keeps the row fresh while the app runs, as
before 2.13.0. Updated devices still carry the persisted periodic job,
which would wake the process once more only to fail instantiating the
deleted class; the package-replaced receiver now cancels it.
2026-08-10 22:30:51 +02:00
edde746 c2bd1d28fd fix(subtitles): load external subtitle files with the media whether or not selected
Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.

Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.

close #1860
2026-08-10 21:23:55 +02:00
edde746 ea356a6112 fix(plex): use fMP4 HLS for video transcodes so HEVC presets stop corrupting
Non-Original presets advertised hevc inside the mpegts HLS target; a Plex
Pass server with HEVC encoding enabled obliges, and its HEVC encode -> TS
segmenter path emits parameter sets mpv rejects ("PPS changed between
slices"). The VOD target now requests fragmented MP4 (verified against
PMS 1.22-1.43), retrying once with an H.264-only TS profile when a
server's decision does not echo the mp4 container back, and falling back
to direct play when neither is honoured. Live TV keeps its own TS target:
live sessions copy broadcast hevc/mpeg2video streams, a path the encoder
bug does not touch.

Presets also now send the videoResolution/videoQuality caps their labels
promise; previously only the bitrate limitation went out, so a "1080p
8 Mbps" preset delivered 2160p at a starved 8 Mbps.

close #1859
2026-08-10 20:38:45 +02:00
edde746 69fadc220d chore: clean up code comments 2026-08-10 20:28:41 +02:00
edde746 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00
edde746 a0269c6feb fix(player): keep HDR/Dolby Vision through zoom on iOS and tvOS
Nonzero mpv video-zoom flips vo_avfoundation into a per-frame Core
Image re-render that destroys HDR/DV passthrough - DV frames render
near-black on tvOS (verified on Apple TV 4K, DV P7->8.1 content:
panel luma mean 0.0 zoomed vs 87-103 unzoomed at locked exposure).

Zoom now scales the AVSampleBufferDisplayLayer itself (a
sublayerTransform on the container is ignored by the video plane)
via the existing Player.setVideoZoom seam, and VideoFilterManager
pins the mpv property to 0 on backends with native zoom. The layer
tree at 100% stays identical to before: clipping engages only while
zoomed, and updateFrame sizes the layer via bounds/position, which
frame= decomposes to anyway.

macOS keeps the property path (gpu-next zooms losslessly in-shader);
Android is untouched.
2026-08-10 14:17:40 +02:00
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00
edde746 f5665df43f fix(music): report a gaplessly advanced track's first timeline at its own start
When a gapless advance was announced, the new track's tracker sent its
initial report from live player state, which still carried the finished
track's position and duration - telling Plex the new track was already
at ~100%. PMS recorded a play (and a Last.fm scrobble) at track start on
top of the one from the real playthrough, and the tracker latched the
new track watched locally the moment it began.

The music bind now pins the initial report to the track's own start
(position zero, metadata duration); timer ticks keep reading live state.

close #1849
2026-08-10 08:33:11 +02:00
Tolu AdegbehingbeandGitHub 5c08b29756 fix(plex): keep compatible audio on capped transcodes (#1845)
`maxVideoBitrate` budgets the whole stream, so a capped transcode forced
even profile-compatible audio down to low-rate AAC (measured on Plex
1.43: EAC3 5.1 640k became 360k AAC). The client profile already carries
the video cap through `add-limitation(video.bitrate)`, so drop the
redundant `maxVideoBitrate` param and send `directStreamAudio=1`: the
video stays capped per preset while audio in the codecs the profile
declares (aac/ac3/eac3/mp3) is copied through untouched. Audio the HLS
target cannot carry still transcodes as before.
2026-08-10 07:05:23 +02:00
edde746 716fe9b51b feat(watchlist): add watchlist toggle to library context menus
Watchlist membership was only reachable from Explore cards and the
detail screen's action row, which drops the bookmark first on narrow
screens with no fallback in the overflow menu. Add an entry to
MediaContextMenu for movies and shows whenever a connected catalog
source can hold the item, covering card long-press everywhere and the
detail screen's overflow.

External-id resolution is session-cached per item on
CatalogSourcesProvider and shared with the detail screen. A cold cache
labels the entry "Add to Watchlist" and always adds (idempotent), so a
press can never turn into a surprise removal; "Remove" is offered once
cached membership proves it. Several capable sources open the same
per-source chooser the detail screen uses.

close #1822
2026-08-09 17:14:48 +02:00
edde746 9d13584c00 feat(settings): add appearance toggle to hide the Explore tab
The Explore tab appears for every Plex-backed profile because the Plex
Discover catalog source connects implicitly, with no way to opt out
short of a Jellyfin-only profile. Add a Show Explore Tab switch to
Appearance > Navigation that hides the tab in the bottom navigation and
side rail. UI-only: catalog sources stay connected so watchlist
surfaces keep working while the tab is hidden.

close #1844
2026-08-09 17:14:47 +02:00
edde746 8e5279a487 feat(subtitles): optionally anchor text subtitles to the screen bottom
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android +
ExoPlayer only, default off). When enabled, the text SubtitleView is
sized to the full container instead of the letterboxed video rect, so
SRT/VTT/mov_text cues render in the black bars below widescreen video
and font size and the position setting become relative to the physical
screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged;
mpv already places plaintext subtitles in the margins by default.

close #1730
2026-08-09 17:14:34 +02:00
edde746 e0a364e26a chore(tvos): remove the Atmos output diagnostics 2026-08-09 11:52:48 +02:00
edde746 de76c0a515 feat(tv): refresh the Watch Next row without the app open
The Watch Next row previously only updated while the app was in the
foreground, so it drifted stale until the next launch. A WorkManager
periodic job (6h, network-connected, KEEP) now runs a headless Flutter
engine executing `systemShelfBackgroundMain`, which mirrors the
cold-start profile bind from cached tokens (never prompting for a PIN),
fetches Continue Watching through the existing multi-server aggregation,
and republishes the shelf through the normal Watch Next pipeline.

The job is armed by a committed foreground sync, cancelled when the
shelf is cleared, and re-armed after boot or app update only when
persisted shelf state exists. It skips entirely while a foreground
engine holds the shelf lifecycle lease, both to defer to the live app
and to avoid two engines sharing the database in one process. The Dart
isolate always reports completion over `backgroundSyncComplete`; the
worker hard-caps the run at 90 seconds and destroys the engine on the
main thread.
2026-08-09 10:59:30 +02:00
edde746 291a22a4a4 feat(tvos): fetch Top Shelf content live and show poster art
The Top Shelf extension now fetches Continue Watching directly from
Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its
last foreground Discover pass. The app publishes per-profile server
descriptors on every shelf sync (`updateSources`): token-free metadata
in the app group, tokens in an app-group-shared keychain item, both
wiped by `clear`. On success the extension rewrites the cached payload
as the offline fallback; any fetch failure falls back to the previous
cache-replay behavior. Poster images are passed as remote URLs, so the
extension no longer depends on app-side artwork downloads.

Episodes now render season/series poster art (2:3, `.poster` shape)
instead of 16:9 episode stills, and labels lead with the S/E marker so
long titles no longer hide it behind the focused-item marquee. Shelf
schema v3 (Dart, Android, tvOS envelopes bumped together) discards
stale wide-art caches instead of letterboxing them into poster slots.

close #1474
close #1835
2026-08-09 10:59:16 +02:00
edde746 9d51f5aadd feat(tvos): scale Siri Remote swipe distance to the focused item
A focus step cost a fixed 180pt of pan travel regardless of what was
focused, so small controls felt sluggish and large cards hair-triggered
compared with the native focus engine, which prices a step by on-screen
geometry. Derive per-axis thresholds from the focused control's rect
(gain 1.1, clamped 100-360pt) and normalize axis resolution by them, so
a wide-flat tile steps vertically once the finger covers its height.
Focus scopes, the player's screen-sized catch-all surfaces, and nodes
without layout fall back to the fixed threshold, keeping player chrome
behavior unchanged.
2026-08-09 08:08:59 +02:00
edde746 fe79817e76 fix(tvos): stop a single Siri Remote flick moving focus two steps
Touch travel banked during the swipe repeat cooldown was released as a
second focus step by the first post-cooldown move frame, even when the
finger had stopped or was lifting. Re-anchor the swipe delta on every
frame inside the cooldown so a discrete flick emits exactly one step
while a sustained drag keeps repeating.

close #1756
2026-08-09 07:46:31 +02:00
edde746 f4ce60611b fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.

Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:

Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.

Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.

The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.

Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00
toluLikesToCodeandedde746 8740a19f36 feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision
request never sent offset=, so any non-zero open - resuming a
transcoded title, or switching from Direct Play to a transcoded
quality mid-playback - opened a session whose produced window begins
at the start of the file and seeked it. mpv immediately requests a
segment the transcoder has not produced, PMS answers 404 for it and
every subsequent segment, and playback buffers forever.

Send offset=<seconds> (6dp) with the decision and start request - the
view offset on initial open, the resolved resume position on every
in-place reload - so the session begins producing at the position the
player consumes first. The playlist timeline is unchanged: an offset
session's media playlist still covers the full title from segment
zero, so the player keeps opening with start: at the resume position
and in-stream seeks work as before.

Before a native player opens an offset playlist, waitForTranscodeReady
walks the master playlist, the media playlist, and the segment
containing the offset, because PMS can publish a manifest before that
segment is fetchable and mpv treats the 404 as an HLS error. The probe
is best-effort: it never fails an open, hands off immediately on HTTP
500 (on the response and exception paths alike) so the server-limit
dialog stays prompt, stops on cancellation, skips itself when the
playlist durations never reach the offset, and stays out of the
endpoint-failover cascade. In-place reloads resolve the replacement
source only after the old stop report has gone out, so Plex cannot use
that stop to terminate the replacement transcode.

close #1840
2026-08-09 06:28:59 +02:00
edde746 7437b43207 fix(plex): validate a failover candidate before switching the live endpoint
A transient GET failure on a healthy endpoint could park the client on an
unreachable fallback (e.g. the server host's Docker bridge gateway, which
plex.tv advertises as a local connection) for a full connect timeout, failing
every request in flight during that window (log bbr90).

The cascade now probes each candidate with an unauthenticated /identity
request under the discovery-race budget and only switches when it answers as
the expected server, mirroring the Jellyfin trust gate. Unreachable-looking
private IPv4 candidates stay in the list — a client on the server host can
legitimately reach them, so reachability is probed, not inferred.
2026-08-08 12:18:32 +02:00
edde746 636fd48f40 fix(player): settle the watched patch the backend recorded itself
Watching an episode to the end left it stuck as watched for the rest of the
session. Unmarking it on another device and refreshing did nothing; only a
restart cleared it. Unlike #1829 this needs no second device to cause -- a
normal watch-through is enough, and the second device only makes it visible.

A threshold crossing writes an unacknowledged overlay patch, deliberately:
reporting success proves the backend received the report, not that it
classified the item as played, so the patch stays owed until something
settles it. _settleServerMark has three settled outcomes and only one of them
did. The explicit-mark branch promoted; the two branches that skip the mark
because the backend already recorded the watch itself -- Jellyfin from
/Sessions/Playing/Stopped, Plex from a timeline crossing past
LibraryVideoPlayedThreshold -- returned without promoting. Those are the
common paths, so nearly every completed playback stranded a patch that the
store then refused to suppress, because an unacknowledged entry is never
retired by an authoritative read.

Both now promote, through one idempotent helper that clears the id so the
delivery callback and the settle paths cannot promote twice.

Promotion has to follow delivery rather than the settle decision. A
marks-on-stop backend settles when the crossing latches, which happens before
the stop is sent, and until that stop lands the watch really is still owed --
promoting there would let a refresh retire a patch the server had never
heard about. MediaBrowser also drops a stop for a session it never opened, in
which case the watch it would have recorded never happens at all. So the stop
path promotes only once the report reached a session able to act on it, which
is the same condition that already governs whether the stop persists its
position; that condition is now named rather than recomputed, and reset with
its siblings when a session re-arms. The crossing branch needs no such gate:
it is assembled from two delivered reports, so delivery is already proven.

Verified against a live Jellyfin server driving the real client and tracker:
before, the server reported the item unwatched after a second device cleared
it while the overlay still rendered watched; after, the overlay follows the
server. The optimistic mark still appears immediately during playback -- it
now yields to a later authoritative read instead of outliving one.

The #1287 and #1740 contracts are unchanged: neither branch issues an
explicit mark, and the tests assert that alongside the promotion.
2026-08-08 10:02:04 +02:00
edde746 5f397a99d9 fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing
Refresh left the first device showing the old "minutes left". Restarting the
app showed the right value. Two independent defects produce that, and either
alone reproduces the report.

The first is the watch-state overlay. Every local watch event lands in
WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites
viewOffsetMs unconditionally; isNewerThan only ever orders one patch against
another, never against the server row underneath. Nothing expires a patch and
nothing clears the map except a profile switch, so the Mac's own paused
position kept winning over every subsequent fetch until the process died.

A patch exists to bridge the gap between a local action and the next server
read of that item, so it should stop applying once that read happens. The
store now records the watermark at which a successful authoritative response
returned each key, and suppresses an acknowledged session patch at or below
it. Only a watermark is stored, never the observed state: WatchStateSnapshot
cannot hold a container's leaf counts, and keeping max() per key makes the
order two concurrent responses complete irrelevant. Suppression is a
read-time predicate, so nothing mutates during build.

The barrier covers the parentChain too. patchForItem picks the newest of the
item's own entry and its ancestors', so retiring only the item's entry would
let an older season mark win and render watched/0 -- worse than either the
stale value or the fresh one. An authoritative read of a child already
reflects any container mark that preceded it, so the child's observation
judges its ancestors as well; a newer container action still wins.

Provenance decides what may be suppressed at all. WatchStateEvent now carries
serverAcknowledged, defaulting to false so an unclassified emit site degrades
to today's behaviour rather than silently becoming retireable. An offline
write is owed to the server and a read must never retire it, so it stays
until a WatchPatchPromotionNotifier promotion says the queue replayed it. That
channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts
to watched/unwatched by purging queued progress, so replaying one there would
delete a newer rewatch. Promotion matches an exact WatchPatchId -- session
minted for live crossings, derived from the persisted (profile, row, revision)
for queued ones so it still joins after a restart.

Report acceptance is not delivery: PlaybackReportSession resolves true for a
same-state startup heartbeat it drops, so acknowledgement now keys on
onDelivered. A MediaBrowser Started saves play count and last-played date but
not the position, so it cannot acknowledge an offset. No report-derived
watched crossing is acknowledged on any backend -- Jellyfin hard-codes its
threshold and Plex never loads the server pref that would tell it the real
one -- so only an awaited explicit markWatched settles one.

The second defect is that a failed Refresh reported success. Plex _fetchHubs
and the Jellyfin hub legs both degrade a failure to an empty list, and the
library prefetch discarded its failures, so a server whose every hub request
failed was recorded as succeeded; DiscoverProvider then kept the previous rows,
set loaded and surfaced nothing. Worse, the background Continue Watching
refresh wiped the row outright on zero success.

Hub legs now report what they degraded through a HubFetchDiagnostics sink,
which keeps partial rows alongside the failure and leaves every existing
caller untouched. Failures ride through the aggregation results, a leg that
could not run because discovery failed contributes that failure rather than a
successful no-op, and loaded-server ids became succeeded - failed - cancelled
so one bad leg no longer caches a server as covered and blocks its retry. The
toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad
snackbar on failure while the retained rows stay on screen. Rollback after a
mid-pass exception is version-guarded, refilters against the current hidden
libraries and no longer publishes a system shelf the pass never committed.

Observations are staged with the pass and flushed only once the same disposal,
generation and exception checks that authorise committing those rows have
passed, so a discarded or rolled-back response can never suppress a patch.

Also fixes a live data-loss race the promotion work would have built on:
upsertProgressAction stamped a millisecond timestamp and updated the row in
place, so a rewatch queued during an in-flight replay was deleted by id.
Revisions are now strictly monotonic per row, replay deletes and retry updates
compare against them, and the upsert resets the retry fields because a new
revision is a new logical action.

close #1829
2026-08-08 09:09:48 +02:00
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00
edde746 4816e3928f fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make
the next press rebase off a position the seek has not reached yet. Nothing
retired that pin when something else moved the playhead, so for the ten
seconds it survived, a skip taken after a timeline tap, a chapter jump, an
OS media control or a peer sync resumed from the superseded target and threw
the user back across their own jump.

Publish every playhead movement on the player and retire the pin whenever
the announced destination is not the accumulator's own commit. Overlapping
seeks and backend-chosen relocations arbitrate by which operation the
backend accepted, so a request that was merely asked for cannot speak for
where the playhead ended up.

close #1819
2026-08-07 08:43:48 +02:00
edde746 660e375248 feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s
ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from
one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or
less free, with no way to raise it. On hardware where mpv cannot render at all
that ceiling is the whole buffer budget.

Playback Buffer offers Auto, Large and Extra Large. The durations are taken
from jellyfin-androidtv and jellyfin-android so the same words mean the same
thing across Jellyfin clients; Auto keeps the memory-tiered values that
shipped. Named tiers rather than a duration because a duration would be a
promise the load control cannot keep: prioritizeTimeOverSizeThresholds is
disabled, so targetBufferBytes stops the loader even below minBufferMs and the
byte cap binds first above roughly 23 Mbit/s.

The tier crosses the method channel as a string and resolves in the core,
where an unrecognised name falls back to Auto. LoadControlPolicy clamps the
resulting pair: media3 validates the ordering with Guava Preconditions, an
unconditional throw R8 does not elide, so a bad pair would be an
IllegalArgumentException out of player construction rather than a bad buffer.

The two play-start thresholds stay fixed even though the Jellyfin tiers move
them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from
BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the
stall watchdog indict a player that is obeying its own load control.

That leaves the byte target as a second, often smaller ceiling, and nothing
surfaced either. The resolved values now reach getStats, and the overlay's
Buffer section gains a Cache Limit row reading "120s / 128MB" next to the
buffered-ahead duration, so a tier that appears to do nothing on a
high-bitrate file explains itself.

close #1816
2026-08-07 08:43:47 +02:00
edde746 f63d0fe49e fix(music): shuffle the head of a shuffled queue too
Starting a music playlist, album, or artist on shuffle always opened on
the list's first track: MusicQueueController.load anchored _order[cursor]
and shuffled only the rest, and _startQueue collapsed "no start track"
into startIndex 0, so the anchor was always the head.

Anchoring is right for the two callers that do have a track which must
play first -- the now-playing shuffle toggle, and a load with an explicit
start track -- so make "no explicit start" representable instead of
inferring it from the index: load takes int? startIndex and shuffles the
whole list, head included, when it is null. A start track the list turns
out not to contain now drops the anchor rather than falling back to 0.

Video playback was never affected: Plex shuffles server-side via
/playQueues and Jellyfin already shuffles its full local list.

The queue's Random is injectable so the service-level regression is
deterministic without depending on the SDK's seeded-PRNG sequence.

Close #1811
2026-08-06 06:11:12 +02:00
edde746 094be1fa3e fix(continue-watching): clear the resume position when an item is marked watched
Marking a movie or episode watched left it sitting in Continue Watching with a
checkmark, and the only way to shift it was to play it and skip to the end.

Continue Watching membership on a MediaBrowser server is derived from
UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played
normally zeroes that position as a side effect, so the row usually disappears
and nothing ever checked that it had. When something writes a position back
afterwards the item is left played *and* resumable, which the resume route
happily keeps returning forever. markWatched now reads the UserItemDataDto the
mark already returns and clears the bookmark itself when the server left one
behind, so the postcondition holds however the item got into that state. The
follow-up write costs a request only when the invariant is actually broken.

The writer putting items there is our own offline queue. insertWatchAction
already drops queued progress for an item when the mark is itself queued, but
the online mark writes straight to the server and queues nothing, so a progress
row recorded earlier survived and replayed afterwards — pending actions go out
oldest first — restoring the very position the mark had cleared. The sync
service now listens for watch-state events and discards queued progress for
that item as the mark lands. Progress recorded after a mark is a rewatch and is
queued later, so it is untouched. Plex never showed this because it forwards the
recorded-at timestamp and lets the server discard a stale replay; the
MediaBrowser stop report has nowhere to put one.

Continue Watching also drops the row locally now instead of waiting a round trip
for the refetch to confirm it, matching what removal events already did, and
marking a season or show takes its on-deck episode with it.

Watched items are deliberately still not filtered out of the shelf: Jellyfin
keeps Played set when new progress arrives, so a rewatch in progress is
indistinguishable from a stuck row, and filtering would hide it.

close #1812
2026-08-06 04:21:24 +02:00
edde746 309a107912 feat(downloads): let Android move the app and its downloads to adoptable storage
Declare android:installLocation="auto" so the app becomes eligible for the
Settings "change storage" flow and pm move-package. Adoptable storage relocates
the private data directory with the APK, so downloads follow the app onto a USB
drive adopted by an Android TV.

Moving the app changes the private data directory, which invalidated any download
task already enqueued: those pinned BaseDirectory.root plus an absolute directory
that background_downloader persists verbatim, so a queued or paused download
resumed writing to a volume the app no longer owns. Enqueue app-storage targets
against the base directory the downloader re-resolves from the live app context
instead, and drop the tasks and records a previous location left behind so the
download restarts under the current one.

That sweep runs before the downloader is wired up, because initialization delivers
statuses accumulated while suspended — which can mark the row failed, and a failed
row is deliberately not restarted — and because rescheduleKilledTasks re-enqueues
every killed record it finds, stale absolute directory included.

Compare paths by containment rather than by string prefix while making a stored
path relative. A custom download root that merely starts with the base directory's
name is a sibling the app does not own, and stripping it re-rooted the download
inside app storage.

close #1794
2026-08-06 03:47:43 +02:00
edde746 961e9c0326 feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
2026-08-06 03:45:09 +02:00
edde746 3a56218a12 fix(automotive): keep music playing while a car is parked, and silence it while driving
Music ran under a foreground service whose lifecycle observer was registered for
App TV, so backgrounding the app on a head unit never paused it and driving never
stopped it. Both halves were wrong for a car: parked audio must survive the app
going to the background, and DD-2 requires it to stop when the vehicle starts
moving.

The vehicle now owns exactly the pause it caused. It is claimed when a restriction
arrives and discharged on the event that proves the resume, so a track the user
paused during a drive stays paused when the car parks. A restriction landing while
the next source is still resolving silences the native player as well as the
session, because the previous track is still coming out of it, and a pause that
throws ends the session rather than leaving audio running in a moving car.
2026-08-06 03:45:09 +02:00