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
On the light theme, the detail hero's fallback title was hard-coded
white over a scrim that washes artwork toward the near-white background,
and white-on-transparent clear logos vanished into the same wash on the
detail, home, and TV spotlight heroes.
The title fallback now defaults to the theme foreground with a
background-side halo, matching what the TV hero already did. Clear logos
run through the channel-logo tone remap: light-toned marks recolor
toward the theme foreground on light backdrops (network and downloaded
artwork alike), colored marks keep their pixels, and dark themes render
the original artwork. The backdrop-luminance gate is shared with the
Live TV guide as logoToneTargetFor.
Both anchored popups were small tap targets on phones. AppMenuButton
gains an opt-in adaptiveSheet flag that routes through
showAdaptiveAppMenu: full-width, untitled sheet rows on touch platforms
(phones plus Android TV / tvOS), the same anchored popup on desktop.
Pressing Cmd+, on macOS did nothing: the stock Flutter template left a
disabled Preferences menu item holding the key equivalent, and nothing
in the app handled the chord.
MainScreen now handles Cmd+, (macOS) / Ctrl+, (Windows/Linux) beside
the existing Cmd+F search shortcut, reusing its tab-aware open path. A
SettingsShortcut fallback above the profile navigator covers pushed
content routes, pushing a named settings route; a navigator observer
blocks the chord while settings is already in the stack, and the video
player route keeps ownership of the keyboard. The dead xib menu item
is removed.
close#1909
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
The clock renders through the existing formatClockTime helper driven by
MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour
setting instead of introducing an app preference. It re-arms a one-shot
timer onto each wall-clock minute boundary rather than polling, and
resyncs on resume because a suspended process runs no timers.
The player header is shared by the mobile and desktop/TV controls, so one
insertion point covers every form factor: the player is fullscreen
everywhere, so it never has an OS clock to defer to. Home is the
exception and only gets one on TV, where a leanback app hides the system
clock; a phone status bar and a desktop menu bar already show the time.
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
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
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
Cycling backdrops reach a fallback path only once every rotating path
has failed to load, but every hero passed the rotation-agnostic backdrop
list as the rotation set and the aspect-ordered candidates as the
fallback. One servable wide backdrop was therefore enough to hide the
square background for good, so phone detail and Discover heroes
cover-fitted a 16:9 backdrop into a portrait box instead of showing the
square image Plex supplies.
Give the rotation set the same aspect-aware preference the candidate
list already has: near-square containers rotate the square background
alone and keep the backdrops behind it as fallbacks.
close#1700
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.
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.
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.
The hero dots/pause row was gated on live input mode, so any navigation
key event (Android back key, BT keyboards, gamepad-source noise) hid it
until the next pointer event - on phones it ended up permanently hidden.
Gate on the TV platform instead (issue #600's actual scope); the TV
layout never renders the carousel, so nothing changes there.
The bottom fade lost its guaranteed opaque band when the section-level
gradient was removed (686a61ac): the per-item overlay only reached full
background at the literal last pixel, letting artwork ghost through the
final 15% and read as a hard cut against the content below - worst on
phones, where square hero art is bright at the bottom. Finish the fade
at solid background from the 0.94 stop (~32px band on a phone hero).
Skia's dithered gradient shaders cost ~10ms per full-screen pass on
Mali-class TV GPUs: the two spotlight scrims alone were ~20ms of a 27ms
raster frame, while flat blended quads at the same coverage are ~free.
Bake axis-aligned LinearGradients once into cached 1x1024 premultiplied
strip textures drawn as stretched quads (shader fallback for unsupported
shapes and the first frame). Converted the spotlight, TV detail backdrop,
app-bar scrims, rail bleed, person-card overlay, and the video-controls
scrim, which now also keeps one widget type across hasFrame flips so the
controls subtree survives in-place source switches.
Scripted-browse on a Mali-G31 box: draw p50 27.5ms -> 7.1ms, swap block
20.3ms -> 0.9ms, janky draws 69% -> 2%; screenshots pixel-identical.
Hub moves, focus flips and item selects now flow through the rail focus
model + ListenableSelectors (two headers/cards repaint instead of every
visible card rebuilding). DiscoverScreen memoizes the hubs list, gates
provider notifies on a render signature and caches the rail widget so
no-op refreshes skip the subtree entirely. The whole-rail 0.6 and
inactive-row 0.7 AnimatedOpacity layers are replaced by AnimatedDimScrim
quads, removing the permanent per-frame saveLayers that kept low-end TV
GPUs raster-bound.
Adds context.withFreshWatchState/readFreshWatchState and sweeps the
remaining session-critical readers: context menu actions, folder tree
rows, discover hero, and TV spotlight minutes-left.