Commit Graph
862 Commits
Author SHA1 Message Date
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
edde746 7ce5a443fd feat(automotive): read the vehicle's driver-distraction state
Android Automotive tells an app when the car requires distraction optimization,
and Plezy never asked. A monitor now watches CarUxRestrictions and publishes the
verdict over the existing platform channel, where a single Dart gate answers
whether playback may start.

The car service is reached through the lifecycle-listener overload rather than
Car.createCar(Context). That overload blocks its caller for up to five seconds
polling ServiceManager, and on car-service death it reaches killClient(), which
kills the hosting process for any context that is not an Activity or a Service -
a crash in a system component would take the app down with it. Head units on
Android 9 and 10 predate the listener, so a legacy ServiceConnection is used
there, with the same identity guard on reconnect.

A vehicle that has not answered yet counts as restricted, and one deadline is
spent resolving it rather than one per request, so a wedged car service delays
playback once instead of on every open.
2026-08-06 03:45:09 +02:00
edde746 eaa1736c4e feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a
Device Code app so no client secret or redirect URI ships in the binary
and TV, mobile and desktop all use the same flow.

MDBList omits `verification_uri_complete`, but its device page seeds the
code field from a `user_code` query parameter and the sign-in redirect
preserves the query string, so the activation link is built locally and
the dialog's open button lands on a filled-in form instead of an empty
one. A server-supplied complete URL still wins if one ever appears.

Poll state is read from the response body rather than the status code:
`authorization_pending` and `slow_down` both arrive as HTTP 400, and a
missing grant answers 404 `device_not_found`.

Writes go out as real-time `/scrobble/*` reports plus `/sync/watched`
for the marks that never pass through the player, with ratings on
`/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block
has no `tvdb` field, so a TVDB-only item is skipped rather than written
under an empty id block.
2026-08-05 12:03:05 +02:00
Vincent Vallee 8879941d29 fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on
Windows and Linux whenever HTPC-style player navigation was off, so it dropped
the window out of fullscreen regardless of who put it there. For anyone running
with "start in fullscreen" (or who had toggled fullscreen from the browse UI),
backing out of a movie left the app windowed, with "exit fullscreen on player
close" switched off.

Track fullscreen ownership instead: FullscreenStateManager now exposes a scope
that the player opens in initState and closes in dispose, and setFullscreen —
the single funnel every desktop platform reports through (window_manager on
Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) —
records whether the fullscreen currently active was entered inside that scope.
Escape only exits fullscreen the player itself entered; otherwise it is plain
Back. The scope is depth-counted so the next-episode swap, where the incoming
screen's initState runs before the outgoing screen's dispose, carries ownership
across rather than resetting it.

Nothing changes for a user who fullscreens from inside the player: Escape still
exits fullscreen first, then acts as Back. The fullscreen toggle button and its
shortcut are untouched, as is exitFullscreenOnPlayerClose.

Fixes #1624.
2026-08-05 07:14:11 +02:00
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00
edde746 58d5d3c4ef fix(plex): read external ids from legacy agent and HAMA AniDB guids
Plex only builds the `Guid` array for the Plex Movie / Plex TV Series
agents. A library still on a legacy agent answers with the scalar `guid`
alone, so `fetchExternalIds` returned nothing for it and every consumer
went quiet: trackers logged "no external IDs" and skipped the write,
manual ratings showed "Not available", the detail screen dropped its
watchlist button, and Continue Watching stopped collapsing duplicate
copies. The reverse lookup already read that scalar; only the forward
path ignored it.

Read both shapes from the one request the method already makes, with the
array winning per field and the scalar filling the rest.

HAMA identifies anime by AniDB id and nothing else, which no id set could
carry. AniDB is the Fribb mapping's own primary key, so it now travels on
`ExternalIds` and indexes those rows directly — 7177 of them expose no
tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain
`anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one
TVDB-numbered show, so the guid names the root entry only.

Two guards keep the new id where it means something. It is trusted for
season 1, because that mode puts the anime there and its specials in
season 0, while a higher season means the library is numbered by TVDB
instead. And it resolves nothing for Trakt and Simkl, which never map
anime and cannot address an AniDB id, so they keep reporting no ids
rather than failing silently further down. `hasCatalogIds` marks the
callers that can only speak IMDb/TMDB/TVDB.

close #1788
2026-08-04 16:56:54 +02:00
edde746 4872adcde3 feat(player): keep the session's explicit track choices across episodes
Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.

Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.

Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.

close #1785
2026-08-04 16:10:02 +02:00
edde746 61ae314c94 fix(player): tell same-language subtitle rows apart across episodes
The committed track kept the server display title, which collapses to the
bare language ("English") and is identical for every same-language row: a
carried signs/songs choice tied with the full dialogue track on the next
episode and latched onto whichever row sorted first. The row's own title
is preferred now, so the carried intent names the exact row again and the
native pass can match the right container track by title instead of
language order.

Reproduced against a live library where both English ASS rows differ only
by Title ("Styled Subtitles" vs "Signs/OP/ED").

close #1785
2026-08-04 15:22:46 +02:00
edde746 fdd4c661fe fix(player): carry a picked subtitle language across episodes with sparse tags
The cross-item subtitle intent required declared languages on both sides,
and a null on either side counted as a contradiction. Any untagged track -
common when a title like "Swedish" is the only signal - declined on every
episode advance, fell to the server's per-item priority, and turned the
viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard
gates).

A unique real title match now vouches for a row when language evidence is
missing on either side. Declared languages that disagree still decline no
matter what the title says, forced-class parity is untouched, codec and
external parity only break ties within the title-matched set, and a
residual tie declines rather than guesses, so the wrong-track class of
#1716 stays closed.

A decline is also no longer laundered into a viewer decision: the resolver
keeps the unserved preference on the selection, the open flow hands it to
the track manager instead of a navigation-priority off (late native tracks
may carry the container tags the server rows lack), the next episode
boundary re-carries it instead of hardening it into an explicit off, and
progress reports withhold the -1 subtitle index that would otherwise come
back as the item's server-side default forever.

A pick the screen could not map to a source row (no subtitle catalog, or
an identity-matcher miss) previously never reached the committed session
selection at all, so the next episode carried the stale off while the
picked track was visibly on screen. Such picks now commit the raw native
track without source ids and demote to a semantic intent at the boundary.

close #1785
2026-08-04 13:51:20 +02:00
edde746 439ae1d733 perf(detail): paint a show before its on-deck episode is looked up
Jellyfin has no equivalent of Plex's bundled `?includeOnDeck=1`, so a show
detail open chained `/Shows/NextUp` behind the item fetch and the screen sat
on a spinner for both round trips. The second one is not needed to paint:
everything except the play button's episode label comes from the item.

`fetchItemWithOnDeck` now takes an `onItemReady` callback and invokes it as
soon as the item is known, when that is strictly before on-deck settles.
Plex returns both together and never invokes it.

Phone and desktop only. TV keeps its own reveal gate — `_isTvDetailReadyToReveal`
holds the foreground at opacity 0 until extras, related hubs, seasons and the
first episode page have all loaded, and those still run after the on-deck
lookup settles, so TV sees no change. Both halves are pinned by tests.

Measured on a remote Jellyfin server, 15 interleaved show-detail opens per
version: time to content 1264ms -> 1042ms (-18%), with the rest of the load
unchanged.

Seasons and extras deliberately still start after the whole lookup settles.
Starting them at the early paint measured worse (time to settled +21%)
because they contend with the on-deck request instead of overlapping it — the
same reason `/Shows/NextUp` is not fired in parallel with the item fetch.
That trade-off is also why TV was left alone rather than being unblocked by
moving those loads earlier.

Two ordering hazards the early paint introduces, both covered by
`media_detail_screen_test.dart`:

- The early call must not write on-deck. `_loadFullMetadata` runs again after
  playback, and clearing there would blank the play button for the length of
  the round trip. `onDeckSettled` marks the authoritative write, so a reload
  that finds the series finished still clears it.
- A settled empty on-deck must not drop the episode-derived fallback that
  `_ensureFallbackOnDeckEpisode` supplies.

close #1784
2026-08-04 08:38:19 +02:00
edde746 a759e8b3c6 perf(jellyfin): fetch a detail item once when several callers want it at once
Opening a detail screen issued two identical full-detail GETs for the same
id, concurrently: `_loadFullMetadata` calls `fetchItemWithOnDeck`, and
`_initWatchlistState` calls `fetchExternalIds`, which fetches the same item
purely to read `ProviderIds`. Playback start adds three more for its own id.

Each of those makes the server rebuild the entire dto — `People`, `Chapters`
and `MediaSources` cost a database query apiece and `Trickplay` costs several
plus a filesystem stat — so the duplicate is expensive on both ends.

`fetchItem` now shares an in-flight request per item id. Single-flight only:
once a request settles the next caller re-fetches, so nothing can serve a
stale item.

Measured on a remote Jellyfin server, 12 interleaved show-detail opens per
version: requests 4 -> 3, payload 28.4 KB -> 18.6 KB. Median wall time is
unchanged (1394ms -> 1386ms) because the duplicate ran alongside the first
rather than behind it; this removes duplicated work, not latency.

Two things were tried and rejected because measurement did not support them:
starting `/Shows/NextUp` in parallel with the detail fetch (the requests
contend rather than overlap — NextUp went from 380ms alone to 1395ms beside
it — and it costs a wasted request per movie), and dropping `Trickplay` /
`Chapters` from the detail field set (no measurable effect; both are real
data the playback path reads).

Refs #1784
2026-08-04 06:39:23 +02:00
edde746 74d3af3ae1 perf(home): load the home screen once instead of twice per cold start
The Discover tab fanned out its whole request set twice on every cold
start and replayed slow rows on a shrinking timeout ladder, so a healthy
remote server produced anywhere from 4s to 15s of loading.

Measured against a remote Jellyfin server with four libraries, 24
interleaved cold-start samples per side:

  requests  19 -> 9      payload  219 KB -> 94 KB
  settled   5231ms -> 2502ms median, 13222ms -> 5927ms p95

Four independent causes:

- Retry policy. `Client.send` resolves on response headers, so the
  connect budget covers the server's think time and a slow-but-alive
  query raises `connectionTimeout`. Replaying it made the server re-run
  the query with a shorter budget than the one it just missed; the
  `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after
  23s. Hub surfaces now get one whole-request deadline, retry only
  immediate connection errors, and the deadline bounds the whole call
  including the request still in flight.

- Request shape. `/Items/Latest` groups a TV library by series, so its
  rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost
  a DB count each, per row. Hub rows now ask for `Overview` only; watch
  state survives because Jellyfin derives `UserData.Played` from
  `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp`
  sends `NextUpDateCutoff` to bound the server's series-key scan, and
  `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData`
  and `PremiereDate` leave the browse set: neither is an `ItemFields`
  member, so the server dropped them anyway.

- Fan-out. Per-library hubs ran in batches of three separated by a
  barrier, so one slow library stalled every library behind it. A
  sliding window keeps the same peak concurrency without head-of-line
  blocking. Concurrent `fetchLibraries` calls now share one `/Views`
  instead of racing two identical round trips, Plex's global and music
  hub legs start together, and Jellyfin gets Plex's pool tuning.

- Duplicate pass. `DiscoverScreen.initState` starts a load and the
  online-entry hook asked for a full refresh on top of it, which
  `CoalescedLoadCoordinator` correctly queued as a trailing pass. The
  hook now calls `primeRefresh`, which rides along with a load already
  in flight; profile switches still go through `fullRefresh`.

Refs #1784
2026-08-04 04:35:06 +02:00
edde746 860ce1e11a feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.

Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.

The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.

close #1667
2026-08-04 02:22:44 +02:00
edde746 2b4875d389 fix(player): keep hidden and cycled subtitles off in the next episode
Episode navigation carries the subtitle choice this screen has committed, so
a way of turning subtitles off that the screen never sees is undone by the
next episode.

ExoPlayer has no renderer-level visibility switch, so the player's hide
toggle is emulated by deselecting the track. That emulation lasted until the
next selection: the automatic pass after an episode change put subtitles
straight back on screen while the toggle still read "hidden", and un-hiding
then restored a track id belonging to the episode that had already ended.
Hiding is now sticky across media opens the way mpv's global sub-visibility
is, selections made while hidden become what un-hiding restores, and the
toggle no longer refuses to restore because the hidden track reads as Off.

Cycling subtitles over the native track list — downloads, and items whose
server exposes no subtitle rows — went straight to the track manager, which
owns the player selection and the server write-back but not the committed
choice. The screen records the cycled track now.
2026-08-03 17:07:14 +02:00
edde746 e7aa1e4782 fix(jellyfin): stop overriding a server subtitle mode of None
Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the
user's SubtitleMode is None: the index is the server's whole answer, and null
means it picked no subtitle. The mapper read null as "the server did not say"
and promoted the container's default/forced flags to a server selection
instead, which outranks the profile subtitle mode in the selection ladder. A
viewer who had turned subtitles off for their Jellyfin user got them switched
back on by every item that carried a default or forced row.

Only the row the server names is selected now. A stream the viewer picks, and
an explicit off, still survive per item because Plezy reports the index
through playback progress and the server hands it back as that index or -1.

close #1779
2026-08-03 17:07:05 +02:00
edde746 0f5e5c8b6e feat(music): offer File Info on tracks and other file-backed items
The context menu only offered File Info for movies and episodes, so a
track's path, container, and audio stream detail were unreachable even
though both backends already answer getFileInfo for them.

Gate the entry on the new MediaKind.hasFileInfo instead of a literal
kind list: movies, episodes, tracks, and clips are leaf items with real
files, while shows, seasons, artists, albums, collections, playlists,
and folders carry no Media/MediaSources and would only ever produce the
"not available" snackbar.

Also fix the Plex stream classifier, which mapped streamType 4 to an
embedded image although PlexStreamType.lyrics is 4. Only music tracks
carry that type, so a track's lyric stream rendered under "Embedded
Images" with the video field block. Type 5 was invented outright and is
now unknown.

close #1747
2026-08-03 02:34:37 +02:00
edde746 fb0613e3db feat(player): toggle playback on a two-finger tap without raising the chrome
A touch viewer had to raise the chrome to pause, which dims the picture and
covers the subtitle line they were trying to finish reading. A two-finger tap
now toggles playback with the chrome left down, so the frame that pauses is the
frame that was on screen. It fires the moment the chord resolves, in every
player state.

The two-finger double tap no longer resets the video zoom. Keeping it would mean
holding this toggle back for the double-tap window before acting, and pausing
late is pausing on the wrong frame. Zoom reset stays in the video settings sheet,
its presets and the keyboard shortcut, and pinching back to 100% now snaps
exactly within three percent so touch has a one-gesture path too.

Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert
under screen lock, in PiP and while the content strip is open; the zoom reset
previously fired straight through a locked screen.

close #1505
2026-08-03 00:04:03 +02:00
edde746 9c08c78f6d fix(plex): stop recording a second play when the server already logged one
Plezy reported a completed playback twice: the /:/timeline heartbeats let
the server mark the item played on its own, and the in-player auto-scrobble
then sent an explicit /:/scrobble for the same watch. On PMS 1.30 that adds
a second Play History row; on 1.43 the row is suppressed but viewCount still
lands on 2 for one playback.

Measured against PMS 1.43 to find what the server acts on: a watched-threshold
crossing observed inside one session. Consecutive above-threshold reports mark
nothing, a resume point left by an earlier session does not arm a new one, and
a report at position zero is inert while one at a single second is enough. So
the explicit mark now goes out only for sessions that gave the server no
crossing to observe.

That decision cannot be made while the session is live. A session beginning
past the threshold has no crossing yet, but rewinding and playing forward
creates one, and the server records it — marking eagerly and then hitting that
path leaves viewCount at 2 again. The mark is therefore deferred to the
terminal stop, and rides its future so callers that await the stop before
tearing the player down do not drop it. Deferring also covers a crossing
coalesced away during startup and a seek back below the threshold before
stopping.

Crossing state is tracked from reports the backend actually received rather
than from PlaybackReportSession.report()'s bool, which resolves true for a
same-state snapshot dropped during startup.

The same-file sibling hook (#1500) still runs exactly once, on the transition
to a settled mark rather than at the local crossing, so sibling episodes are
never marked watched while the episode actually played is not.

Local watched state and Continue Watching removal still happen on the observed
crossing, so the only behaviour that moves is the redundant server call.

close #1740
2026-08-02 16:16:19 +02:00
edde746 95b013e155 fix(seerr): show worldwide popular titles in Explore again
Overseerr and Jellyseerr bind the `language` query parameter of
`/discover/movies` and `/discover/tv` to `originalLanguage`, which becomes
TMDB's `with_original_language`. Sending the app locale there collapsed both
shelves to titles originally made in that language, so a Portuguese UI saw
only Portuguese films. Those two routes take their display language from the
instance/user locale, which already wins over the query value, so the
parameter was pure filtering with no localization to show for it.

Drop it from the two paged discover routes. Trending, both upcoming rows,
search, details and recommendations keep it: Seerr treats it as the display
language everywhere else.

close #1763
2026-08-02 15:35:42 +02:00
edde746 d83d0790ba fix(exoplayer): match side-loaded subtitles after media3 rewrites track ids
Plex sidecar subtitles are attached as MediaItem.SubtitleConfiguration and
tagged `external_<n>`, then recovered from the Format id the track selector
reports. Since media3 1.3.0, DefaultMediaSourceFactory always merges
side-loaded subtitles with the primary source and MergingMediaPeriod rewrites
every child format id to "<periodIndex>:<originalId>", so the tag arrives as
"1:external_0" - measured on device - or "0:1:external_0" behind the
container-sidecar merge. The prefix test therefore never matched and every
sidecar reached Dart as an embedded track with no URI.

A Plex sidecar's only identity is its stream key, which the app carries in
that URI, so both matchers failed on it: a server-selected sidecar could
never resolve and left subtitle selection pending, and a manually chosen one
could not be mapped back to a stream id to write to the server. The
already-attached branch of addSubtitleTrack compared the raw id too, so
re-selecting a loaded sidecar silently did nothing.

Route every write and readback of the tag through ExternalSubtitleIds, which
matches the final id segment, and cover it with an instrumentation test that
side-loads a subtitle through the real media3 media-source factory. Also stop
claiming a saved track selection when no server stream was identified - there
is no local store, so that path silently dropped the user's choice.

close #1713
2026-08-02 12:19:28 +02:00
edde746 35061f9f68 fix(jellyfin): scope global search to visible libraries
Jellyfin search rows do not expose their owning collection, so hidden libraries cannot be filtered after the response. Scope searches to visible libraries, stamp the returned rows, reuse the latest loaded views, propagate cancellation, and fail closed when views cannot be loaded.

Keep full candidate budgets and split music libraries into parallel album, audio, and artist requests. Album requests disable UserData and use the existing album field set to avoid recursive per-album work from #1552; audio requests retain cheap leaf play state.

close #1770
2026-08-02 09:29:59 +02:00
edde746 1d9ffb7427 fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a
hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue
Watching and the library rail still surfaced their contents in the Search
tab. Thread the profile's hidden keys from SearchScreen through to the
aggregation, and drop matching items between the fan-out and the ranking
pass so hidden hits cannot spend the result limit and shrink what is
shown. Items the backend cannot attribute to a library, such as Plex
shared and external media, are kept.

The screen re-runs the visible query when a library is hidden or unhidden
while results are on screen. Its listener is attached only after the
provider has hydrated, so the initial load notification cannot race the
first query into running twice.

Plex search rows now go through the library-aware tagger, so a response
that names its section only via librarySectionKey or
targetLibrarySectionID is still attributable, and therefore filterable.

Jellyfin search results carry no library id at all: the mapper's
ParentLibraryId is not a Jellyfin field, and ParentId resolves to a
season or physical folder rather than a CollectionFolder. Filtering there
needs server-side ParentId scoping and is left for a follow-up.

close #1770
2026-08-02 09:29:59 +02:00
edde746 2a7e5f4f9c fix(jellyfin): ask the server who may delete before offering it
Jellyfin never consults IsAdministrator when authorizing a library
delete: BaseItem.IsAuthorizedToDelete looks at EnableContentDeletion and
the per-library grant, and only the first user a server creates gets the
former for free. Gating the "Delete from server" entry on the admin bit
therefore offered a destructive action that answers 401 to later
administrators, and hid it from plain users who do hold the grant.

Ask the server per item instead, through the new
MediaDeletionPermissionClient capability: BaseItemDto.CanDelete already
folds the global grant, the per-library grant, and item state such as
missing files or an in-progress recording. The probe runs when a menu
opens on a deletable kind, costs ~0.5 KB, carries a whole-request
deadline because the client's own budget covers connect and receive
separately, and fails closed on anything unknown. Plex keeps its
account-level owner/admin gate; it has no per-item permission on the
wire.

close #1749
2026-08-02 07:08:14 +02:00
edde746 bc0d14a749 fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore
"In these libraries" chooser could never show more than one copy per server.
A movie held by both a 4K library and an HD library on one Plex server
therefore resolved to whichever copy came back first, with no way to reach
the other.

Return every id-verified match instead. `/library/all` is already
server-wide and each `Metadata` entry carries its own `librarySectionID`,
so both copies come back labelled with no extra request; Plex was simply
taking `Metadata[0]` and the title ladder was returning on its first hit.
An exact-guid hit no longer short-circuits the title search either — a
library still on a legacy agent has a different primary guid and is
invisible to the `guid=` filter.

Copies are deduped by global key and ordered best-first, and each row now
states its resolution, since library names need not mention it.

Resolution passes merge rather than replace: the cross-server fan-out logs
and skips per-server failures, so a later pass can come back short a server
that answered an earlier one, and a failed pass no longer claims the title
left the library. Duplicate keys fold field by field, because Jellyfin's
library stamp is a best-effort ancestors lookup that returns the item bare
when it fails and an unstamped row is indistinguishable from its sibling.
Focus nodes are keyed by copy and reclaimed after a merge re-sorts the
rows, so a dpad user is not thrown to a different copy.

close #1754
2026-08-02 06:40:04 +02:00
edde746 f78f65faf5 chore(player): report marker counts when loading playback extras
The extras loader logged only the chapter count, so a user report of
"auto skip never fires" could not be told apart from "the server has no
intro marker for this item" — the two need opposite fixes. Log the
marker count and types on all three load paths, including the cache-only
one that previously logged nothing at all.

Drop PlexVideoPlaybackData.markers while here: the playback-start parse
filled it on every item and no caller ever read it, because the player
controls fetch their own PlaybackExtras.

Document why getPlaybackExtras may serve the shared metadata cache row
without a freshness check: getPlaybackInitialization refreshes that row
network-first before the controls mount. That ordering is what makes
cache-first correct, and nothing said so.
2026-08-02 04:55:54 +02:00
edde746 957711a650 fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of
10336 bytes, every one of them zero, and reported each as "still failing". The
gate classified it correctly every time and the consented repair would have
cleared it in-process, but nothing on the failure screen said so: Retry was
first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside
it as a tonal afterthought. Retry re-reads the same document, so for a
corrupt-store failure it is an action that cannot succeed however many times it
is pressed — and it was the one the screen recommended.

Repair now takes the primary styling, the focus node and first position whenever
it is offered, and the body text says plainly that retrying will not help.
Retry keeps its place for every other failure, where a locked database or a
denied directory really can change between attempts.

The consent dialog was also promising an outcome it could not always deliver.
Servers and profiles survive a repair only because their tokens are ciphertext
in the database and the key that decrypts them lives in the store, so a store
the key cannot be read out of signs the user out of everything — exactly the
all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without
touching it, and the dialog now names the real cost from that. The retained copy is
labelled as holding credentials unless the bytes prove otherwise: what the
salvage recovered says nothing about what the file still contains, because a
store truncated mid-value keeps most of a vault key in plaintext while the
salvage pattern — which needs the value's closing quote — matches nothing at
all. Only an all-zero file drops the warning, so the one case that cries wolf
is the one that provably holds no secret.

`describe()` finally carries whether a repair was on offer. That line is the
difference between a report a maintainer can act on and two days of guessing
whether the button was even on screen.

close #1732
2026-08-02 04:37:29 +02:00
edde746 2cb2c3eb95 feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor
took `rating` and fell back to `audienceRating` only when it was null, so a
Plex movie carrying four attributed scores surfaced one, and which one was
whatever the server happened to put in the scalar slot. #1755 asked for a
setting to choose the source; showing all of them answers it without one.

The data was already on the wire and being thrown away. `/library/metadata/
{id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels,
TMDB — with no extra query parameter, but PlexMetadataDto declared no field
for it, so json_serializable dropped the key. The identical parse already
existed in plex_catalog_source for the Explore tab and had simply never been
wired to library items.

Model the scores as a list rather than widening the scalar pair. The neutral
MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage
and audienceRatingImage, which the list subsumes — Plex sends those images
on listings too, so the same field covers both response shapes and no caller
narrows to a backend type to read a score any more. CatalogRatingSource is
promoted to lib/media as MediaRatingSource instead of growing a second
near-identical type beside it, and plex_catalog_source's _ratingsFor becomes
the shared plexRatingSources so one implementation serves both paths. There
is no persistence to migrate: MediaItem.toJson has no production caller, the
offline path re-parses raw Plex JSON through the same mapper, and Plex's
audienceRating sort is server-supplied data, not a model read.

Cards and the dashboard still show fewer scores than detail screens, and
that part is a real Plex limit rather than a shortcut. Section listings send
only the scalar pair; includeRatings, includeElements=Rating,
includeFields=Rating, includeChildren and includeExtras were each probed
against a live server and none surfaced the array, while includeGuids=1
demonstrably does add Guid[] — the probe works, the parameter does not
exist. Hydrating every card would be one request per row, so listings render
whatever their own response carried, which is one or two attributed scores
rather than the single one they showed before.

Jellyfin has no per-source array at all: the server collapses whatever its
fetchers found into CommunityRating and CriticRating. CommunityRating's
provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or
a local NFO, last writer wins — so it stays the generic `audience` source
with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a
0-100 percent and is divided by ten explicitly rather than folded by
magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have
promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin
reuses CommunityRating for the EXIF 0-5 star.

The badges share one slot on every surface. On the phone hero the scores go
in a single pill because that chip row is a height-clipped Wrap and a chip
per source would push year, certification and runtime out of the visible
band on short heroes; on the TV detail line and the dashboard spotlight the
group occupies the one metadata slot so bullet separators do not multiply.
The group announces itself as a single semantics node naming each source,
because a bare row of four percentages tells a screen reader nothing about
which score is which. rating_utils drops parseRatingImage and
isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper —
and the source-key resolver and label map, previously private to the Explore
detail screen, become the shared pair both screens use. The label strings
move from explore.ratingSource to common.ratingSource accordingly, which
costs no translations because every non-English value was empty; running
clean_translations also scaffolds startup.quitPlezy and
startup.restartRequiredBody, which were already drifted.

Verified against the live server the probes came from: a detail response now
yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the
production mapper and badge resolver, and the listing response for the same
title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures.
Coverage adds mapper ordering, dedupe against the array's repeat of the
scalar, out-of-range rejection, the Jellyfin scale and photo guard, the
CatalogItem conversion that feeds Explore's dashboard hubs, and the three
render surfaces including the semantics announcement.

close #1755
2026-08-02 03:59:56 +02:00
edde746 9ecf8db90f fix(startup): stop offering Retry after a repair that needs a restart
A seed-and-restart repair writes the salvaged credentials straight to disk and
leaves this process's store closed, because the plugin still holds the bad
document in memory. repairCorruptStore says so plainly — "nothing may write a
preference before that restart … the caller keeps the app on the failure
screen precisely so nothing does" — but the caller did not. Clearing the
repairing flag re-enabled Retry, and pressing it reopened onto the stale map,
whose first write would flush it back over the seed and orphan every
ciphertext token in the database.

The repair hook returned a bare bool, which cannot express the difference
between "retry now" and "never retry in this process", so replace it with
StartupRepairResult. The restart case latches terminal state on the bootstrap,
withdraws Retry and Repair rather than grey them out — a disabled control
still invites another press — and says what to do instead, which nothing did:
repairNeedsRestart was a dialog title with no body anywhere. Desktop gets a
Quit button through the existing AppExitService seam; Copy and Upload stay
live everywhere, because a stuck user still needs the diagnostic out.

close #1732
2026-08-01 06:59:20 +02:00
edde746 3ae7aa554b fix(prefs): recover a preference store whose bytes are not valid UTF-8
`File.readAsString` reports a UTF-8 decode failure as a FileSystemException,
not a FormatException, so three guards written for that case never ran. The
preflight's `on FormatException` branch was unreachable and its
`on FileSystemException` sibling waved the document through; the plugin then
threw the same FileSystemException, which failed the FormatException/TypeError
test that decides repairability; and quarantine's lossy-decode fallback sat
dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray
0x80 — therefore reached the user as a failure screen with no Repair button
and no way forward at all.

Read bytes and decode explicitly instead, at both sites. Classification moves
into describeStoreDamage, so a failure that surfaces after the preflight
passed is judged by re-reading the file rather than by the error's type: a
denied or locked store is indistinguishable from a decode failure by type or
message, and offering a destructive repair for a permissions problem would
reset every setting and risk the vault key over something a chmod fixes.
isCorruptStoreError went with it, having no remaining callers.

A repair that quarantines the store and then cannot reopen it no longer
strands the process either. The repaired future was built straight from the
cache loader, bypassing the self-healing reset sharedCache installs, so a
failed reopen parked a rejected future in _cacheFuture and every later attempt
replayed that stale error — with the damaged file already moved aside, so a
restart would have booted cleanly.

CorruptPreferenceStoreException now carries reopenSafe and a derived,
content-free shape: byte length, whether it decoded, whether every byte is
zero. #1732 arrived as "FormatException at offset 0" and nothing else, which
cannot separate an all-zero file from a non-JSON first character from bytes
that are not UTF-8; these can, and never quote the document.

Cover the loop against the real desktop backend rather than a fake.
shared_preferences_linux is pure Dart, byte-identical to the Windows
implementation, and exposes fs/pathProvider, so pointing it at a temp
directory exercises the genuine read, parse, cache and write path on any host
— the join between preflight, classification and reopen where every one of
these defects lived, and which had no coverage at all.
2026-08-01 06:59:20 +02:00
edde746 56ad48824b fix(jellyfin): pin MediaSourceId on every static stream URL
Jellyfin has no DirectStreamUrl field — MediaSourceInfo carries only
TranscodingUrl, and a DirectPlay decision returns no URL at all, leaving the
client to build /Videos/{id}/stream itself. The branch reading
DirectStreamUrl was therefore dead against every Jellyfin version, along with
the 'DirectStream' play method and the doc comment promising both.

The static URL also dropped MediaSourceId whenever the item had a single
source whose Id equalled the item id — an ordinary episode. The streaming
endpoint resolves a blank MediaSourceId to its own first sorted source
(VideoFile first, then widest video), so the omission silently streamed a
different file as soon as the item gained an alternate version. Forward the
id the negotiation settled on, as jellyfin-web, Findroid, and Streamyfin all
do unconditionally.

Every "pinned" fixture used a source id that differed from the item id, so no
test exercised the shape that dropped the param; add one that does.
2026-07-31 21:45:33 +02:00
edde746 6f9edd3e93 fix(startup): make the deferred crash report survive its races
The persist-then-flush model had four ways to lose or corrupt the record
it exists to protect.

A no-op hub — which is what a failed or timed-out crash-reporting init
leaves behind, because that phase is best effort — accepts an event and
returns an empty id without throwing. "Did not throw" was treated as
delivery, so the record was marked reported and suppressed forever.
Delivery now requires a non-empty Sentry id, and init completion is
tracked explicitly rather than assumed.

Opting out, and building without a DSN, are deliberate suppression
rather than delivery failure: both mark the record resolved so it is not
rediscovered every launch. Everything else stays pending, and
consumption no longer deletes an unreported record — deleting it ended
the only retry there was, which made "the next launch tries again"
false.

The write path is now a queue. Record writes were launched unawaited
from the failure path, so a fast retry could flush before the file
existed, consume before a late write landed, or run two writers against
one file and let the older one finish last. markReported joins the same
queue and compares record identity before rewriting, because reading and
writing outside it let a concurrent record land in between and be
overwritten by the record it had just superseded. Records carry an id so
that comparison is meaningful.

Consumption also waits on a registered flush, so the success path cannot
delete the file mid-send.

Also routes the tvOS recovery marker through the tolerant read.
reconcile() runs inside AppDatabase.open, a fatal gate step, so a
wrong-typed marker vetoed the launch outright on a first-class TV
target. Both new guards have regression tests verified to fail without
the fix.
2026-07-31 21:45:33 +02:00
edde746 9555937873 fix(startup): defer crash reports until the reporter exists
Reporting the failure inline was wrong for the phase that matters most.
The gate opens preferences before SentryFlutter.init, so a corrupt or
unreadable store — the likeliest cause of #1732 — was captured by a
NoOpHub and silently discarded, which is exactly the telemetry gap the
previous commit claimed to close. Initialising the reporter earlier is
not an option either: `_beforeSend` reads the crash-reporting opt-out
from settings, so events raised before settings load would bypass a
user's choice.

Every failure is now persisted first and flushed once the reporter is up
with settings loaded, which in practice is the user's own retry seconds
later in the same process. Records carry a `reported` flag so a send
happens exactly once, and a failed send leaves the flag clear so the
next launch tries again. The flush reads without consuming, so the
record still reaches Settings > Logs.

Also routes the tvOS recovery marker through the tolerant read:
`reconcile()` runs inside `AppDatabase.open`, a fatal gate step, so a
wrong-typed marker vetoed startup outright on a first-class TV target
despite the new default-instead-of-veto behaviour. An unreadable marker
tells us nothing, which is the same position as an absent one.
2026-07-31 21:45:33 +02:00
edde746 66549e3a67 fix(prefs): route every credential read through the tolerant path
The wrong-type recovery only covered reads that went through a
BaseSharedPreferencesService instance. The three stores that hold
credentials read the shared cache directly, so a mistyped value there
still threw a raw TypeError or, for Seerr, was swallowed by a catch-all
and reported as "no session" — the registry documented protection it did
not actually provide.

readPreferenceTolerantly now takes the cache, so CredentialVault,
TrackerAccountStore and SeerrSessionStore get the same classification as
the settings layer. CredentialVault's post-write re-read moves outside
its catch: a wrong-typed value written by another isolate was swallowed
there, and the process then returned a key that never durably landed,
making every ciphertext written under it unreadable on the next launch.

Those stores are consulted long after startup, where a throw is an
unhandled provider error rather than a repair prompt, so SettingsService
initialization now walks the cached key set once and reads every
sensitive key. That puts the failure inside a fatal gate step while the
store is still open and a surgical single-key repair is possible.

The remaining direct reads in settings and storage are routed too; the
only ones left are the library-density dual-type migration, which probes
both types deliberately, and an untyped switch that is type-safe by
construction.
2026-07-31 21:45:32 +02:00
edde746 7f0cad339c fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
2026-07-31 21:45:32 +02:00
edde746 944a8d89f5 feat(windows): package for the Microsoft Store as an MSIX bundle
The Store's unpackaged EXE path would require Authenticode-signing the
installer and every PE file inside it. MSIX submissions are re-signed by the
Store instead, so this route needs no code-signing certificate. build-msix.ps1
mirrors build-installer.ps1 and consumes the same per-architecture build
artifacts, leaving the installer, portable archives and WinSparkle appcast
untouched.

One template generates the manifest for both architectures, carrying the
identity reserved in Partner Center. check_windows_msix.py recomputes the
package family name from the publisher DN, so a mistyped identity fails CI
rather than a submission, and it parses the script rather than running it
because root CI is Linux. Qualified logo assets are indexed into
resources.pri; without the altform-unplated variants the shell draws the
taskbar icon on an accent-coloured plate.

PlatformDetector.isPackagedInstall gates the in-app updater and the Liberapay
tile, which the read-only package directory and Store commerce policy
respectively rule out. Gating at runtime keeps one Windows build feeding both
the installer and the Store package.
2026-07-30 20:03:04 +02:00
edde746 834895486b style: apply dart format to six drifted sources
Formatting was clean through 53288116 and then drifted across three commits on
2026-07-30: 1bf7aac7 left one source unformatted, f13f5af6 a second, and
daab4f1e four more. CI's Verify formatting job checks the whole tree, so it has
had six files to report ever since. No pre-commit hook is installed in this
checkout, so the aggregate check never ran locally to catch them.

Formatted with the dart_style revision Dart 3.12.0 bundles, which is what the
pinned Flutter 3.44.0 CI toolchain runs, rather than with a newer local SDK; the
two disagree about some argument-list splits. The current stable formatter
accepts this result as well, so both report the tree clean.
2026-07-30 14:57:26 +02:00
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.

Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.

Two failure paths found while consolidating are fixed here too.

The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.

The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
2026-07-30 14:51:32 +02:00
edde746 5a25c1f9cc feat(simkl): report playback progress while it happens
Simkl only heard about an item once playback crossed the media server's
watched threshold, so stopping partway recorded nothing at all: no resumable
position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the
player lifecycle instead, carrying the measured progress. Seeks report
nothing, as Simkl asks.

The terminal stop owns watched state for in-player playback, so real-time
trackers are excluded from the threshold markWatched fan-out and one watch
never produces two writes. Progress is reported as measured — it doubles as
the user's resume position — so when a server threshold configured below
Simkl's own 80% rule would leave the watch unrecorded, the tracker records it
through /sync/history rather than inflating progress. Manual, container,
offline-replay and external-player marks keep using /sync/history. Only
/scrobble/stop accepts a 409, which is the sole action documented to return
one.

Reports go out one at a time because Simkl serialises scrobble writes per
user and fails queued ones with a 400; overflow sheds the oldest non-terminal
report so an episode swap cannot drop the previous item's stop. A playback
session is pinned to the account bound when it began and every send re-checks
that binding, so a profile switch or a disconnect/reconnect can neither
redirect a queued report nor misfile the watched fallback.

Also close the paths that lost the terminal report entirely: app exit flushes
it instead of dropping it, the desktop window button goes through the app
shutdown rather than exit(0), a detached VOD player reports a stop, and a
finished item reports completion at EOF instead of waiting for teardown. A
session that opened at 0% is still closed on stop, or Simkl keeps showing the
item as playing until its runtime elapses.

close #1719
2026-07-30 11:55:13 +02:00
edde746 27b994422c feat(player): optionally follow the server's per-episode track selections
With the new playback setting enabled, episode advance carries no audio or
subtitle preference at all, so both resolve from the streams selected on
the server for each individual episode. This serves setups that curate
selections server-side (e.g. Plex Auto Languages) and is independent of
"Remember track selections", which keeps gating only the write-back of
manual changes.

close #1717
2026-07-30 02:52:18 +02:00
edde746 daab4f1e24 fix(player): preserve the forced-subtitle class across episode boundaries
Plex treats a subtitle stream as forced when its title says "Forced" even
with the API flag unset. Every forced comparison now uses that effective
forced-ness on both sides: the match scorer, the low-metadata hard gate,
the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation.

Carrying a track choice into the next episode no longer reuses the
same-item identity matchers. A sealed SubtitlePreference (off / track
reference / semantic intent) replaces the id-'navigation' pseudo-track
through the whole preference channel, and cross-item intents hard-require
language and forced-class parity. When the next episode has no track of
the same class, the intent declines and selection falls through to the
server's own per-episode choice instead of latching onto a full track by
position and persisting that mistake back to the server.

Intents wait for pending native tracks under the same catalog-completeness
rule as source ids, so an early decline cannot retire the selection
listener before the real track arrives.

Ref #1716
2026-07-30 02:46:10 +02:00
edde746 f13f5af6e2 fix(images): scale artwork budgets to the physical display
Every artwork budget in the image pipeline was tuned for 1080p surfaces:
the transcode request clamp (1920x1080), the per-type decode caps
(poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV
image-cache bytes. Those numbers are exact on phones and on the many TV
boxes that composite the app at 1080p, but a TV compositing at 4K
renders every capped image below its slot and GPU-upscales the result:
hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x,
shelf posters by ~1.13x - the softness reported against the official
Plex client in #1697, and the class #860's min-2x-DPR fix could not
reach.

DevicePerformance now latches a display budget factor - the display's
shortest physical axis over 1080, capped at 2x - whenever the image
cache budget is applied (startup, post-mount, effects-setting changes).
The transcode clamp, the full-tier decode caps and the TV cache bytes
all scale by it, so a 4K surface fetches and decodes 4K backdrops and
proportionally larger cards. The reduced tier stays pinned to 1.0, and
sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art
(~33MB per decode) cannot starve mid-RAM boxes; latching once per
session keeps transcode URLs - and with them the disk cache keys -
stable across rotation and rebuilds.

Whether a given TV composites at 1080p or 4K decides whether any of
this can help, and logs never recorded it: the startup banner and the
log-upload header now carry a display line (physical, logical, DPR,
latched budget) so uploaded logs answer that question directly.

The two pre-existing Windows-host test failures (automotive auto-PiP
gate, backdrop temp-dir teardown lock) reproduce unchanged on the base
commit.
2026-07-30 01:40:24 +02:00
edde746 1bf7aac75b fix(explore): match Plex Discover titles to the library again
Two defects sank Explore's Plex integration. Discover started rejecting
X-Plex-Container-Size=500 with a 400, so the watchlist membership
snapshot never loaded: hearts stayed unknown and toggles dead. The
snapshot now pages at 100, and getWatchlist refetches a rejected page in
chunks of the row fetch's field-proven 25, so the next cap drift degrades
gracefully instead of failing and callers' offset math survives either
way.

Worse, every Plex catalog item reached the library matcher carrying only
its Discover rating key: listings were fetched without includeGuids, so
the lookup rested entirely on exact plex:// guid equality between two
metadata universes (Discover duplicate entries break it, notoriously for
anime), and the title fallback can never confirm a candidate without
external ids to intersect - "Not in your library" for owned titles the
MAL provider matched fine. Discover listings now request Guids, the
detail screen re-runs the matcher when enrichment gains id forms
(generation-guarded so the slower bare lookup cannot overwrite the
richer verdict), the matcher keys its memo by id fingerprint so the poor
form's cached negative cannot answer for the rich one, and the Plex
client stops burning title requests that external-id verification is
guaranteed to reject.

Discover requests are now logged like every other API surface; this bug
shipped blind because they were not.

close #1715
2026-07-30 01:33:46 +02:00
edde746 53288116fe fix(explore): size catalog detail relations, ratings and facts to their content
Four sections of the catalog detail screen spent more room than their data
justified.

Franchise relations drew one hub shelf per label. Real payloads make that
absurd: MAL returns twelve relations for Attack on Titan across six labels,
and "Side story" and "Sequel" each hold exactly one title, so each spent a
header, a scroll row and one card. Flatten the labelled groups into one
"Related titles" section of compact rows — poster thumb, label, title and year
— that flow into columns on wide viewports. D-pad moves through the grid by
index and still hands off to the cast strip above and the recommendations
shelf below, which keeps its shelf because taste-based recommendations are
meant to be browsed.

Drop the MAL picture gallery. It was a horizontal strip of unfocusable poster
variants of the title you are already looking at, and it cost a page-height of
scroll; the `pictures` field comes back out of the detail request with it.

Draw attributed scores behind their own brand mark where the source has one,
the way the media detail screen already does: Rotten Tomatoes fresh/rotten and
upright/spilled, IMDb and TMDB, each on the scale that source publishes.
Sources with no mark (critic, audience, tracker scores) keep their written
label. Plex's own badge state is derived from the 60% tomatometer threshold it
encodes in `image.rating.ripe`.

Flow the definition rows — original title, studios, country, budget, box
office, crew — into two or three columns once the window is wide enough.
A 1440-wide window drew a 140-pixel label, a short value and 1,000 pixels of
nothing per fact.

Verified against live MAL and Plex Discover payloads on macOS: the Attack on
Titan page drops from 4,082 to 2,115 logical pixels, Dune: Part Two from 1,282
to 1,154.
2026-07-30 01:01:38 +02:00
edde746 27acbaf435 feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
2026-07-29 06:47:54 +02:00
edde746 0ef71e6489 feat(file-info): detail every version, file and stream the server reports
The sheet collapsed an item to `Media.first` / `MediaSources.first` and
rendered a fixed set of rows, so split files, extra versions, per-track
properties, HDR classification and Dolby Vision were all invisible.

Model the payload the way both servers shape it — versions own parts,
parts own streams — and project every property either backend populates
onto `MediaStreamDetails`. The field set comes from sweeping both test
servers in full through the clients' own request shapes (Plex 6842
Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763
streams / 61224 attachments), so file presence, Dolby Vision layers,
dynamic range, sample rate, spatial audio, sidecar provenance, embedded
attachments, rotation and the lyric stream type all survive. A coverage
test fails when a server key is neither carried, folded into a sibling,
nor excluded with a reason.

File Info also stopped trusting the shared `/library/metadata/{id}`
cache row: `getPlaybackExtras` writes it without `includeStreams` /
`checkFiles`, so the sheet could render with no stream table at all.
Detect that shape and refetch once under the request context captured
before the cache read, so the outgoing token and the cache namespace
stay on one profile.

Rework the layout to match: a summary chip row, then flat tonal cards
with a two-column field grid that collapses to one column on narrow
viewports, per-stream cards with flag chips, and a copyable monospace
path row. The card fill is a tonal step off the text colour rather than
the `bg` token, which is one shade from the sheet surface on OLED.
2026-07-29 05:19:27 +02:00
edde746 f3795d49eb feat(player): answer transport keys with transient indicators, not the chrome
Pressing pause or seeking while the player's on-screen controls were hidden raised
the entire OSD, covering the subtitles the viewer was rewinding to read. Transport
keys now answer with a transient indicator and leave the chrome down; Select,
D-pad Center and a centre tap remain the deliberate way to bring the controls
back.

Play/pause confirms with an icon-only translucent disc at the centre of the frame,
72px around a 44px glyph, which grows and fades in, holds half a second at rest,
then runs the same motion in reverse. Seeking shows the amount plus a single
chevron on the same line at the edge it travels toward, with no backdrop at all:
anything large enough to read as a surface is large enough to cover picture and
subtitles, so legibility comes from shadows instead. Only the chevron moves, and
it eases outward across most of its cycle and returns briefly, holding a visible
opacity floor rather than blinking out. Type is scaled per platform, since a
television is read from across the room. The existing text pill stays for genuine
notices - rate changes, chapter titles, zoom, errors - because an earlier centred
pill overlapped ASS \an8 subtitle placement, which is the readability complaint
this feedback exists to answer.

Every relative seek entry point now shares one coalescing primitive. The keyboard
shortcuts fell through to KeyboardShortcutsService and previously reported
nothing, and both they and the remote's chapter fallback rebased each press off
player.state.position, so a burst against a slow backend pinned every request near
one step while the indicator climbed to a total that was never committed. A
released key commits its pending target immediately and resets the acceleration
tier, including on live TV where seeks bypass the accumulator. A chapter seek with
nowhere to go, past the last chapter or already at the start, no longer announces a
jump it does not perform.

Rewind-on-resume follows the resolved intent rather than the current state, so a
directed pause on an already-paused video neither resumes nor rewinds. Indicators
carry their own liveRegion semantics nodes: their labels previously merged into the
full-screen "show playback controls" target, corrupting its accessible name, and
they keep announcing "Paused"/"Playing" and the seek amount from icon-only visuals.

close #1676
2026-07-29 04:24:04 +02:00