Commit Graph
258 Commits
Author SHA1 Message Date
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00
edde746 9ac8002b53 feat(player): apply separate default quality on cellular connections
On mobile data the single default streaming quality either burns
through the user's data plan or permanently caps quality at home.
Playback start now applies a dedicated cellular default when the
device is on a cellular-only connection, read from the app's existing
single connectivity subscription. The new setting defaults to
following the general default, ships in Settings > Playback for
phones/tablets only, and explicit per-play quality picks still win.

'Metered connection' had been spelled out independently in the offline
provider, the download WiFi-only gate and the sync-rule cooldown.
Rather than add a fourth copy, all four now share
ConnectivityLinkType. Network *presence* is deliberately left alone:
its callers disagree on whether an empty snapshot counts as
connected, so folding that in would change behavior.

showSelectionDialog now returns the picked DialogOption instead of its
raw value so the tile's null-valued 'Same as Default Quality' option
stays distinguishable from dismissing the dialog.

close #2147
2026-08-27 14:14:01 +02:00
edde746andGitHub ae555ed5d7 feat(settings): account preferences, stored on the server instead of the device (#2112)
Preferences that belong to the media-server account had nowhere to live in Plezy. Jellyfin keeps a user's audio/subtitle language, subtitle mode and several library options in `UserConfiguration`; plex.tv keeps the same language choices plus watched indicators and review visibility on the account. Plezy read four of those fields for auto-track selection and could never show or change any of them.

Adds an Account preferences section under Connections. One account edits in place; several show a picker first, scoped to the active profile's own connections so a managed Plex Home user never edits the owner's record. Every row is gated on what the backend can actually store.

It also moves "rewatching in Next Up" onto the account. There is no `UserConfiguration` field for it, but the per-user `DisplayPreferences` store is keyed `(userId, displayPreferencesId, client)` with no device component, so the switch goes there and follows the account.

`AccountRef` keys by account, not `clientScopeId`: MediaBrowser is `{machineId}/{userId}`, Plex is (account, Home user). Writes are patch-shaped because both backends replace whole objects — Jellyfin's `POST /Users/{id}/Configuration` and its `DisplayPreferences` row both reset omitted fields, so each write re-reads, merges only the patched keys, and posts back. Plex takes its changes as query parameters with an empty body, and its `experience` blob and the PMS `/accounts/1` mirror are deliberately untouched.

`AccountPreferencesController` owns a single repository above the profile session, so changing a language in settings reaches the next playback without a restart. Emby is gated out of rewatching through `MediaBrowserDialect.supportsNextUpRewatching`.

close #1910
2026-08-25 08:42:48 +02:00
edde746 99c21ce6ae fix(remote): stop companion pairing hanging when a host address is unreachable
Tapping a discovered desktop in the phone's companion remote did nothing:
the device list emptied to "No devices found on your network" and no
connection was ever made. Manual IP:port entry silently did nothing too.

A host advertises every local IPv4 address, so a PC with virtual adapters
(WSL, Hyper-V) broadcasts addresses the phone cannot route to. The client
races them all, and cleanup of the losing candidates awaited
`sink.close()` on channels that never connected. That future never
completes in web_socket_channel: the close future waits on the local
stream's listener, which is only attached on the connect-success path. So
the race never finished, the winner was never joined, and the flow just
stopped with no error.

Cleanup now tracks whether a candidate ever connected: unconnected probes
get a deferred close armed on `ready` settling instead of a blocking
await, and they skip the terminal drain since they hold no host admission
slot. The managed join gets a real connect timeout that surfaces as a
typed timeout error, and disconnect during a pending connect closes the
socket once the connect settles rather than blocking on it.

Two paper cuts around the same flow: a failed attempt now restarts
discovery instead of stranding the cleared list on "No devices found",
and the desktop's server card shows its listen addresses so manual entry
is not guesswork.

close #2077
2026-08-24 00:08:10 +02:00
edde746 7652d060e2 fix(trackers): purge queued writes on session invalidation and lock-order the fallback sweep
Queued tracker writes still replayed through the wrong account when the session died by token expiry instead of explicit disconnect: the auth-failure teardown cleared the store and rebound null but never purged the service's retry queue, so rows queued under account A replayed through whichever account connected next. The invalidation callback now purges like the disconnect path, after the rebind so an in-flight failure is dropped by the account-binding check instead of re-queueing behind the purge.

Narrower second hole in the same invariant: removeService swept the in-memory fallback outside the queue lock, so an enqueue whose persist failed while a disconnect raced it re-buffered the row after the sweep and the next flush resurrected it. The fallback add and the fallback sweep both run inside the queue lock now, so lock-slot order covers the buffered store the same way it already covered the persisted one.
2026-08-21 19:23:44 +02:00
edde746 7fc559e64b fix(downloads): resolve offline detail metadata through the active profile scope
A shared physical download created under MediaBrowser user A could replace user B's offline detail metadata with A's watch position and token-stamped image URLs: lookupOfflineMetadata resolved via the download creator's clientScopeId. It now resolves the active profile's persisted scope like every other profile-visible read; a profile without its own cached row falls back to the lightweight seed metadata.
2026-08-21 19:23:44 +02:00
edde746 5c08862194 fix(trackers): purge queued writes on disconnect, evict stale anime snapshots, and cancel dismissed auth dialogs
Queued tracker writes carried no account identity, so rows created under account A replayed against account B after a reconnect; explicit disconnect now purges that service's queue. An in-flight markWatched/reconcile that failed after the purge was re-enqueued anyway (the write scope only tracked profile generation) and replayed into whichever account connected next: Tracker.accountBinding (client identity, rebound synchronously on every disconnect) is captured before each write and re-checked before queueing a retry, and because the check sits in the same synchronous segment as the enqueue, a row that passes it is claimed ahead of the purge in the queue mutex and is still removed by it.

The memoized anime-list snapshot survived writes, so sequential rewatch updates computed from pre-write state; successful writes now evict the entry.

A device-code dialog dismissed by system back never cancelled the poll (blocking new attempts until the deadline), and a user-cancelled sign-in showed the connection-failed snackbar; both now route through one cancel path.
2026-08-21 19:23:41 +02:00
edde746 d7422c81cb fix(providers): keep reconnects, retained libraries, and Discord presence alive after failures
Companion-remote reconnects derived their intent from a session status the peer overwrites mid-join, so a failed reconnect attempt often never rescheduled; the attempt now carries its own intent flag.

Library loads dropped retained state on failure. A delta load that failed for every requested server removed their libraries while adding nothing, and an in-place refresh where one server succeeded and another timed out replaced the whole list with only the successful response, wiping the failed server's sidebar entries even though it stayed out of the loaded set. Both paths now key retention to the servers that actually responded: unreachable servers keep their entries and are refetched on the next status emission, removed servers still drop.

Discord RPC kept the dead client after a disconnect, making every 30s reconnect a no-op for the rest of the session; disconnect now tears the client down so the timer builds a fresh one. Rapid disable/enable while a connect was still initializing also let the old client's failure dispose the fresh one, so each attempt binds its listeners and cleanup to its own client instance and stands down when it is no longer current.
2026-08-21 19:23:40 +02:00
edde746 7c496413f8 fix(remote): survive app backgrounding by guarding the reconnect cycle and retrying on resume
Backgrounding the Android app or locking the screen near-always dumped the
companion remote back to the device-selection page, forcing a manual
re-connect. Three defects combined: the peer's trailing disconnected status
event knocked the session out of reconnecting and broke the retry chain, the
bounded backoff budget (~31s) burned out against restricted background
networking before the user returned, and nothing retried on resume.

The provider now ignores stale peer status/error events while a reconnect
cycle is active, defers retries while backgrounded instead of consuming the
budget, and on resume retries immediately with a fresh budget - or pings a
nominally-connected session so a dead socket fails into the reconnect path
right away. Authenticated socket errors in the peer now surface as
disconnects like clean closes, keeping both terminal signals on the
reconnect path.

close #2035
2026-08-20 07:10:51 +02:00
edde746 9fe6dbe9f2 fix(explore): add MDBList watchlist source
Connected MDBList accounts were omitted from Explore, so their watchlists could not be selected or displayed. Register the profile-scoped MDBList client as a catalog source with watchlist mutations and search.

close #1888
2026-08-18 13:13:06 +02:00
edde746 5fe1e6dc65 refactor(ui): delete unwired log output, dead notifier filters, and never-passed widget params
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates.
2026-08-17 19:02:25 +02:00
edde746 a7527718df refactor(services): delete dead branches, write-only fields, and test-only cache/service surface
plex_client.selectStreams' allParts=false branch, download_manager's both-branches-true conditional and unscoped getAllPinnedMetadata tail, playback_source_resolver.preferOffline, and update_service's unreachable catches had no reachable path; PlaybackSession.result/streamHeaders, PlaybackContext.clientScopeId, FileInfoStreams.audioStream, PrefsRepairOutcome.settingsReset/sessionsSalvaged, and JellyfinLiveSessionTracker.playSessionId were write-only; PlexConnection.directUrl, PlexServer.isOnline plus its parse-and-persist presence field, SafStorageService.createDirectory, FullscreenStateManager.stopMonitoring/dispose, the play_queue_launcher re-export shim, track-selection's constant params, and PlexApiCache.unpinForOffline/isPinnedRatingKey (matching the earlier Jellyfin removal) were dead API.
2026-08-17 19:02:25 +02:00
edde746 ceab5a5b59 refactor(providers): remove the unreachable init fallback, unread load results, and dead refresh surface
hidden_libraries' constructor always seeds _initFuture, so ensureInitialized's ?? _initialize() fallback could never run (field now late final); _loadLibrariesInternal returned a bool no caller read; OfflineModeProvider.refresh() had zero call sites; ExploreScreen's Refreshable application was dispatched by nobody (only the discover tab and library tabs are dispatched); the discover null-serverId branch stays — the TV spotlight resolver passes null items during initial load.
2026-08-17 19:02:24 +02:00
edde746 3665d416c4 refactor(providers): deduped peer disposal, hoisted discover outcome tail, live server-connection getter
CompanionRemoteProvider memoized peer disposals in an Expando even though CompanionRemotePeerService.dispose() already dedups concurrent and repeat calls — the wrapper now only keeps cleanup failures from escaping teardown, and the test fake mirrors the service's idempotent-dispose contract. DiscoverProvider._loadOnce carried the same outcome/generation/boundary tail copy-pasted in three branches, now one local settlePassOutcome. OfflineModeProvider cached _hasServerConnection in five write sites when it always equalled a two-term expression — now a live getter.
2026-08-17 19:02:20 +02:00
edde746 2e1a7130fc refactor(downloads): one auto-remove-watched core, trimmed offline-watch provider, shared focus-restore mixin
The auto-remove-watched-downloads rule existed twice (DownloadProvider's sweep and OfflineWatchProvider's single-item copy) — the kind/completed/delete/title core now lives once on DownloadProvider, with the watched judgment staying per caller because the offline path fires before metadata can reflect a local mark. OfflineWatchProvider drops five members with no production callers (isSyncing, getPendingSyncCount, isWatched, getViewOffset, getEpisodesWithWatchStatus) plus the sync-service listener that only served them. The downloads screen's three verbatim 'suppressAutoFocus flipped — focus the first item' didUpdateWidget blocks are now one mixin.
2026-08-17 19:02:20 +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 ab199b04cd refactor(profiles): resolve the active profile's Plex token through one shared helper
The job "which Plex token represents the active profile right now" was implemented three times against the same registries — the Discover session supplier, the Seerr token supplier, and UserProfileProvider's settings refresh — and the copies had already diverged on whether a Plex Home profile may fall back to the account token.

resolveActivePlexToken in lib/profiles/active_plex_token.dart now owns the policy: the per-user ProfileConnection token wins when present, else the account-owner token, with an explicit allowAccountTokenForHomeUser flag (true for Discover/Seerr, false for the settings refresh, which must not impersonate the owner). The three call sites keep only their genuine differences.
2026-08-16 16:48:02 +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 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 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 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 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 3364b3c22c fix(startup): keep the platform launch screen behind the loading frame
Since 2.10.0 the app opens on a Flutter-owned startup frame, and that frame
paints an opaque themed Scaffold before any preference is readable. Its
themeMode defaults to system, so the theme comes from platform brightness --
and a TV has no system dark-mode toggle, so Fire TV and Shield report light.
The result was a near-white #F7F7F8 sheet held for the whole gate, from
prefs through Sentry to the database open, over an Android window the
television resource qualifier had already painted black. Before 2.10.0 the
gate ran ahead of runApp and no Flutter frame existed to cover it.

Nothing in the loading frame is worth covering the launch screen for. Android
composites Flutter in TransparencyMode.transparent over a window whose colour
MainActivity already restored from plezy_prefs, so the loading Scaffold is
transparent there and the launch screen carries the launch. Every other
platform composites opaquely with nothing behind Flutter, so they keep
painting their own background.

The spinner and the failure screen still need a colour, and platform
brightness is the wrong one for exactly the devices this bug is about, so the
startup frames now adopt the persisted theme once it can be read. TV
detection has to run before that read: the theme_mode default is TV-aware and
isTVSync answers false until its singleton exists, which would resolve a
fresh Android TV install to the light theme. Both singletons are memoised and
awaited again by the gate. The read is best-effort -- an unreadable store is
the gate's failure to report, not this path's -- and it also stops a startup
failure from rendering as a full-screen white error page on a TV.

darkThemeFor and materialThemeModeFor move onto ThemeProvider so the startup
frames and the provider resolve OLED from one mapping rather than two.

Verified on an Android TV emulator in television/notnight mode, clean install,
cold start: peak frame luma 228 for 78 frames before, 0 frames above 120
after, and the same on a returning launch.

close #1833
2026-08-08 08:29:37 +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 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
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 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 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 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 82d6c5d555 fix(explore): render Plex Discover home shelves
Plex Explore showed only the Watchlist row. `/hubs/sections/watchlist`
answers with placeholder hubs — every entry carries `placeholder: true`,
`size: 0` and no `Metadata` — so `fetchHubs` mapped each one to an empty
page and dropped all of them. That is true no matter what the profile has
watchlisted; the shelves never rendered.

Read `/hubs/sections/home` instead, the section Plex's own web client
renders on its Home > Trending tab, and hydrate each placeholder from its
own key (six at a time). `directory` shelves list browse categories and
`clip` shelves list trailers, neither of which becomes a catalog item, so
they are skipped before spending a request. A shelf that fails degrades to
the ones that succeeded; a pass where every shelf failed still throws.

Discover ignores container offsets on hub keys and truncates with `limit`
instead, so a hub is one page: View All takes the whole shelf in a single
request rather than replaying page one, and hub requests drop `Media` and
`Image` elements the catalog layer never reads.
2026-07-28 05:26:37 +02:00
edde746 78eedd21d3 style: apply dart format to eight drifted sources
`dart format --set-exit-if-changed` over lib and test rewrites these.
The analysis job never reached its formatting step, so the drift went
unnoticed. No behaviour changes.
2026-07-26 23:05:53 +02:00
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00
edde746 60cc983471 feat(downloads): remove playlist sync downloads together
close #1656
2026-07-26 14:58:38 +02:00
edde746 9429a76acc refactor: share search field, auth dialog, and list-download plumbing
The search screens, the out-of-band auth dialogs, the live TV guide and the
list-download paths each carried their own copy of the same shell. Extracts
SearchInputField and PendingAuthDialog and routes the duplicated download
and guide helpers through one implementation.
2026-07-26 06:09:49 +02:00
edde746 4eaf4423a1 refactor: share focus chrome and simplify the TV picker and browse paths
Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
2026-07-26 06:09:49 +02:00
edde746 c68ffe9ed0 refactor: share the toolbar scrim and dedupe playback and download paths
Extracts the repeated toolbar fade into a single ToolbarScrim widget, folds
duplicated request/retry handling in the media server HTTP client, and
collapses the parallel playback-source, download-manager and live TV helper
paths into shared implementations.
2026-07-26 06:09:49 +02:00
edde746 7416327d4b refactor: unify tracker slots, Seerr detail models, and queue launches
- Merge SeerrMovieDetails/SeerrTvDetails into one SeerrDetails model and
  route both detail endpoints through a single request helper.
- Replace the three parallel tracker session/store/rebind-generation
  triples in TrackersProvider with a _TrackerSlot record plus one _rebind
  path.
- Fold the three JellyfinSequentialLauncher entry points onto a shared
  _launchLocalQueue helper that owns loading, abort, shuffle and publish;
  each caller now supplies only its fetch.
2026-07-26 06:09:49 +02:00
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00
edde746 e251273322 feat(downloads): warn about Android background restrictions 2026-07-26 04:24:55 +02:00
edde746 4af77f4696 fix(app): restore playback and state lifecycle contracts 2026-07-25 16:16:04 +02:00
edde746 269bb7a322 fix(playback): recover episode navigation without Plex queues 2026-07-24 08:08:10 +02:00
edde746 b41fb4fe75 fix(ui): harden settings focus and semantics 2026-07-24 03:46:50 +02:00
edde746 43a8fe020d fix(relay): secure reconnect and room ownership 2026-07-24 03:46:50 +02:00
edde746 e0bf66eea8 fix(runtime): harden application service boundaries 2026-07-24 03:46:46 +02:00
edde746 e32fcc2190 feat(explore): add AniList, Simkl, and Plex catalogs 2026-07-20 10:28:31 +02:00
edde746 d5f3e581df fix: eliminate cross-app consistency drift 2026-07-13 23:13:53 +02:00
edde746 e03edad824 fix: prevent async state and platform regressions 2026-07-13 00:03:52 +02:00
edde746 917f6a2839 fix: guard lifecycle-bound state updates 2026-07-12 19:00:28 +02:00
edde746 a561777456 perf: reduce state notification fan-out 2026-07-12 18:59:58 +02:00