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
181 lines
7.1 KiB
Dart
181 lines
7.1 KiB
Dart
/// Static capability flags advertised by a [MediaServerClient]. UI consults
|
||
/// these to gate feature affordances per server (e.g. hide Live TV when no
|
||
/// connected server supports it).
|
||
///
|
||
/// These describe what the *backend kind* supports in this app's current
|
||
/// implementation — not necessarily what the wire protocol can do. As more
|
||
/// Jellyfin features are wired in over time, the corresponding flags flip
|
||
/// without changing call sites.
|
||
class ServerCapabilities {
|
||
/// This backend kind has a Live TV / DVR API the app can talk to. Whether
|
||
/// a *specific* server has Live TV configured is a runtime concern —
|
||
/// [MultiServerProvider.checkLiveTvAvailability] probes each server and
|
||
/// only those with channels surface in [MultiServerProvider.liveTvServers].
|
||
final bool liveTv;
|
||
|
||
/// Backend has a recording/DVR API wired in this app. Channel listing is
|
||
/// gated by [liveTv]; this flag enables the additional recordings/scheduling
|
||
/// UI. Plex serves `/media/subscriptions`; Jellyfin and Emby adapt
|
||
/// `/LiveTv/Timers` + `/LiveTv/SeriesTimers`.
|
||
final bool liveTvDvr;
|
||
|
||
/// Server can transcode video.
|
||
final bool videoTranscoding;
|
||
|
||
/// Server provides curated recommendation hubs (Plex Discover). Jellyfin
|
||
/// returns synthesized hubs but with sparser categorisation.
|
||
final bool richHubs;
|
||
|
||
/// Numeric ratings (Plex 0–10 via [Item.userRating]). Jellyfin has no
|
||
/// numeric user rating, so star sliders should be hidden.
|
||
final bool numericUserRating;
|
||
|
||
/// Per-user favorite flag ("heart") on media items. Jellyfin exposes it via
|
||
/// `/UserFavoriteItems/{itemId}?userId=...`; Plex has no equivalent.
|
||
final bool userFavorites;
|
||
|
||
/// Hide an item from Continue Watching without changing watch state or
|
||
/// playback progress. Plex exposes this directly; Jellyfin does not.
|
||
final bool continueWatchingRemoval;
|
||
|
||
/// External subtitle search/marketplace (Plex `/library/metadata/{id}/subtitles`).
|
||
/// Hides the "Search subtitles" affordance when false.
|
||
final bool externalSubtitleSearch;
|
||
|
||
/// Server exposes metadata edit endpoints. Hides edit affordances when false.
|
||
final bool richMetadataEdit;
|
||
|
||
/// Server can supply thumbnails for the player's seek-bar scrub preview.
|
||
/// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite
|
||
/// sheets. Both backends are wired through [ScrubPreviewSource]; the flag
|
||
/// gates whether the player attempts the load at all.
|
||
final bool scrubThumbnails;
|
||
|
||
/// Library section exposes a folder hierarchy. Plex uses
|
||
/// `/library/sections/{id}/folders`; Jellyfin uses direct-child
|
||
/// `/Items?ParentId=...&Recursive=false` queries.
|
||
final bool folderGrouping;
|
||
|
||
/// Server can build an "instant mix" / radio track list from a seed item.
|
||
/// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues
|
||
/// (`POST /playQueues?type=audio&uri=...station...`).
|
||
final bool instantMix;
|
||
|
||
/// Server pushes library-content change notifications over a websocket the
|
||
/// app can subscribe to (#1646). Plex: `/:/websockets/notifications`
|
||
/// timeline entries; Jellyfin/Emby: `LibraryChanged` on the session socket.
|
||
/// Whether a *specific* server's socket is reachable (reverse proxies may
|
||
/// not upgrade) is a runtime concern handled by [LibraryEventService]'s
|
||
/// silent degradation.
|
||
final bool libraryChangeEvents;
|
||
|
||
const ServerCapabilities({
|
||
this.liveTv = false,
|
||
this.liveTvDvr = false,
|
||
this.videoTranscoding = true,
|
||
this.richHubs = false,
|
||
this.numericUserRating = false,
|
||
this.userFavorites = false,
|
||
this.continueWatchingRemoval = false,
|
||
this.externalSubtitleSearch = false,
|
||
this.richMetadataEdit = false,
|
||
this.scrubThumbnails = false,
|
||
this.folderGrouping = false,
|
||
this.instantMix = false,
|
||
this.libraryChangeEvents = false,
|
||
});
|
||
|
||
/// Defaults for a fully-featured Plex server.
|
||
static const ServerCapabilities plex = ServerCapabilities(
|
||
liveTv: true,
|
||
liveTvDvr: true,
|
||
videoTranscoding: true,
|
||
richHubs: true,
|
||
numericUserRating: true,
|
||
userFavorites: false,
|
||
continueWatchingRemoval: true,
|
||
externalSubtitleSearch: true,
|
||
richMetadataEdit: true,
|
||
scrubThumbnails: true,
|
||
folderGrouping: true,
|
||
instantMix: true,
|
||
libraryChangeEvents: true,
|
||
);
|
||
|
||
/// Defaults for a Jellyfin server.
|
||
///
|
||
/// `videoTranscoding` is `true` — `JellyfinClient.getPlaybackInitialization`
|
||
/// negotiates via `POST /Items/{id}/PlaybackInfo` and uses the server's
|
||
/// `TranscodingUrl` when a non-original quality preset is selected.
|
||
///
|
||
/// `liveTv` is `true` because Jellyfin exposes `/LiveTv/Channels` and
|
||
/// `/LiveTv/Programs`; `liveTvDvr` rides the timer APIs
|
||
/// (`/LiveTv/Timers`, `/LiveTv/SeriesTimers`).
|
||
static const ServerCapabilities jellyfin = ServerCapabilities(
|
||
liveTv: true,
|
||
liveTvDvr: true,
|
||
videoTranscoding: true,
|
||
richHubs: false,
|
||
numericUserRating: false,
|
||
userFavorites: true,
|
||
externalSubtitleSearch: false,
|
||
richMetadataEdit: true,
|
||
scrubThumbnails: true,
|
||
folderGrouping: true,
|
||
instantMix: true,
|
||
libraryChangeEvents: true,
|
||
);
|
||
|
||
/// Defaults for an Emby server.
|
||
///
|
||
/// `continueWatchingRemoval` is the one flag where Emby is ahead of Jellyfin:
|
||
/// `POST /Users/{uid}/Items/{id}/HideFromResume` drops an item from Continue
|
||
/// Watching while keeping its resume position, and Jellyfin 10.11 has no
|
||
/// equivalent route.
|
||
///
|
||
/// Scrub thumbnails take a different transport than Jellyfin's: Emby has no
|
||
/// `Trickplay` item field or sprite-sheet route, so the player loads a
|
||
/// Roku-format BIF from `/Videos/{id}/index.bif` instead — the same wire
|
||
/// format Plex serves, parsed by the same `BifThumbnailService`. Emby only
|
||
/// fills the endpoint once its own preview-extraction task has run; a server
|
||
/// that has not generated frames answers with a header-only BIF, which
|
||
/// parses to zero frames and keeps the seek-bar tooltip suppressed.
|
||
static const ServerCapabilities emby = ServerCapabilities(
|
||
liveTv: true,
|
||
liveTvDvr: true,
|
||
videoTranscoding: true,
|
||
richHubs: false,
|
||
numericUserRating: false,
|
||
userFavorites: true,
|
||
continueWatchingRemoval: true,
|
||
externalSubtitleSearch: false,
|
||
richMetadataEdit: true,
|
||
scrubThumbnails: true,
|
||
folderGrouping: true,
|
||
instantMix: true,
|
||
libraryChangeEvents: true,
|
||
);
|
||
|
||
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
|
||
/// which Plex probes per server (`PlexClient.capabilities`) — so that is the
|
||
/// only override this type needs. Widen the parameter list if another flag
|
||
/// ever becomes a runtime probe.
|
||
ServerCapabilities copyWith({bool? videoTranscoding}) {
|
||
return ServerCapabilities(
|
||
liveTv: liveTv,
|
||
liveTvDvr: liveTvDvr,
|
||
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
|
||
richHubs: richHubs,
|
||
numericUserRating: numericUserRating,
|
||
userFavorites: userFavorites,
|
||
continueWatchingRemoval: continueWatchingRemoval,
|
||
externalSubtitleSearch: externalSubtitleSearch,
|
||
richMetadataEdit: richMetadataEdit,
|
||
scrubThumbnails: scrubThumbnails,
|
||
folderGrouping: folderGrouping,
|
||
instantMix: instantMix,
|
||
libraryChangeEvents: libraryChangeEvents,
|
||
);
|
||
}
|
||
}
|