Explore showed "Not in your library" for titles the user owns. Two causes:
Plex's /library/all?title= filter is an ordered word-prefix substring match,
so `Oshi no Ko` never found a library titled `[Oshi no Ko]` and a romaji or
localized title was unreachable once the first candidate hit; and a server
that answered slower than the 10s header budget was treated as a dead
endpoint, dropped from the wave, cached as a negative for ten minutes and
cascaded through its stale LAN candidates.
Plex title candidates now go through /hubs/search with includeGuids, the
full-text index behind Plex's own search, which also covers originalTitle.
Both backends search every candidate concurrently and union the id-verified
copies instead of stopping at the first title that hit; the candidate cap
becomes two title families so a sequel can reach its alternate title. Lookup
requests run under a dedicated deadline with endpoint failover off. The
aggregation layer reports per-server failures, the matcher only memoizes
complete positive waves, and the detail screen shows "Couldn't check N
servers" instead of claiming absence.
close#2098
The phone hero's chip rows were spaced 8px apart with 12/6 padding and a
16px gap to the action row, which read loose against the centred layout.
The scores pill and the tappable Rate chip also came out taller than the
plain text chips because their 16px icons pushed past the 13px label
line, so the row had a visible step in it.
Chip geometry now lives in one _HeroChips table: 4px spacing between
chips and rows, 10/5 padding, a 10px (6px on short heroes) gap to the
actions, and every chip sizes its content to a shared 20px box so icons
and text can never make one chip taller than its neighbours.
Landscape phones got a hero at 60% of a short viewport, which shrank the
logo to a sliver and let the overview run under the camera cutout. The
hero now has a floor of one full hero — status bar and back strip,
full-size logo, both chip rows and the action row — and the body sliver
sits in a SliverSafeArea so section text keeps the same horizontal inset
as the hero content above it. The hero's top padding also reserves the
back-button strip explicitly instead of relying on the logo budget.
On phones the movie/show hero hugged the left edge under a full-bleed
backdrop while the new collection page stacks its poster, title and
actions on the centre line, so the two pages read differently.
At widths under the mobile breakpoint the hero now centres the clear
logo (or title fallback), both chip rows and the action row; the hero
height, chip shedding, focus order and back button are unchanged. Wide
heroes keep the bottom-left column — a 400px logo centred in a
tablet-wide hero floats, and the wide collection header is left-aligned
too. TV is untouched.
_buildDetailLogoOrTitle gains an alignment and _buildDetailTitle a
textAlign, both defaulting to the previous values.
Collections opened as a bare poster grid under a plain app bar: no
collection artwork, no summary, no count, so they looked unrelated to
the movie/show and album pages around them (#1493).
The collection screen now leads with the same header the album and
artist pages use: the collection poster (square for music collections)
beside its title, item count, year span, content rating and collapsible
summary, with the Play / Shuffle / Download / Delete row underneath.
The collection's backdrop art washes the header region behind a scrim
that fades to the scaffold background before the grid starts; a
collection with only a poster gets a blurred copy of it instead so the
wash still reads as colour. A transparent title bar fades in once the
header scrolls away, and the circular back button matches the detail
pages. The year span is only shown once every page has loaded so a
partially fetched collection never reports a narrower range.
buildDetailScaffold gains optional `behind`/`above` layers so a detail
screen can paint art under, and chrome over, its scroll view without
re-implementing the overlay-sheet host and back handling.
Verified on Pixel 9a, 10" tablet and Android TV emulators: touch scroll
and pinned title, D-pad up from the grid lands on the action row and
scrolls the header back into view, D-pad back pops, Plex collection with
art and Jellyfin collection without art.
Turning auto-skip off still surfaced a skip button on every intro and
credits marker, so a viewer who wants to watch episodes in full had no
way to silence it. The two auto-skip switches become per-marker
selectors — Off, Show button, Automatic — stored as skip_intro_mode and
skip_credits_mode. Off filters the marker before it becomes current, so
the player behaves as if the server had sent none: no prompt, no
countdown, no TV autofocus, and Back reaches the screen. Flipping a kind
to Off while its prompt is up drops the prompt immediately.
The legacy auto_skip_intro/auto_skip_credits booleans migrate on first
read: true becomes Automatic, false becomes Show button, matching what
each meant before.
close#2138
Watched posters always carried the corner checkmark, and viewers who
find it noisy had no way to turn it off. A new Appearance switch,
Show Watched Indicators (default on), gates the checkmark on every
surface that stamps watch state onto artwork: grid cards, hub rows,
folder tree rows, episode and playlist thumbnails. Progress bars and the
unwatched-count pill keep their own behavior.
close#1998
Apple TV shipped with passthrough opt-in while the E-AC-3/Atmos
sample-buffer renderer was unverified, so Atmos titles played as PCM
until the viewer found the switch. The renderer is now hardware-verified
on real receivers, so the pref defaults on for Apple TV the same way it
does for Android TV. An explicit stored value still wins.
close#1300
Playing a Plex live channel from outside the LAN always came back as a full
re-encode at the server's highest transcode tier (20 Mbps 1080p on the
reporter's server), no matter which quality the app was set to. The Plex live
path never sent a ceiling: a live source has no bitrate the server can check
against its remote-stream limit, so a remote session without a client cap
falls onto the server's own top tier. On the LAN the server remuxes anyway,
which hid the gap.
The live stream path now reads the same saved preset the library path and
the Jellyfin live path (#2198) already honor. Original is unchanged: a remux
(directStream=1) with no ceiling. A capped preset pins directStream=0 and
sends the bitrate limitation clause plus the videoResolution/videoQuality caps
so the encode lands at the chosen tier rather than the server's. The preset is
session state, so a recovery re-tune keeps the cap.
directPlay stays 0: a tuned Plex session is only reachable through the
transcoder's HLS output, so "no re-encode" on live means a remux, not direct
play. Capped sessions use the h264-only TS target because every codec in the
target becomes an encode output once directStream is off, and HEVC into TS is
the #1859 corruption; the broadcast-codec live target remains the Original
remux menu.
close#2072
A skip back of 10-20 s on a freshly tuned Plex Live TV channel landed on
or after the frame being shown, while the same skip worked once the viewer
had already time-shifted. Offset-less opens (initial tune, retry, channel
zap, subtitle switch at the edge) pinned stream position zero to wall clock
at open time, but the transcode starts behind real time by tuner ingest and
encoder start-up latency; offset opens were exact because the server
defines their origin.
Every /:/timeline response already carries the playback transcode as the
top-level TranscodeSession next to the CaptureBuffer wrapper; its timeStamp
is the epoch of stream position zero, which Plex's own client uses as the
playhead origin. The parser used to return only the capture window. It now
returns both, and each heartbeat re-anchors the clock on the playback
origin unless the response predates the current open or an open is still
calibrating. Offset-less opens seed a provisional anchor from the capture
edge instead of wall clock, and the live-edge threshold is widened to 15 s
so a live stream trailing the edge by normal latency still reads as live.
Three findings from the #2239 investigation.
Discover armed "focus the browse rail when it has hubs" on its first
load and kept it armed indefinitely while the rail had nothing to show.
Hubs landing minutes later — reconnect, push refresh — then yanked the
remote off a sidebar item the user had since moved to. The claim now
lapses once focus sits on a control outside the screen; a bare content
scope (the startup state) still lets the rail take its initial focus.
On Apple TV a Menu press that reaches MainScreen at the home root is
consumed silently by design (the engine normally hands root Menu to
UIKit). That branch is what turned a focus slip into a "dead remote"
report, so it now logs passthrough state, picker state, and the primary
focus label.
SystemShelfService.debugReset awaited the mutation tail queued by the
previous widget test, whose FakeAsync zone ends without flushing the
microtasks that settle the chain, so any second test in a file using it
hung in setUp. It now drops the queue synchronously; the #2239 session
regression test moves back beside the profile-switch test it belongs
with.
On tvOS the profile picker shown after a background/standby resume lost
its focus highlight and the Siri Remote went dead until a force-quit.
Android TV hit the same shape with the player (#2034).
The picker and PIN dialog are pushed on the root navigator, above the
nested profile-session navigator. Nested routes still report
`isCurrent == true` under that cover, and Flutter leaves a covered
route's scope focusable, so any focus self-heal under MainScreen —
the sidebar reveal on an offline/online flip, a Libraries grid reload,
the TV browse rail — won the remote behind the picker. With menu
passthrough pinned off while the picker is up, Menu was swallowed too.
Restore the invariant once at the navigator boundary instead of at
every reclaim site: CoveredRouteFocusBoundary wraps the session in an
ExcludeFocus keyed on the root route's currency, so requests below a
cover are no-ops, and re-requests its own scope on uncover so focus
walks back to the leaf that had it (the covering route's pop culls the
excluded scope from the route scope's history before the exclusion
lifts, which is why Flutter's own restoration cannot). This makes
isRouteChainCurrent redundant; the player's guards return to plain
nested currency.
close#2239
A Seerr instance behind forward-auth (Authelia, Authentik, Cloudflare
Access) or HTTP Basic failed setup with "No Seerr instance at … (HTTP
200)": the probe followed the proxy's redirect to its login page and
decoded the HTML as "not JSON". A live session that later hit the same
wall took the proxy's 401 as Seerr's, re-authed through the wall, failed,
and unlinked a perfectly good stored session.
Seerr's API never redirects and always answers with JSON, so a 3xx or a
non-JSON 401/403 is the proxy talking. Seerr requests no longer follow
redirects, that shape maps to a SeerrProxyException with a message that
says what to do, the probe reports it in place of "no instance", and the
client neither re-auths nor unlinks on it.
close#1877
With ambient lighting enabled, ASS subtitles anchored to a corner (and PGS
bitmaps) rendered against the whole screen instead of the video: the effect
stretches mpv's frame to the window with video-aspect-override and lets the
shader composite the real picture inside it, and mpv places subtitles against
that stretched frame.
The pinned libmpv now carries --sub-video-rect-aspect (edde746/mpv-build
ec08018), which makes mpv derive its subtitle margins from the picture's real
aspect inside the displayed rect. AmbientLightingService hands it the video
aspect before overriding the frame, so a libmpv without the option refuses
cleanly with the frame untouched, and resets it on disable.
Verified on a Pixel 7 (2400x1080) with a 16:9 file whose OP credits are
\pos-anchored: subtitle extents are pixel-identical with ambient lighting on
and off, and mpv reports OSD borders l=240 r=240 for the overridden frame.
close#2120
On a phone in portrait the video controls header is too narrow: the clock crowds the back button, title, and track/chapter controls. Hide it there; landscape, tablets, desktop, and TV keep it.
Phones were locked to portrait outside the video player, and the mobile
shell had no answer for a wide, short viewport: car head units (which use
the mobile layout) and tablets in landscape spent their scarce height on
a bottom navigation bar and a 500dp hero.
In landscape the mobile shell now puts the bottom bar's destinations on a
leading Material NavigationRail (MobileNavigationRail) and keeps every
other mobile layout decision as it is. The rail keeps the bottom bar's
extras: the offline reconnect affordance as its leading action and the
long-press library quick picker on the Libraries destination. Labels
follow the existing nav-bar-labels setting but give way automatically
when the destinations cannot fit (a landscape phone is ~410dp tall), and
the rail scrolls as a last resort. The content beside it drops the leading
system inset the rail already absorbs.
Phones now allow every orientation; OrientationHelper.restoreDefaultOrientations
is context-free and the player exit path uses it instead of its own copy
of the phone lock. The Discover hero fills the viewport in mobile landscape
and compacts its logo and bottom offset when short, so its content stays
below the top bar.
The music mini-player learns a start inset (MiniPlayerInsetController.setNavInsets)
so it floats beside the rail in landscape and above the bar in portrait;
route suspension zeroes both.
Known follow-up: the media detail header is not yet laid out for a
landscape phone (logo runs under the status bar and back button).
On Android Automotive head units with a left- or right-positioned system
bar, the whole edge of the app rendered underneath it: Discover's title,
hero text and first card were clipped, and the player controls sat under
the bar. Play's car app quality AR-1 requires interactive UI to stay clear
of system bars.
The platform reports the bar as a systemBars inset and Flutter forwards it
as MediaQuery.padding.left/right, but the mobile screens only honour the
top and bottom insets. On the emulator this only showed after the first
video session (the player's edge-to-edge restore stops the DecorView from
fitting a navigation-bar-typed left bar); on Android 14+ CarSystemUI the
left bar is a status-bar-typed inset, so it is under the bar from launch.
Car bars are opaque and may be impossible to hide, so nothing is worth
drawing under them: FormFactorScale now wraps the automotive surface in a
horizontal SafeArea inside the scaled MediaQuery, consuming the insets
once for every route.
Reproduced and verified on the API 33 automotive image with the
com.android.systemui.rro.left overlay enabled for the foreground user.
Downloaded episodes never showed the partially-watched progress bar, so a
user resuming offline could not tell which episode they had started.
Offline playback already records progress: the tracker queues a progress
action and emits a WatchStateNotifier event, and DownloadProvider hydrates
those rows back into WatchStateStore on load. EpisodeCard consumed that
state via withFreshWatchState but then passed progressAvailable: false to
WatchedIndicator for offline cards, a guard left over from before offline
progress tracking existed. Downloaded movie cards (MediaCard) never had the
guard and already showed the bar.
Drop the guard and the now-unused WatchedIndicator.progressAvailable flag.
close#2236
A movie whose first chapter is titled "Opening Credits" or "Introduction"
got a Skip Intro button that skipped the whole chapter — several minutes
of picture. Detected intros are short; only Plezy's own chapter-title
fallback produced these. Cap chapter-derived intros at three minutes.
Server-supplied markers and credits chapters are unaffected.
close#2235
Apple TV (and any suspended process) wakes with dead keep-alive sockets in
the HTTP pool. The first request after resume — the health probe — failed
with a connection error, and the failover cascade treated that as a dead
endpoint: it validated the remote candidate, switched to it, and persisted
it as preferred. Nothing walked the session back to the local endpoint,
because the only re-optimization trigger is a connectivity event and a
same-interface sleep/wake never produces one. Only killing the app fixed it.
FailoverHttpClient now runs the existing candidate trust gate against the
current endpoint on a connection error and retries in place when it answers;
timeouts and 5xx still cascade directly. MultiServerManager gains
reoptimizeDemotedServers, called from the resume probe, which re-races any
online Plex server sitting on a remote or relay endpoint while a local one
is published.
close#2056
Requesting an anime series with REQUEST_ADVANCED routed it to the standard
Sonarr profile and root folder. The sheet seeded its pickers from
activeProfileId/activeDirectory only, then posted them as explicit overrides,
which beat the anime defaults Seerr would otherwise apply at approval. A user
without the permission sent no overrides and was routed correctly.
The service DTOs now carry activeAnimeProfileId, activeAnimeDirectory,
activeAnimeLanguageProfileId, activeTags, activeAnimeTags and the instance's
tag list. The sheet detects the TMDB anime keyword on the TV details it
already fetches and seeds each picker the way Seerr's web requester does:
the anime value when the series is an anime and the instance configures one,
else the standard value. Options carry the "(Default)" marker and an anime
series shows the same note as the web UI.
Tags are editable: an inline checklist under the advanced pickers, seeded from
the instance's (anime) default tags and posted as `tags`. It is inline rather
than a nested sheet page because the host builds only the top page, so a push
would dispose the sheet and drop the season and picker selections on the way
back.
close#2215
PlexConfig.acceptJson defaulted to true in both constructors and
travelled through copyWith, but no production or test caller ever passed
false, and every Plex path decodes JSON, so the header was conditional on
a flag that could not be off.
Emit `Accept: application/json` directly and drop the field. Headers on
the wire are unchanged.
The video controls' key handling redefined the directional, horizontal
and select key sets privately, duplicating DpadKeyExtension with
identical members; the private select helper then handed the event to
handleOneShotSelect, which classified it again with the canonical
isSelectKey.
Use isDpadDirection, isLeftKey/isRightKey and isSelectKey and delete the
copies.
The two setup acknowledgements ran the same sequence twice: accept and
validate the response, handle the same two exceptions, install the peer
roster, publish connected state, and complete the setup completer. Only
the log line differed, plus a roster loop that skipped the
already-present check.
One case handles both, using the guarded add so the roster publishes
idempotently for either acknowledgement.
LiveTvDvr parsed 21 DVR fields plus nested Setting and raw Device lists
on every Live TV availability refresh, while the app reads only the key,
the three lineup labels, and the enabled channel mappings. The rest
describe tuner hardware and DVR setup, which Plezy does not implement.
Keep key, lineup, lineupTitle, lineupURL and channelMappings, and
channelKey/enabled on ChannelMapping. The tests keep the root
channel-mapping fallback, malformed-sibling tolerance and flexible
`enabled` parsing.
AccountPreferencesController subscribed to watchConnections() and
watchForProfile() and threw both payloads away, then re-read the same two
tables through list() and listForProfile() on every resolve - four
one-shot queries during attach alone, each re-decoding rows and revealing
credentials the watcher had already decoded. The generation counter
discarded stale results but never the queries.
Retain the rows the subscriptions deliver and resolve from them, clearing
the profile rows whenever the watched profile changes so one profile's
rows can never pair with another's. Until both watchers have delivered,
the resolve task parks on that first delivery, so ensureActiveLoaded
still has something to wait for.
The audio-only core reused the video core's property registrations
wholesale, so every music session observed track-list,
demuxer-cache-state, audio-device-list, audio-device, secondary-sid,
seekable, paused-for-cache, volume, speed, aid and sid. libmpv sends an
initial notification plus an event per edge for each one, and three of
them decode structured nodes into track and device models nothing on the
music path reads.
Register time-pos, duration, pause, eof-reached and playlist-pos there;
the video path keeps the full set. Also drop the post-initialization
`gapless-audio` write: every native audio core already sets it before
mpv_initialize, which is where libmpv wants initial configuration.
The audio ladder still carried a per-item language tier between the
server's selected stream and the account preference. For Jellyfin and
Emby the value mapped to it was `PreferredMetadataLanguage`, which is the
library's metadata-scraping language - it inherits from the parent, the
library options, or the server config and says nothing about playback -
so on any source whose selected/default stream did not map to a native
track it silently picked audio in the scraping language instead of the
user's preferred one. Plex's item-level `audioLanguage` reached the same
tier, after 2dfebf32b and 7ae293631 had already settled that the server
folds every preference level into the stream `selected` flags.
Drop the tier and the plumbing that only fed it:
TrackSelectionPriority.perMedia, MediaItem.audioLanguage and
subtitleLanguage, PlexMetadataDto.audioLanguage/subtitleLanguage/
subtitleMode, and the Jellyfin PreferredMetadataLanguage mapping. The
order is now carried selection, server-selected or default source
stream, account preference, native default. Persisted cache entries
holding the old keys decode unchanged.
The action row's track status was a button that opened a pre-play
chooser, but on TV the hero describes whichever episode the rail has
focused, and focusing the row hands the hero back to the show, so there
was never a per-episode target for the control to act on. It also sat
inline with the buttons in muted grey, reading as a sixth, low-contrast
action.
The status is now plain text at the row's far end, right-aligned to the
hero's text column, in the hero's own ink, off the focus path. The
chooser sheet, the per-screen choice, and the Play pass-through go with
it, along with the overlay-host and track-tile changes that only served
the chooser.
The detail screen gave no hint of what pressing Play would do with the
file: which audio track and which subtitles the player would start with,
or the picture format. Changing tracks meant starting playback first.
The action row now ends with a status line — picture labels, then the
audio and subtitle rows the player will select — computed by the player's
own selection ladder (TrackSelectionService) over the server's stream
rows, so it agrees with what the player then does. Select opens a
pre-play chooser with the track sheet's two columns; Play and rail
activation pass the pick to the player as its navigation-tier preference,
and a pick carries across episodes by semantics, forced-ness included.
Plex listings carry no stream rows, so a focused episode is probed once,
debounced, and cached for the screen.
Along the way: MediaStream keeps the server's `selected` apart from the
container `default` flag (they were OR'd, so the account's pick was
indistinguishable from the default track); the overlay sheet host no
longer overrides a row that claimed focus itself.
The TV show detail page spent its hero rows on information that never
changes while browsing episodes (the show's genres, the file's stream
quality) while the rail cards repeated the show name on every card and
pushed the episode title into a truncated subtitle. The Play button also
kept naming the on-deck episode while the hero described the focused one.
Rail cards inside the show now headline the episode title with
`S1 E3 · 23min` as subtitle; genres and quality labels move from the hero
to the details sheet (which now also names the episode and takes the show's
genres explicitly); Play follows the hero's episode and falls back to
on-deck when nothing is focused. The freed rows go back to the logo and the
third description line.
close#2217
On the TV detail screen the hero follows the focused episode: its metadata
line and description update, but the episode's own title is nowhere in the
hero. The only copy is the rail card's subtitle, which truncates for most
titles, so the viewer cannot read what they are about to play.
Add a title line between the show logo and the episode metadata line,
reserved in the hero's height budget like the other rows, announced in the
hero's accessibility label, and inside the focusable info block that opens
the details sheet. The hide-spoilers path no longer substitutes the episode
title for a missing summary, since the title line already names it.
close#2217
The remove button's default 48px tap target set each row's height, adding
about 12px of dead space around every 36px field on top of the row gap.
Constrain the button to 36px so the field decides the row height, and trim
the field padding and row gap.
On Android TV the mpv.conf editor used Plezy's own on-screen keyboard
instead of Gboard, and the only way to get a long conf onto a TV was to
retype it key by key (#2232). The system keyboards cannot host the
multiline editor: FireTVIME has no newline key, and both FireTVIME and
Gboard could wipe a pre-filled multiline field on the first keystroke
after the first-show restartInput.
On TV, render one single-line native field per line, with a line-number
gutter, a remove button per row and an Add line button. Rows open their
keyboard on Select only, so D-pad traversal does not raise it. A value
arriving with newlines (a paste from a phone remote) is split into rows
and typing continues on the last one; the rows are keyed so the split
does not rebind another row's input host. The keyboard's action key does
not insert a row: the app never learns which action the IME sent, and
FireTVIME reports Back as `previous`, so an insert-on-action editor kept
opening rows on every Back. Completion hands focus down instead — the
next row, or Add line after the last one.
TvTextInputController gains focusAndOpenTextInput() so a row the app
creates (Add line, paste split) can be typed into without a second
Select; it activates after the focus request lands, since the host's
focus sync deactivates an unfocused field.
Verified on a Google TV box (Gboard, Android 14) and a Fire TV Stick
(FireTVIME): rows keep their text on open, Back closes the keyboard
without side effects, edits persist across leaving the screen. Phone,
desktop and pointer platforms keep the text-area editor.
close#2232
Plex Web reads only the `selected` flag PMS stamps on each stream; the
account's autoSelectSubtitle is applied server-side when that flag is
computed. Plezy's Priority 2 already trusts the same flag, but once
playback read AccountPreferences the Plex mode became non-null and could
reach the client-side profile pass whenever there was no media info or no
embedded subtitle streams - forcing subtitles off for Plex's 0 ("manually
selected") or on for 2, over a decision the server had already made.
TrackSelectionService now skips the profile subtitle mode for Plex items;
MediaBrowser keeps it because those servers do not pre-select.
UserProfileProvider kept a second cache of the active user's audio/subtitle
defaults beside AccountPreferencesRepository, re-implemented the active
profile/connection listener wiring and Plex Home token resolution, and for
Plex Home profiles bypassed the shared cache entirely (fetching /user with
its own client), so a write in the Account preferences screen never reached
Home-profile playback.
The controller now resolves the active account (the head of its sorted
account list), loads it through the one repository on attach, on profile
switch, and when the account's token changes, and exposes it as
activePreferences; ensureActiveLoaded replaces initialize() for the startup
gate. Playback, offline waiting, and logout storage clearing read the
controller directly; UserProfileProvider, the per-backend user-profile DTOs,
PlexAuthService.getUserProfile, and JellyfinClient.fetchUserProfile are
deleted.
The ranked defaultAudioLanguages/defaultSubtitleLanguages lists go with
them: Plex Web never reads them, PMS applies them when it stamps `selected`
on streams, and Plezy already trusts that flag ahead of the profile. They
only refined a best-effort audio fallback for Plex items with no media
info, which the primary language covers; on that path a second preferred
language is no longer tried before the file's default track.
Plex Home profiles now read /user/profile with the switched token through
the same cache as local profiles.
The memo-lookup, scroll-budget, skeleton-scheduling sequence was copied
in the paginated grid, the browse tab, and hub rows; SkeletonUpgradeScheduler
now owns it as realizeBudgeted with per-site skeleton and card builders,
keeping the hub's focus salt and padded skeleton.
The video player screen held fourteen nullable subscription fields and
listed them twice when cancelling; they are two lists (player re-wire,
media controls) snapshotted and cleared before cancellation, with the
screen-lifetime Apple TV and sleep-timer subscriptions left as named
fields. Live seek and subtitle switches open through one helper, and the
initial live open applies the shared stream options instead of an
inline property write.
The 32-byte reconnect token and its 43-character base64url shape were
pinned by hand in both server/main.go and the Dart peer service, and the
supported-version check was spelled out three times in Go. The spec now
carries reconnectTokenBytes and the generator emits the token size,
encoded length, and validator for Dart plus the size and
supportedRelayProtocolVersion for Go, failing before writing either
target when the key is missing. The last handwritten error code in lib/
('not_in_room') uses the generated constant, and releaseSession's
in-flight join is a FutureCoalescer.
MDBList and Trakt repeated the rating type, entry matching, and request
body construction; tracker_rating_match.dart now owns them with the
service label passed in. The collection detail screen re-implemented
fetchAndQueueListDownload minus its cellular-blocked branch and now uses
it, and the three showDownloadOptionsAndQueue call sites share
queueDownloadWithFeedback, so the detail action buttons surface a queue
failure as an error snackbar instead of an unhandled async error.
Seven sheets hand-rolled the Column(min) + BottomSheetHeader + Flexible
tree that BottomSheetPageScaffold already produces; each now passes its
header arguments to the scaffold. No onBack is supplied, so the widget
tree, height, and focus behaviour are unchanged.
Three MediaBrowser pagination loops re-implemented drainPages; it gains an
onPage hook (fires after intermediate pages only) so the two
progressive-render loops fit as well as the episode queue. Five
handwritten FIFO future tails (companion lifecycle, Seerr persistence,
tracker write lock, companion host-auth commit, Watch Together message
routing) become SerialFutureQueue, and the companion peer service's
disconnect/dispose in-flight joins become FutureCoalescer.
Loading the downloads list re-resolved the backend and profile scope for
every row, and the provider's bulk lookup only tried the public key, so
every MediaBrowser leaf fell back to a per-item lookup that resolved the
scope again (twice more for episodes and tracks). Startup was therefore
O(downloads x profile bindings) in database selects.
getAllPinnedMetadata now resolves each distinct server once, covers
queued and in-progress rows too, and returns the scope snapshot beside
the items; hydration tries the exact compound key, then the public key,
then the existing per-item fallback, and reuses the snapshot for parent
rows. findProfileScopeId prefetches bound connections in one select while
keeping binding precedence. A provider test pins the select count as
constant across download count.
Deleting a container also read the row, resolved metadata, and queried
its children twice; deleteDownload now loads them once and threads the
snapshot through the file deletion, keeping the cancel/progress/file/row
order intact.
ConnectionBootstrap kept a private cache read/fetch/write path for Plex
Home users beside PlexHomeService, which already owns refresh
coalescing, generation checks, durable writes, and publication. The
bootstrap now keeps only the one-time legacy cache migration, then
reloads the service from storage or asks it to refresh, and reads the
hydrated users from the service snapshot. A failed hydration clears the
service's empty cache slot before the migrated account is removed.
The timeline bar rendered whole seconds but rebuilt on every ~250ms
position emission, so three of four rebuilds produced identical output.
It now derives a distinct position-seconds stream, the same pattern
ContentStrip uses for its chapter index, and rebinds only when the player
changes; streamStartEpoch changes still apply on the next build.
The movie/show/season/episode/artist/album/track to 1/2/3/4/8/9/10 table
was repeated in the query translator (forward and inverse), collection
creation, and the metadata-edit adapter; PlexMetadataType now owns
forKind/kindFor and each caller keeps its own unsupported sentinel.
getMetadataWithImagesAndOnDeck and _getMetadataWithImages shared the
endpoint, cache key, query flags, first-row parsing, and library-section
stamping and differed only by includeOnDeck; they are one private typed
fetcher returning (metadata, onDeckEpisode), so fetchItemWithOnDeck no
longer reads a string-keyed dynamic map.
Capping quality against an Emby server on macOS (or any device with an
AV1 decoder) failed with HTTP 500: Emby takes the first entry of the
TranscodingProfile codec list verbatim and has no AV1 encoder. Jellyfin
rotates admin-disabled codecs to the back, which is what the AV1-first
list relied on. Gate AV1 on that dialect behavior.
close#2230
On a dual-stack network Plezy pinned API traffic and the notification
websocket to IPv4 while the native Plex apps and the media stream used
IPv6. dart:io looks up A before AAAA and connects in arrival order, so
the AAAA address was never tried when the IPv4 path answered within
250 ms.
Install an HttpClient.connectionFactory that does one lookup (keeping
the resolver's RFC 6724 order), interleaves families per RFC 8305 and
races candidates 250 ms apart. The task is returned before the lookup
starts so connectionTimeout and cancel cover resolution. TLS is done in
the factory since the SDK skips it once one is installed; proxied
requests stay plain. The library-event and companion-remote websockets
get the same client because WebSocket.connect otherwise builds its own.
Supersedes #2233.