With the mpv backend on Android, every subtitle change cost two dropped
video frames on a Fire TV Cube (36 per minute with PGS SDH subtitles, 15
with srt, #2240) and accounts for the residual drops on the Shield in #2202,
whose sessions also had subtitles on. The diagnostic build showed the drops
in pairs: a frame handed over on time, then the video thread waking 90-100
ms late. vo_mediacodec rasterized subtitles and blitted them into the OSD
Surface on that thread, and locking the Surface waits CPU-side for the
compositor - dequeue plus the release fence, one or two vsyncs, 42-83 ms at
23.976 Hz. Kodi, VLC 4 and ExoPlayer all keep subtitle output off the
presentation thread.
Move the native pin to edde746/mpv-build@88c1b135e7: the whole OSD stage
runs on its own thread, fed with the video pts at every flip, so the video
thread never waits on the compositor or on libass. Subtitles latch on the
same vsync as the video in the common case and one later when the lock has
to wait. Shield on vo=mediacodec with ASS subtitles: no video-thread stage
over 10 ms and 0 drops across five 150 s runs, subtitles verified on
screen; DTS-HD MA 4K unchanged at 0 drops. The pin keeps the #2202
diagnostics and adds VO stage timing to them. Apple, Linux and Windows
assets are unchanged.
The smoothed passthrough clock from aeb06c2c9 (edde746/mpv-build 0105)
still let the Shield in #2202 drop a frame every 70 s or so on TrueHD and
DTS-HD MA. The average handles head jitter completely - on a Shield with
the raw head artificially held for 150 ms it went from 410 dropped frames
in 200 s to 0 - but it regresses on stalls: when the head freezes (device
underrun, rebuffer, a HAL that stops updating) while the wall clock runs,
the estimate runs past what was written, (written - head) wraps, and
getLatency reports 0 - the core sees the whole device buffer vanish in one
step and drops video. With the AO starved for 400 ms every 15 s that was 77
drops in 170 s against 8 with the raw clock, and a Box R 4K Plus hit the
wrap 361 times in four minutes of ordinary DTS-HD playback.
Move the native pin to edde746/mpv-build@a58f0c1336: the estimate is
clamped to the written position and its window restarted on overshoot, and
between reports it may deviate from "last report + elapsed" by at most 10%
of the elapsed time unless it is a second or more off - the two guards
media3's AudioTrackPositionTracker keeps over the same average. Starvation
run: 9 drops, no wrap; real HAL with pauses and seeks: 0 drops. The pin also
carries verbose-level diagnostics for this issue (per-drop timing in vo.c,
passthrough clock anomalies and a 10 s summary in ao_audiotrack) so the
next log from the reporter pins whatever remains. Apple, Linux and Windows
assets are unchanged (their content keys did not move).
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
ktlint's spacing-between-declarations-with-annotations and
-with-comments rules failed on android/libmpv/build.gradle.kts, so
scripts/format_native.sh --check - and with it scripts/ci_checks.sh -
reported a failure on an untouched file.
Add the blank line before the @Suppress'd mpvAssets declaration and
before the comment above stagedArchiveName.
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.
Linux and Windows each wrote the same eleven pre-initialize mpv options
(keep-open, idle, input disabling, OSC, ytdl, audio fallback, and the
audio-only set); ApplyCommonStartupOptions in mpv_player_common.h owns
them once, with the ytdl security rationale kept in one place. Rendering,
windowing, HDR, and log-level options stay per platform.
The Android JNI event thread forwarded every mpv event to Kotlin, where
MpvEvent modelled ten variants but MpvPlayerCore consumes four. The
native switch now forwards only START_FILE, FILE_LOADED, and
PLAYBACK_RESTART (END_FILE keeps its own path), and the unused variants
are gone.
windows/CMakeLists.txt repeated the mpv-build key, asset base, and both
SHA-256 values that mpv-build.lock.json already owns, and the pin mover
never touched it, so a lock bump left Windows silently stale. CMake now
reads artifacts.windows with string(JSON) (floor raised to 3.19) and the
Windows native cache key hashes the lock; a workflow guard rejects
hand-pinned checksums.
The byte-identical Linux libmpv fetch heredoc in build.yml and ci.yml
moves to scripts/fetch_linux_libmpv.py, which verifies the checksum
before extracting and is what the packaging script now points local
builds at. The package-windows and release jobs and
linux/packaging/build-packages.py read the version through
scripts/pubspec_version.py instead of three looser regexes.
Home, privacy, and scan each repeated the same 13 title/canonical/Open
Graph/Twitter tags; PageMetadata now emits them once with route-specific
title, description, url, and an optional noindex. Screenshots keeps one
record per device instead of four parallel arrays plus a lookup table.
FAQ hash jumps and screenshot arrows forced behavior: 'smooth' from JS,
which bypasses the prefers-reduced-motion override in layout.css; both
now defer to CSS scroll-behavior. Removes the unused adapter-auto
dependency.
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.
Emoji and other supplementary-plane characters in titles, file names, and
subtitle paths were corrupted on the Android MPV path, and malformed bytes
from mpv logs could abort under CheckJNI. NewStringUTF/GetStringUTFChars
speak JNI's modified UTF-8, not the standard UTF-8 mpv produces and
consumes; the native lead-byte filter and the Kotlin surrogate scrubber
ran on the wrong side of that conversion and could not recover it.
Both directions now go through UTF-16 (NewString/GetStringChars) with a
small JNI-free transcoder that replaces malformed input with U+FFFD, the
same policy as shared/cpp/sanitize_utf8.h on desktop. The header is
covered by the Android host native test harness.
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
High-bitrate HEVC episodes showed smeared frames and green flashes for the
first 10-15 seconds of playback on the Fire TV Stick 4K Max 2nd gen with the
mpv backend, while ExoPlayer played the same file cleanly (#2227). The
reporter's clip failed at exactly two timestamps on every run: the two IDR
frames larger than 1 MiB.
The FFmpeg mediacodec decoder never set "max-input-size" on the codec
format, so OMX.MTK.VIDEO.DECODER.HEVC allocated 1,052,672-byte input buffers
and FFmpeg split any larger packet across several queueInputBuffer calls.
The MediaTek VPU treats each buffer as a complete access unit, rejects the
remainder ("invalid u4BufferAccumulated"), fails the slice and conceals with
lost-picture references until the next IDR. ExoPlayer is unaffected because
media3 sizes HEVC input buffers at max(2 MiB, w*h*3/4) and never splits a
sample.
Move the native pin to edde746/mpv-build@83879375c9, which sizes the
buffers the same way ExoPlayer does for H.264, HEVC, MPEG-4, VP8, VP9 and
AV1 and logs a warning if a packet still does not fit. Verified on the
AFTKRT with the reporter's clip: zero VPU errors and no lost pictures across
repeated runs, and an H.264 file with ~1 MB keyframes still plays on the
MediaTek AVC decoder; both files also play on a Pixel 7 (Exynos C2). Apple,
Linux and Windows assets are unchanged (their content keys did not move).
close#2227
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.
Playing lossless passthrough audio (TrueHD, DTS-HD MA) on an Nvidia Shield
running Android 9 dropped a video frame every ten to twenty seconds with the
mpv backend, while lossy passthrough and PCM were unaffected and A/V sync
never moved (#2202).
IEC 61937 and raw passthrough tracks in ao_audiotrack never use
AudioTimestamp; their clock is AudioTrack.getPlaybackHeadPosition() taken at
face value, which on a direct track is the HAL's render position and
advances in HAL-period steps - 8192 frames at 192 kHz (42.7 ms) for the HBR
output on the Shield, longer than a 24p frame. The AO thread samples it once
per chunk and feeds it straight into the end time mpv schedules video
against, so a forward snap larger than a frame's remaining lead makes mpv
drop that frame; lossy passthrough runs at 48 kHz where the period stays
below a frame.
Move the native pin to edde746/mpv-build@af74e1906e, which estimates the
passthrough position the way media3 does: a ten-sample average of
(head - clock) offsets taken at least 30 ms apart, so a period-sized step is
spread over the window instead of landing on one frame. PCM tracks keep
their AudioTimestamp path. Apple, Linux and Windows assets are unchanged
(their content keys did not move).
Grid Spacing (Settings > Appearance) only changed the library grid. The
home, explore, detail and library-recommended rows were byte-identical
across Tight/Normal/Spacious because #2083 deliberately opted hub-row
cell packing out of the setting and hard-coded a 2px pad per card, and
the TV home rail never read the setting at all.
Treat a hub row as one row of the grid. MediaGridDelegate.cellWidth
packs row cells with the same gutter MediaGridGeometry uses, so a row
and the "see all" grid behind it render the same card at every setting.
HubSection renders max(4, gap) between cards - Tight keeps its historical
4px so nothing moves on update, Normal/Spacious render the grid's 6/12px
gutter - and feeds the same gap into its D-pad item extent and card memo
epoch. TvBrowseRail threads GridSpacing through metricsForHub,
maxActiveRailHeight and estimateHeight; non-full-card rails use the
setting scaled with the rest of the rail metrics, full-card rails keep
their own scale-derived gutter like full-bleed grids. The spotlight and
detail rail height reservations pass the setting too so reserved
heights track the narrower cards. Both surfaces watch the pref, so rows
re-lay out live.
close#2226
Every D-pad step on the TV home ends with a spotlight swap, and on a
low-end box that frame ran over budget: 12 % of UI-thread CPU during
navigation was paragraph layout, most of it shaping strings a second time
to measure them before the render tree shaped them for real.
- FittingTitleText no longer lays the title out to learn whether it fits:
Text wraps and ellipsizes at maxLines, so the box can only overflow
vertically, and one cached one-glyph line height settles that. Only a
box shorter than maxLines lines still searches, now bracketed by the
proportional shrink of the base layout and stopped at a 0.25 px
tolerance instead of a fixed 12 bisections.
- FittedMetadataLine and inlineRatingBadgeWidth measure through a small
bounded cache keyed by text, style, scaler and direction, so separators,
type labels, certifications, runtimes and years measure once per
session.
- MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their
regex tokenisation per key; every card build and step re-asked them.
Cold navigation on the Android 14 box (30 steps over three hubs): build
312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90
7.5-7.9 ms -> 6.8-7.2 ms.
Android enables Flutter semantics for any bound accessibility service, so a
TV running the Projectivy Launcher (whose service only wants foreground-app
events) paid for a full semantics tree on every frame that touched a node:
on an Android 14 box, 2 ms per frame, ~20 ms on each spotlight swap, and a
third of the navigation-time GC churn.
The Android side now reports whether any enabled service can consume the
tree (touch exploration, an accessibility tool, or spoken/braille/audible/
visual feedback; an empty list means UiAutomation, which reads it too), and
AssistiveTechnologyService closes a SemanticsTreeGate on the app binding
when none can. The gate sits under SemanticsBinding.semanticsEnabled, so the
pipeline owner drops the semantics owner exactly as if the platform had
turned accessibility off; explicit ensureSemantics clients (the debug handle
for Maestro) always win. Re-evaluated on platform toggles, service-list
changes (API 33+) and resume.
Cold navigation on the box with Projectivy's service bound, 30 D-pad steps
over three hubs: SEMANTICS phase 700-770 ms -> 0, UI frame max 31-37 ms ->
25 ms, GC 3.0 s -> 2.2-2.6 s.
D-pad navigation on Android TV felt sluggish next to Projectivy and the
Fire TV launcher: every step of the home rail and hub rows trailed the
focus border in a 500ms ease-out, and hub moves deferred the vertical
glide by a frame. The 500ms was measured from the tvOS focus engine
(f357be407) and applied to every platform; Leanback prices a one-card
step at roughly 100-150ms, so the same glide reads as input lag on a
D-pad.
FocusTheme.navigationScrollDuration now vends the per-platform value:
Apple TV keeps the measured 500ms, everything else glides in 150ms.
TvBrowseRail and HubSection use it for row and hub-list scrolls, and a
hub move starts the vertical animation in the same frame when the
build-time section offsets are already known.
Measured with screen recordings on a Shield TV (2019) and a Fire TV
Stick 4K Max: row motion per press 470-510ms before, 135-150ms after;
hub move 480ms before, 120-150ms after.
Dolby Vision profile 7 titles (UHD Blu-ray remuxes, typically TrueHD or
DTS-HD MA) never showed a frame on the Fire TV Stick 4K Max 2nd gen with the
mpv backend: the spinner stayed forever while audio came up. Profile 8.1
titles carrying HDR10+ on the same device went fully black, controls
included, with audio playing.
Both are the FFmpeg mediacodec DV packet filter. It emitted every rewritten
access unit with a five-byte "00 00 00 00 01" start code - the leading zero
was copied once as head bytes and again as part of the first NAL region -
and MediaTek's VPU rejects such units ("H265_decode invalid NALU"), so the
profile 7 conversion and strip paths both produced nothing. And the filter
never dropped in-band HDR10+ SEI, which these chipsets cannot take next to
the RPU on the native DV decoder (#1296 on the ExoPlayer path).
Move the native pin to edde746/mpv-build@0ad6388c, which counts that zero
once and strips the ST 2094-40 SEI whenever a video/dolby-vision decoder is
selected. Verified on an AFTKRT with the published armeabi-v7a asset: a
P7.6 remux sample decodes on both the HEVC (strip) and DVHE.STH (convert)
paths with no VPU errors, and a P8.1 + HDR10+ remux plays on the DV decoder
with a single output-format change. Apple, Linux and Windows assets are
unchanged (their content keys did not move).
close#2176
Enabling Deinterlacing had no effect on Android and Android TV for
interlaced MPEG-2 (and any other software-decoded stream): mpv asks
libavfilter for bwdif, and the pinned Android and Linux FFmpeg builds were
configured with --disable-filters, so the request failed with
"filter 'bwdif' not found" and the frame passed through combed. Windows
and Apple ship FFmpeg with the filter, which is why the same file
deinterlaced on Windows.
Move the native pin to edde746/mpv-build@9bfbcd69, which builds bwdif into
the Android and Linux FFmpeg. Apple and Windows assets are unchanged
(content-addressed keys did not move). Verified on a Shield TV Pro with the
published arm64 asset: 480i MPEG-2 now deinterlaces cleanly.
close#2224
With an external player on Plex, starting a never-watched episode could
open a "resume playback?" prompt at the position the previous, unrelated
file was exited. The intent carried no start hint when the item had no
resume point, so players with their own bookmark store (Zidoo native
player, VLC) consulted it - and every Plex part URL ends in the same
`file.<ext>`, so those bookmarks collide across items.
Send `from_start=true` on fresh launches and `from_start=false` beside
`position` when resuming, matching the Zidoo integrations in
jellyfin-androidtv-zidoo and PlexToZidoo. MX, Just Player and mpv ignore
the extra.
close#2223
AAC/AC3/EAC3 5.1 tracks at 44.1 kHz played as stereo through mpv on Android:
ao_audiotrack sized its buffer as 75 ms aligned to the sample size, which at
44.1 kHz is not a whole number of 6-channel frames, so AudioTrack refused it
("Invalid audio buffer size") and mpv fell back to the stereo-only OpenSL ES
output. 48 kHz sources (DTS, or audio-samplerate=48000) happened to align.
Pin mpv-build 1779135, which frame-aligns the audiotrack PCM buffer
(edde746/mpv-build@d89cfbb). Only the Android libmpv key moves.
close#1445
Rooms drifted in speed and kept pausing on poor links. A 2x long-press or a
speed pick on any peer propagated with no attribution, so it read as the app
changing speed on its own; a host whose cache starved let mpv resume its player
on its own timetable while the room waited, so every recovery re-anchored ahead
of the guests and they hard-seeked in a loop; and a wall-clock step on a guest
made every host anchor read stale and seeked the file by the size of the step.
Rate intent is now declared by the screen (speed sheet, keyboard, long-press,
media controls) through WatchTogetherProvider.onLocalRate instead of being
inferred from the player's rate stream, matching how seeks already work. The
expectation ledger no longer tracks rate at all, so a nudge, a default-speed
apply or a late ack can never become a room rate change, and a promoted host
adopts the room rate rather than its player's momentary one. Remote rate
changes surface as "<name> set the speed to <rate>".
The host pauses its own player when it stalls, anchors the room there, raises
mpv's cache-pause-wait to 4s, and holds the resume until it has buffered three
times the stall it just had. Heartbeats during a sub-grace blip keep
extrapolating the previous anchor instead of re-anchoring on a frozen position.
The sync layer's clock is monotonic. ClockSync discards its window when a
sample's offset moves further than the round trips involved can explain, and a
guest that receives an anchor reading implausibly old holds corrections and
re-converges instead of seeking.
Searching for items in a Plex "Other Videos"/home-video library returned
nothing, while the same query worked in Plex Web. `/library/search` with
`searchTypes=movies,tv,music` skips libraries on the Personal Media agent;
they only answer to the `otherVideos` category.
Add `otherVideos` to the search request and treat personal-media rows
(`*.agents.none://` guids) as their own category when a saturated response
is supplemented, so a large movie library cannot starve home videos and
vice versa.
close#2216
A local Seerr account with request rights could sign in to Plezy but never
saw the Request action. POST /auth/local loads only the columns it needs to
check the password, so the entity it returns carries the class default
permissions of 0 (and the email as display name); the session stored that
snapshot and the detail screen gated Request on it.
Sign-in now ignores the login body and reads the user back through
GET /auth/me with the fresh cookie, for every method. SeerrUser.permissions
is required, so a user without a mask fails sign-in instead of persisting 0.
Two related gaps kept an affected session broken until a manual reconnect:
- Seerr answers an expired session with 403, never 401, so the silent
re-auth never ran. A 403 now re-auths once GET /auth/me confirms the
cookie is dead; a 403 from a live session stays a permission denial so a
Quick Connect session is not unlinked over one.
- Nothing refreshed a stored session's permissions. The account provider
now re-reads the user on bind, so sessions persisted with 0 by earlier
builds, and admin-side permission changes, reach the Request action on
the next launch.
close#2213
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Plezy consumed mpv through four unrelated supply chains: an MPVKit fork
via SwiftPM for the Apple platforms, a libmpv-android fork's AAR for
Android, an in-CI from-source build for Linux, and an unpinned
sourceforge mpv-dev 7z for Windows. All four now consume the same
per-commit, content-addressed binaries from
https://github.com/edde746/mpv-build, pinned to one commit and built
from one set of pinned sources (mpv v0.41.0 on Apple/Android/Windows,
ffmpeg n8.0.1, our libass fork).
- Apple: the SwiftPM package moves from edde746/MPVKit to
edde746/mpv-build across the ios/macos/tvos projects;
scripts/set_mpvkit_revision.sh becomes set_native_revision.sh, writes
every pin site plus the new repo-root mpv-build.lock.json, and
tvos/scripts/wire_mpv.rb derives the package repo from the locks.
- Android: the mpv Kotlin API and JNI glue move in-app under
android/libmpv (repackaged com.edde746.plezy.libmpv, exports renamed,
shrinker rules covered), and the module downloads per-ABI native
tarballs (lib/*.so incl. libc++_shared.so + include/) driven entirely
by mpv-build.lock.json, with a PLEZY_LOCAL_MPV_DIR escape hatch for
locally built artifacts. The fork AAR and its Maven coordinates are
gone.
- Linux: CI downloads the prebuilt self-relocating libmpv prefix
(lock-driven, sha256-verified) instead of compiling mpv/ffmpeg/dav1d/
libplacebo/shaderc from source; linux/packaging/build-libmpv.sh and
its test are deleted and native-inputs.json shrinks to the simdutf
entry the CMake builds still fetch.
- Windows: both arches FetchContent the mpv-build dev zips with
URL_HASH enforcement, replacing the checksum-less sourceforge
download and its ARM64 7-Zip special case; guard scripts updated.
The lock plus the Apple pin sites all point at mpv-build commit
d7c3d559, whose manifest was verified asset-by-asset against the
published release digests (17/17 match). Verified locally: wire and
pin-script suites green, runtime-input checks green, all four Android
ABI tarballs downloaded/verified/extracted through the real Gradle
tasks, the Windows FetchContent block exercised end to end through
cmake, the Linux asset hash and layout checked against the workflow
contract, and xcodebuild resolved the flipped SwiftPM graph with
SwiftPM validating every binary checksum.
Opening View Logs with a full 5 MiB buffer laid out the entire log as one
paragraph, freezing the frame for tens of seconds and using ~1 GB of glyph
data — the OS killed the app on low-memory phones and TVs (watchdog/OOM).
Render one Text.rich per entry in a SliverList.builder so only the visible
slice is laid out, keeping selection through a SelectionArea. Also cap the
copy-to-clipboard payload at 256 KiB (newest lines kept) to stay under
Android's ~1 MiB binder transaction limit.
On TV, pressing down on the Explore detail screen jumped straight from the
action bar to the next focusable button, scrolling the description and
background prose past unread. The overview and background sections are now
collapsible focus stops wired into the d-pad chain in both directions, so a
viewer can stop, read, and expand them before moving on.
close#2199
4K HDR->SDR playback on Windows-on-ARM stutters and flickers because libplacebo regenerates its tone-map and gamut-map LUTs on every drawn frame. The two earlier workarounds targeted peak detection and HDR10+ metadata, but both regenerating LUTs log at info level, which libplacebo only does for non-dynamic LUTs, so something else is moving the LUT keys. The same libmpv build on a Snapdragon X Elite CRD (Adreno X1-85, driver 31.0.160.0) shows no churn at all, so the trigger is specific to the reporter's machine.
Add an HdrProbe to the Windows runner that polls the two LUT inputs every 250 ms - mpv's video-target-params (destination colour space, derived per frame from DXGI_OUTPUT_DESC1 and the DisplayConfig SDR white level) and video-out-params (source HDR metadata) - plus the raw DXGI output description, SDR white level and factory IsCurrent() state once a second, and reports every change and a 10 s summary into the app log as hdr-probe lines. Change streams are rate limited so an uploaded log stays within the relay's 1 MiB.
CI's formatting jobs were red on main: dart format wanted
media_card_grid_spacing_test.dart collapsed, and ktlint wanted three
Kotlin expression bodies on their signature line plus one import moved
into lexicographic order.
Pure formatter output from `dart format` and `scripts/format_native.sh
--fix`; no behavior change.
Choosing a hidden library from the sidebar on a fresh launch opened the
topmost visible library instead, and reordering the sidebar changed which
wrong library appeared. A second attempt worked.
The first visit to the Libraries tab mounts LibrariesScreen, and two
post-frame callbacks then run in registration order: MainScreen's, which
applies the requested library, and the one initState registers during that
frame's build, which picks the saved or topmost-visible default. The second
one overwrote the first — and a hidden library can be neither default, so
the selection was always lost.
Initialization now treats an existing selection as authoritative, both on
entry and after each of its awaits, so a selection arriving while it is
suspended (the refresh() stale-resume path) also survives.
close#2207
The Linux build stopped compiling everywhere: d9e31fd1a wrote the
attribute as [[maybe unused]], with a space instead of an underscore.
An attribute-list is comma-separated, so two bare identifiers are a
parse error rather than an ignorable unknown attribute, and
linux/runner/mpv/mpv_plugin.cc reaches every Linux build.
Use [[maybe_unused]], which is what clang 23's -Wunused-but-set-global
actually checks for, so the diagnostic the original change was chasing
stays suppressed.
Disabling peak detection (efb0e885) did not stop the per-frame shader LUT regeneration on Snapdragon devices: libplacebo keys the tone-map LUT on the frame's raw HDR metadata, and mpv forwards HDR10+ per-scene values (scene_max, scene_avg, ootf) on every decoded frame, so the LUT - at tens of milliseconds per rebuild on Qualcomm's D3D11 driver - still regenerated continuously and playback kept stuttering and flickering (#2191).
Zero the dynamic metadata before the renderer with vf=format:hdr10plus=no on Qualcomm GPUs; tone mapping uses the static HDR10 mastering metadata and the LUT is generated once. Dolby Vision L1 metadata is deliberately not stripped: dovi=no would break profile-5 rendering. The applied HDR pipeline options are now reported into the app log on first file load so uploaded logs are self-diagnosing.
Playing HDR content tone-mapped to an SDR display on Windows-on-ARM stutters heavily and flickers in brightness: dynamic peak detection moves the tone-mapping parameters every frame, libplacebo regenerates its tone-map and gamut-map shader LUTs each time, and Qualcomm's Adreno D3D11 driver takes tens of milliseconds per regeneration - roughly 100 ms of work against a 42 ms frame budget at 24 fps.
Scan the DXGI adapter list for a Qualcomm vendor id and set hdr-compute-peak=no when one is present, keeping auto elsewhere; static metadata-driven tone mapping generates its LUTs once.
close#2191
Since 2.18.0 no Jellyfin Live TV channel plays: opening one fails with
"Could not start the live channel", and switching channel inside the player
does nothing at all. Negotiating video transcodes as fMP4 (#2131) left the
device profile with no MPEG-TS entry, and Jellyfin discards every non-ts
transcoding profile for a live source whose tuner sets
UseMostCompatibleTranscodingProfile - hardcoded for every HDHomeRun host, the
default for M3U ones - so the negotiation came back with no HLS URL at all.
The device profile now sends a ts profile alongside the fMP4 one, listed
second, the way jellyfin-web does. Both of its codec lists are strict subsets
of the fMP4 entry's and the server ranks profiles with a stable sort, so ts
can only win where fMP4 has been filtered out; VOD keeps negotiating fMP4.
flac and truehd are absent because TS cannot carry them, av1 because that is
the gap fMP4 exists to fill.
Live TV also stops being unconditionally transcode-only. It now reads the same
saved quality preset the library path does: Original asks for direct play and
sends no bitrate ceiling, so a channel the server can hand over untouched no
longer costs a GPU encode session per viewer, and a capped preset transcodes at
that ceiling. A direct-play session whose stream dies re-negotiates a forced
transcode instead of re-opening a URL the server will not serve.
Two smaller holes on the same path: a negotiation that bailed out left the live
stream AutoOpenLiveStream had already opened running, because no playback
session existed to ever stop-report it - a few failed tunes could saturate a
provider's connection budget - and a channel switch that failed to start showed
no message at all.
close#2198
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
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.
The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
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.
White-on-transparent broadcast logos (CBS, FOX, the NBC wordmark) vanish
on the light theme's white guide cards, making channels hard to identify.
Channel logo decodes now classify the frame's tone once per image-cache
entry and bake light-neutral pixels toward the theme foreground:
monochrome white marks recolor fully, mixed marks (colored peacock plus
white wordmark) keep their colored pixels, and self-backed or dark
artwork decodes unchanged. Applied in the guide channel column, guide
search, and favorite reordering whenever the backdrop is light,
including the dark theme's inverted focus card; dark backdrops keep the
original artwork.
close#2197
4:4:4 (High profile) AV1 played as green or black video on Android TV
devices with AV1 hardware decoders: MediaCodec defines no profile
constants beyond Main, but decoders like the Fire Stick 4K Max (MTK)
and Amlogic C2 accept the stream at configure time and emit garbage or
nothing, so no error ever reached the player and no fallback ran.
libmpv v1.2.2 carries an FFmpeg patch that reads seq_profile from the
bitstream at av1_mediacodec init and refuses High/Professional streams,
letting mpv's hwdec probing fall through to dav1d software decoding.
Main-profile AV1 keeps hardware decoding.
close#2194
A quality preset the source already fits under direct plays the file
since #2152, but a transcode above the source bitrate is sometimes the
point: a device that cannot decode the source (10-bit H.264, AV1, 4:4:4)
wants the server's highest-quality encode, and the shortcut silently
handed it the very file it cannot play smoothly.
A new Quality switch, "Play Smaller Videos at Original Quality" (on by
default), gates the shortcut in PlexClient._presetNeedsTranscode; off
restores the pre-#2152 behavior where any non-original preset always
transcodes. Plex-only by design: MediaBrowser servers make the
equivalent direct-play-vs-transcode call server-side.
close#2193
TrueHD/Atmos passthrough with Frame Rate Matching enabled never engaged
the receiver on the mpv backend (Ugoos SK1 -> Denon AVR): the display
mode switch settles 2 s before the audio output opens, but some HDMI
chains are still renegotiating audio then, so the IEC 61937 track binds
a half-negotiated route and the sink never locks onto the MAT
bitstream - for the whole session. The same file with Frame Rate
Matching off, or on ExoPlayer, engages Atmos immediately: media3
rebuilds its sink when the audio device set changes, while mpv's
ao_audiotrack never revisited a track once created.
libmpv v1.2.1 polls AudioTrack.getRoutedDevice from the AO thread and
reloads the AO when the route changes - or vanishes and rebinds - under
a live IEC or raw passthrough track, reopening it against the settled
route. PCM tracks are exempt, and audiotrack-route-reload=no is the
escape hatch.
close#2190
close#2183
Reusing a room means whoever enters first becomes the host, and moving the
session to someone else's library meant everyone leaving and re-entering in
the right order. The host can now promote any connected guest from the
participant list (Watch Together screen and the in-player session sheet),
with playback, control mode, and reconnect identities carried across.
The relay owns the swap: a new `transferHost` message validates the sender
is the live host and the target a connected modern-protocol guest, then
swaps `HostPeerID` and the reconnect verifiers (each peer keeps its own
token), persists the room, and broadcasts `hostChanged` to every peer.
Clients flip roles in place: the controller swaps its role engine while
keeping the session, message queue, and player attachment. A promoted guest
seeds the coordinator with the room's last known intent (a paused room
stays paused) and the known-peer roster so the fresh epoch re-gates
instead of solo-starting; a demoted host falls back to a reconciler with a
fresh clock sync and asks the new authority for state. Guests re-pin the
host identity, reset their sequence, and re-converge their clocks.
A rejected transfer (`not_host` / `peer_not_found`) surfaces as a toast
instead of tearing the session down. Pre-transfer app builds in the room
ignore `hostChanged` and stop following the room at the next transfer;
targets on an old sync protocol are not offered the action.
The brightness set with the left-edge swipe was lost the moment playback
ended, so every session started back at the system level. A new
"Remember Brightness Level" toggle under Settings > Playback > Gestures
persists the level a swipe settles on and reapplies it when the next
playback starts (and when the app resumes mid-session). Leaving the
player still restores the pre-playback brightness, so the rest of the
app is unaffected. Default off, preserving current behavior.
close#2178
The chip strip sat 24px below the search field and 24px above the first
result. Drop the strip's own top padding (the field's bottom padding
already provides 16px) and halve the results sliver's top padding when
the strip is shown, so both gaps read as an even 16px.
Audio Passthrough on 2.18.0 played EAC3 silently on a Shield in front of
a Dolby-Digital-only receiver: mpv's IEC 61937 AudioTrack bypasses the
platform's Dolby transcoder, so the pre-packed DD+ bitstream reached a
sink that cannot decode it, drained at full rate, and no failure was
observable app-side. Disabling passthrough lost surround instead, because
multichannel PCM collapses to stereo in the platform mixer before the
HDMI re-encode.
libmpv v1.2.0 unwraps the IEC bursts and feeds AC3, E-AC3 and the DTS
core to a raw ENCODING_AC3/E_AC3/DTS track - the transport ExoPlayer and
Kodi use - keeping the platform decoder/transcoder in the path, with the
IEC track as fallback and audiotrack-raw-passthrough=no as an escape
hatch. TrueHD and DTS-HD MA stay on the 8-channel IEC carrier.
The audio-spdif route probe now accepts a codec when the route takes its
raw track (the same direct-playback tiering media3 uses; encoding-only
below API 29) or one of the IEC shapes, matching the AO's transport
ladder. A dev-only PLEZY_LOCAL_MPV_AAR override allows testing locally
built fork AARs before a release is pinned.
close#2177close#2179
Since the libmpv bump to 20260809 (2.15.0), libmpv-2.dll dynamically links
the Khronos Vulkan loader instead of statically linking it. plezy.exe links
libmpv at load time, so on machines whose GPU driver does not install
vulkan-1.dll - Windows-on-ARM Adreno drivers, driverless x64 VMs - the
process failed before startup with "vulkan-1.dll was not found".
Download the LunarG-built loader (pinned, hash-verified) at configure time
and install it next to the executable with its license. Without a Vulkan
ICD the gpu-api=auto probe fails cleanly and falls back to D3D11, matching
the old static-loader behavior.
close#2110
Making mpv the Android default only moved installs that had never opened
the Player Backend setting. Anyone who had ever picked ExoPlayer kept it,
including the low-end TVs and Hi10P devices the new backend was built for.
The backend choice now lives under a new `android_use_exoplayer` key and
the old `use_exoplayer` one is dropped at startup, so every install starts
on mpv. ExoPlayer stays selectable in Settings > Playback > Player Backend,
and a pick made there writes the new key and sticks.
The guide-search sheet and the TV rail's options button fell back to
English in every non-English locale: their six keys existed only as
empty strings in the sibling translation files.
Fill all 126 empty targets across the 21 locales and regenerate the
slang bindings. searchNoResults keeps its ${query} placeholder and each
locale's quote convention, and channelsSection uses the broadcast sense
of "channel" rather than the audio-channel word already used by
fileInfo.channels.
Stopping HDR playback with frame rate matching enabled left some TVs
black for up to 30 seconds. The teardown reset preferredDisplayModeId
while the display was still signaling HDR, folding the HDR exit and the
refresh-rate restore into one HDMI renegotiation that slow sink chains
take many seconds to complete (8.5 s on a Shield Pro with an HDR10
display; ~30 s on the reporter's DV AVR+TV chain). Sequenced, the HDR
infoframe clear is free and the SDR mode switch takes about a second.
FrameRateManager.clearVideoFrameRate now takes an hdrActive hint from
the core (mpv: the content-color-transfer decision; ExoPlayer: the
selected track's colorInfo transfer) and defers the restore by 400 ms so
the surface teardown commits the HDR exit first. The deferred restore
lives on its own main-looper handler because core dispose clears the
shared player handler wholesale, and a new setVideoFrameRate cancels it
so a fresh session's switch is never clobbered. SDR teardown behavior is
unchanged.
Verified on a Shield Pro (mpv backend, 1917 DV P8/TrueHD, Jellyfin):
link recovery after stop went from ~8.5 s to ~0.8 s, with the SMPTE 2086
clear now committing before the mode set.
close#2172
The mini player drew a thin scrubbable progress bar along its top edge on
top of the ambient background fill. Drop the scrub lane: the fill is the
card's only progress display, and precise seeking stays on the
now-playing screen's seek bar. Horizontal drags across the card's top
edge swipe-dismiss the card again.
Quitting the app lost the music session: on the next launch the queue,
current song, and playhead were gone. The session now persists per
profile (new MusicSessions Drift table, schema v22) through throttled
write-through during playback, and the next launch rehydrates it as a
parked-paused queue — no audio core and no source resolution until the
first play, which opens the track at the saved offset. Ending the
session (stop, swipe-dismiss, video claim) clears the snapshot, and a
new "Remember music session" playback setting (default on) gates the
restore.
close#2148
Some HEVC files carry a frame Apple's hardware decoder refuses to decode
(kVTVideoDecoderBadDataErr on an otherwise conformant P-frame). mpv fell
back to software decoding for the rest of the file after the first error,
and 4K HEVC software decode drops frames every few minutes on Apple TV.
MPVKit b8b922ec carries an mpv vd_lavc patch that keeps the configured
fast fallback while probing but lets a hardware decoder that has already
delivered frames ride out a mid-stream error burst: the burst one
undecodable frame causes ends at the next keyframe by construction, so
fallback now requires errors to survive two keyframe intervals. Verified
against the reporter's sample: playback logs "Hardware decoding
recovered after 90 errored frames" and stays on VideoToolbox instead of
switching to software.
close#2167
On TV the show detail screen exposed season actions nowhere: marking a
season watched meant toggling every episode one by one, and the action
row's menu only covered the whole show.
Each season row on the TV detail rail now carries a full-size Options
card that opens the same season context menu the mobile season tabs
offer (mark watched/unwatched, download, delete, add to). The card
rests off-screen left of the first episode — the row is a
center-anchored scroll view and the card lives in the negative-offset
region before the anchor — so the resting layout and default focus are
unchanged and normal playback gains no extra steps; pressing LEFT from
the first episode slides it in. The trailing View All / retry / loading
slots become matching full-size cards instead of compact pills.
close#2156
Live TV had no search: finding a channel or a program meant scrolling
the guide row by row, which is close to unusable on TV with a few
hundred IPTV channels, and the global search tab covers neither
channels nor EPG data.
A search action on the Live TV tab bar (shown on every tab, since the
D-pad route through the chips selects each tab it crosses) opens a
sheet that filters channels by name, call sign, and number, and the
next 24 hours of programs by title and series name, from one
fetchSchedule fan-out per open. Selecting a result jumps the guide to
it: the favorites filter is dropped when it would hide the target row,
the time window is re-anchored when the airing is outside the visible
six hours, and D-pad focus lands on the channel cell or program block.
While the sheet is open, companion-remote search queries land in it
instead of the global Search tab (same save/restore idiom as the video
player overrides). Jumps requested while a guide load is in flight are
stashed and replayed when the load commits.
Based on #1526 by @l3gitpanda, rebased over the guide virtualization
and source-group work, with the schedule fan-out moved onto
forEachLiveTvServer and the jump focus routed through the guide's
focus-snapshot publisher so the landed cell actually renders focused.
close#2168
The end-of-episode autoplay prompt only named the next episode; Plex shows
a visual preview, which is what non-readers navigate by. The prompt card now
paints the episode's 16:9 still edge-to-edge behind the text under a bottom
gradient scrim, falling back to the text-only card when no thumbnail can be
served (no thumb, or no client and no downloaded artwork). Unwatched stills
respect the hide-spoilers blur, and the lookup only runs while the prompt is
visible to keep the playback-path build cheap.
close#2166
HDR/Dolby Vision playback on Fire TV (and some Sony/Philips models)
shows gray letterbox bars instead of black on OLED panels: the bars
were the player container's window-plane background, and those device
compositors raise SDR graphics-plane black while the display is in
HDR/DV output mode. Black inside the video plane is unaffected (same
mechanism as ExoPlayer #8803 and Kodi #25300).
Add a fullscreen, buffer-less SurfaceView beneath the video surface in
the shared player container. Like any below-window SurfaceView it
registers its rect as a window transparent region, so the letterbox
area scans out as the SurfaceFlinger backdrop instead of window-plane
pixels. No buffer is ever posted: SurfaceFlinger skips buffer-less
layers, and drawing into one would put the bars back on an SDR layer.
Covers both the ExoPlayer and mpv backends.
close#2163
94 strings added since the settings rework were empty placeholders in every
non-English locale, so those screens fell back to blanks. Fill all 1974
missing values across 21 locales, reuse each file's existing terminology,
and translate the few verbatim-English leftovers (es rankPopular, fr tags,
it dynamicRange, pl stop, nb tag). Regenerated Slang output.
Any quality preset other than Original started a transcode even when the
file was already well under it: a 6.2 Mbps 1080p h264 source under the
"1080p 10 Mbps" preset came back from PMS as a 10.4 Mbps encode, so capping
quality on mobile data spent more data than leaving it uncapped, plus server
CPU nobody asked for (#2152).
A preset is a ceiling, so a version that fits under both its bitrate and its
resolution is now served by the file itself, on the same direct-play path
Original takes, and no transcode decision is sent at all. Height counts as
well as bitrate because the preset's label promises a resolution too, and a
device that asked for 1080p may not decode the 4K source a bitrate-only
comparison would hand it. An unreported bitrate or height leaves the
transcode standing.
The comparison has to happen in the client: PMS answers directPlay=1 with
"Direct play OK" whatever bitrate cap the request carries, measured on 1.43
with a 65 Mbps 4K source under a 10 Mbps cap, so its MDE cannot be asked to
enforce one. Plex Web reaches its own directPlay/directStream flags the same
way, folding the preset's bitrate into its client-side direct-play profile
first. Jellyfin already gets this right server-side.
close#2152
While music plays, the persistent mini player showed progress only as a
subtle background tint with no way to seek; the full seek bar sat one
navigation hop away on the now-playing screen (#2141).
The card's top edge now hosts a thin visible progress bar inside a taller
touch lane: tap seeks proportionally, dragging scrubs and commits one seek
on release, and hovering or dragging grows the bar and shows a thumb. Card
taps outside the lane still open the now-playing screen. Pointer-only by
design: the overlay never renders on TV and the keyboard focus chain leads
to the now-playing screen's focusable seek bar.
close#2141
Tapping Instant Mix on Plex often did nothing: the /playQueues POST used no
endpoint failover, createPlayQueue swallowed every error into null,
fetchInstantMix mapped that to an empty list, playInstantMix returned
silently on empty tracks, and the errors stream's only listener is the
now-playing screen, which is not mounted at tap time (#2141).
The play-queue POST now rides the shared endpoint failover (replaying it is
safe: an orphaned duplicate queue on the server is inert), createPlayQueue
and fetchInstantMix propagate typed errors, playInstantMix reports
started/empty/superseded and throws fetch failures, and the tap site shows
a translated snackbar for a failed or empty mix. A failed collection or
playlist queue launch now also reaches the real failure snackbar instead of
the misleading "no items" path.
Downloaded tracks landed in the generic downloads/{serverId}/{ratingKey}/
layout as video.mp3 because the path builder only special-cases movies and
episodes, so a user-visible download folder showed opaque folders and
meaningless names (#2141).
Tracks now get Music/{Artist}/{Album}/{NN - Title}.{ext} in both file and
SAF modes, grouped by sanitized album-artist with Unknown Artist/Album
fallbacks and a disc prefix on multi-disc albums. Existing downloads keep
working: playback and deletion resolve the stored path per record, and the
empty-parent cleanup keeps shared album folders while a sibling track
remains.
Users could not adjust the five-second Play Next timer, switch off the
edge-swipe/pinch gestures they trigger by accident, deinterlace DVD-era
content, or keep always-on-top across episodes.
Play Next Countdown (0-30 s) joins Auto-Play & Skip; zero skips the
prompt and starts the next episode immediately, while the transient-
retry prompt keeps its fixed five-second spacing. Brightness swipe,
volume swipe, and pinch-to-zoom each get a switch in a mobile-only
Gestures group; a disabled edge also stops stealing the content-strip
drag. A Deinterlacing toggle (mpv-only — ExoPlayer has no filter chain)
sets mpv's deinterlace=auto at player init. The player's always-on-top
toggle now persists via a pref that is re-applied when the next player
opens, including autoplay episode transitions; the window flag is still
dropped when the player closes.
Also resolves the reorg TODO markers per triage: hold-to-speed lock
(#1046) will be gesture behavior rather than a setting, metadata
language (#1668) and delete re-authentication (#1924) are rejected, and
always-on-top is player-persisted state, not a Window setting (#1386).
close#1827close#1810close#2149close#931
Settings were grouped by screen history rather than by what they govern:
the profile-selection prompt, startup section, force-TV mode, and app
language sat under Appearance, Discord Rich Presence and the companion
remote host hid inside Playback > Behavior, and maximum volume lived
under Seek & Timing.
Introduce a General screen (language, startup, desktop window), regroup
Appearance into display/library/home/navigation/Live TV, split the
Playback monolith into engine, video & display, audio, quality,
subtitles, seek, auto-play & skip, and behavior groups, surface
auto-play next episode in settings, rename the Keyboard Shortcuts
section to Controls (now also hosting the companion remote host), move
Discord Rich Presence to Services > Integrations, and move the
performance-overlay switch to Advanced. Pref keys are unchanged, so
stored values, export/import, and reset behavior are unaffected.
TODO markers record the agreed homes for upcoming settings (#1998,
#2064, #1641, #1769, #2149, #1827, #2138, #1810, #1046, #1924, #1413,
#1668, #1386, #1795).
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
When a Plex admin disables credits detection for a show or movie, PMS
strips detected credits markers from its responses, but Plezy's
chapter-title fallback resurrected the skip action from a credits-named
chapter — surfacing a "Next Episode" button (and auto-skip) during
credits the admin asked to leave alone.
Drop credits markers when the owning show/movie carries an explicit
enableCreditsMarkerGeneration=0. Movies read the attribute off the item
already fetched; episodes resolve the grandparent show through the
shared cache-first metadata row, so the lookup normally costs no extra
request and only runs when a credits marker is actually present. Absent
attribute (servers without the feature) and -1 (library default) keep
the fallback intact; lookup failures fail open. The offline cached
extras path applies the same rule from cached rows.
close#2137
The library grid packs posters edge to edge. The grid delegate's cross-
and main-axis spacing were hardcoded to zero, so the only visible gap
came from each card's own 3px padding, and Library Density changed
poster size rather than the space between posters. The result reads
denser than Plex, Jellyfin, or any commercial client, both horizontally
between posters and vertically between a poster and its title.
Add a Grid Spacing setting - Tight, Normal, Spacious (0/6/12px) - below
Library Density. Tight is the default and reproduces the current layout
exactly, so no existing grid moves on update.
The gutter lands in MediaGridDelegate.spacingFor(), already the single
funnel for non-full-bleed grid spacing, so every grid surface picks it
up: library browse, collections, playlists, downloads, and the detail
screens. Square music grids keep their 8px floor. The pref is watched
once in MediaCardSliverLayout - the only MediaGridGeometry.resolve()
call site - so grids re-layout live instead of on restart.
Grid cells grow the poster-to-title gap to 2/4/6px as well; the
Expanded poster absorbs the delta, so the cell keeps its aspect ratio.
Fixed-height hub-row cards keep the historical 2px because their text
band cannot absorb more, and hub-row cell packing opts out of the
setting entirely so shelves stay byte-identical across it.
Full-bleed TV grids are untouched - they already carry an 8-18px
scale-derived gutter - and are opt-in behind tvFullCardLayout, so the
default TV library grid follows the setting like every other surface.
Verified on macOS across all three steps: gutters and title gap grow,
grids re-pack live, and the segmented control reflects and writes the
value.
close#1597close#2083
Plezy carried three native HTTP clients on the assumption that they beat
Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS,
Windows, Linux and three Android devices, two of them do not.
Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN
body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on
sequential API calls. It also fails a 60-way fan-out outright with
net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and
cost 70-705 ms of CronetEngine.build on first use. Paying that build off the
critical path is the only reason AndroidPlatformHttpClient,
warmUpPlatformHttpClient and the per-request delegate swap existed; all three
go away with it.
CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs
60 req/s, and no reported issue ever justified it. tvOS already shipped the
dart:io client, so Apple platforms now agree with it.
WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io
equivalent, and it brings the system proxy and the Schannel trust store.
The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient
so generic tracker and auth clients stayed disposable, but every dart:io client
has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so
tuned and untuned already share shutdown semantics and the flag only cost
throughput: 12 connections per host with a 90s idle measured ~4x the dart:io
default on a 60-way fan-out on Linux and ~2x on Android.
media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's
CronetDataSource independently of package:cronet_http.
Refs #2140.
Two relay setup tests failed on CI while the same commit had passed a
few minutes earlier. The harness rewrote every 10-second timer in the
zone to 10 ms, which also compressed the connect deadline of the
recovery that runs once setup retries are exhausted. On a loaded runner
that deadline expired after the relay had upgraded the socket but
before the client sent its first frame, so the recorded connection
carried no messages and shifted the expected sequence; when all three
recovery attempts were starved this way, the recovery never announced
at all.
A reply that never comes is safe to compress, a real loopback handshake
is not, and one zone hook matching on duration cannot tell them apart.
The service now exposes the setup acknowledgement, the release
handshake, and the release acknowledgement as separate budgets, each
still 10 seconds in production, and every test sets only the one it
means to expire. The blunt helper is gone; what remains collapses the
250 ms and 500 ms retry delays, which are waits rather than races.
Verified by reproducing both failure modes under CPU saturation, about
one run in ten, then 30 consecutive clean runs of the file under the
same load and a full suite at 6332 passing.
Browsing a Plex home-video / "Other Videos" library under "Show all"
offered no way to sort by your own rating. The sorts Plex advertises for
those sections only cover critic and audience ratings, which unmatched
files never carry, so nothing rating-based was usable.
Plex honors `sort=userRating:desc` and `sort=viewCount:desc` on those
sections without advertising them, and the client already appends both
for movie and TV libraries. Home-video sections are `type="movie"` on the
wire and only differ by `subtype="clip"`, which the library mapper folds
into MediaKind.clip for folder-first grouping and wide cards, so they
were failing the movie/show allow-list. Add clip to it.
A player whose predecessor had not released the shared native channel
within three seconds skipped its own native dispose and chained its
release onto that predecessor. If the predecessor's teardown never
completed - one hung 4K session was enough - every later player waited
on the chain, failed to initialize, and playback stayed broken with
"Playback could not be started" until the app was killed.
Initialize and dispose now carry the creating instance's token. The
Android plugins remember which token created the current core and
acknowledge a dispose from any other token without touching the core,
so a dispose that lost the ownership race is provably stale and safe to
send. With that guard, a timed-out ownership wait force-disposes
instead of skipping, settles its release unchained, and frees the
channel for the next session; a dispose watchdog answers Dart even if
a native teardown hangs, leaking that one core instead of wedging all
future playback. Commands wait eight seconds (was three) so a slow but
healthy teardown delays the next session instead of failing it.
Verified on a Shield Pro: 38 back-to-back session races at 0.8-2.2s
gaps with zero failures and balanced teardowns, and a deep-link-over-
playback collision whose stale dispose is ignored, after which Back
returns to the still-playing session and Retry starts the new one.
Apple and desktop handlers ignore the token and keep the historical
skip semantics until they gain the guard.
With video on the MediaCodec plane, real bitstream passthrough, media3
demuxing, and the GL fallback ladder in place, mpv measures at parity
or better than ExoPlayer on every device class tested (Tegra, Mali,
Amlogic armv7, Adreno; API 28-36; phone, TV, and foldable), including
the two historical Android mpv failure modes: low-end TV playback and
Hi10P.
Installs that never chose a backend move to mpv; a stored ExoPlayer
choice is preserved, and the Player Backend toggle stays user-visible
as the escape hatch. The automatic ExoPlayer-to-mpv runtime fallback
is unchanged.
The in-app FFmpeg container demuxer classified every DTS variant as
plain DTS (profile-blind MIME mapping at the demux boundary), so
DTS-HD MA lost its lossless identity downstream, and it ignored
container display-aspect-ratio overrides. media3's extractors get both
right.
Delete the FFmpeg demuxer (JNI, extractor, and its setting); ExoPlayer
direct play demuxes with media3's DefaultExtractorsFactory again, and
mpv - the Android default - reads container display dimensions itself.
close#2124close#2115
mpv's ao_audiotrack hardcoded stereo IEC61937 frames, which cannot
carry TrueHD MAT or DTS-HD MA 8-channel bursts, and the app fed
audio-spdif an empty list in self-defense because mpv
force-passthroughs every codec named there with no decode fallback.
The mpv path therefore decoded everything, and passthrough had to be
enabled by hand on every install.
The Kotlin audio route probe (AudioOutputPolicy) now feeds mpv a
per-route audio-spdif list, the fork's multichannel IEC61937 patch
(pinned libmpv v1.1.3) provides the burst geometry, and E-AC3, TrueHD
and DTS-HD MA bitstream on the mpv path. Android TV installs with no
stored preference default to passthrough on, on both backends; an
explicit user choice is preserved.
Verified by route probing and mpv logs on Shield and Box R; no AVR was
in the loop, so receiver-side decode is unconfirmed.
Software-decoded HDR always collapsed to SDR on Android: the EGL
window surface carried no colorspace, so PQ/HLG content that fell back
to software decode rendered through an SDR surface, and Android was
excluded from the hdr-enabled path outright.
The GL vo now requests a BT2020-PQ window surface (fork patch, pinned
libmpv v1.1.3) and Android joins the hdr-enabled path, so software
decode keeps HDR scanout and tone mapping happens only when the
display genuinely cannot show HDR.
Android mpv drew every frame through vo=gpu: an ImageReader/GLES copy
pinned to 8-bit RGB0 with a 100 ms timed wait per frame, which
truncated 10-bit and HDR output, raced acquireLatestImage, and
performed badly on low-end TVs.
Video now scans out on a SurfaceFlinger video plane through the fork's
vo=mediacodec: decoder buffers are queued to the surface at their
target PTS (av_mediacodec_release_buffer_at_time), so 10-bit and HDR
dataspaces reach the display untouched and frame pacing no longer
depends on GL vsync. Subtitles and OSD render on a sibling transparent
surface presented for the same PTS, frame-locked by construction.
The plane takes decoder buffers only, so software-decoded video stays
on a GL vo, with a chain-failure watchdog re-initializing the output
when the plane refuses a stream mid-session. The GL fallback is vo=gpu
- gpu-next breaks the Tegra GLES linker (#2010) - and GpuVoPolicy
selects gpu-next only when Dolby Vision RPU reshaping needs
libplacebo. AV1 film grain is applied in the decoder
(vd-lavc-film-grain=cpu) because the GL raster grain fallback is not
available on this path.
Pins libmpv-android v1.1.3, which carries the fork patches this path
rides on: the vo itself, BT2020-PQ EGL window surfaces, GLES direct
rendering treated as slow (H.264 Hi10P software decode on Tegra went
from 0.3x realtime with wrong colors to 1.0x with correct 10-bit
output), and multichannel IEC61937 for ao_audiotrack.
Verified on Shield Pro (Tegra/GLES), Pixel 7 (Mali), Box R 4K Plus
(Amlogic armv7), and Galaxy Z Fold3 (Adreno): 520 play/teardown cycles
and 3 hours of continuous 4K HDR playback; HDR engagement confirmed
via SurfaceFlinger dataspace BT2020_ITU_PQ.
On macOS 2.17.1 the UI started flashing garbage: text and Material icon
glyphs rendered as striped noise, and a keypress could flash a whole
glyph-atlas texture across the window. 2.17.0 on the same machine was fine.
2.17.1 moved to Flutter 3.47.1, which made Impeller the default macOS
renderer. Plezy is unusually exposed to that switch: MainFlutterWindow
clears the Flutter view to transparent so the mpv CAMetalLayer behind it
stays visible, so whatever Impeller leaves in the presented drawable is
blended onto the screen instead of being hidden under an opaque frame.
Opt out through the Info.plist switch the macOS embedder reads from the
main bundle at launch, so it applies to release builds too. That restores
the renderer every release up to 2.17.0 shipped; keep it until the engine
fixes for the 3.47 macOS Impeller regressions reach stable.
close#2132
A device with no hardware HEVC decoder could still be handed an HEVC
transcode: the device profile advertised a fixed codec list that assumed
every device decodes everything. Prepending AV1 to reach the AV1 encoders
issue #2131 asks for would have made that worse - an Apple TV 4K and every
iPhone before the A17 Pro have no AV1 decoder at all.
Probe the platform instead. Android enumerates MediaCodecList for a
hardware decoder and iOS/tvOS ask VideoToolbox, both feeding one latched
VideoDecodeCapabilities that the device profile reads when it builds its
codec lists. Desktop deliberately answers nothing: a pre-Kaby-Lake Mac has
no hardware HEVC decoder and an M1 no AV1 one, yet both software-decode in
real time, so narrowing there would force transcodes for nothing. An
unanswered or failed probe advertises everything, so the list never
narrows on missing data.
The transcode target becomes av1,hevc,h264 filtered by the probe. Leading
with AV1 is safe because the server rotates codecs its admin has not
enabled ("Allow encoding in HEVC/AV1 format", both off by default) to the
back before picking one, so it costs nothing on a server that will not
emit AV1.
Audio now accepts everything the path can carry. The direct-play profile
drops its AudioCodec list entirely - an omitted list means "any codec" to
Jellyfin - so an audio stream can no longer be what blocks direct play.
The transcode target lists every codec Jellyfin can put in an fMP4
segment, so a video-only transcode copies DTS or TrueHD instead of
re-encoding it. Two limits bound that string: the server validates it
against ^[a-zA-Z0-9\-\._,|]{0,40}$ when it echoes the list into the
transcode URL, so alac does not fit and * is not a wildcard; and omitting
the key is not "accept everything" here the way it is for direct play,
because the server substitutes the source codec and then ships no audio at
all for a source fMP4 cannot carry.
close#2131
MPEG-TS segments cannot carry AV1, so a Jellyfin server with an AV1
hardware encoder (Intel Arc and similar) could never pick AV1 as the
transcode output no matter what the codec list advertised. Switch the
video TranscodingProfile container from ts to mp4, matching
jellyfin-web's fMP4 HLS behavior; mpv consumes fMP4 HLS on every
platform and the Plex VOD target has shipped it since issue #1859.
close#2131
After 2.17.1 the video went fully black on Windows for as long as the player
UI was on screen and came back the instant it faded out. Audio was never
affected, mpv.conf made no difference, and 2.17.0 on the same machine and
driver was fine.
2.17.1 moved to Flutter 3.47.1, which made Impeller the default Windows
renderer. Plezy is unusually exposed to that switch: the patched engine
presents the UI on a topmost DirectComposition visual that DWM blends over the
mpv video child, so the video is only as visible as the presented frame's
per-pixel alpha says it is. When Impeller's GLES backend gets that alpha wrong,
the OSD's full-screen scrim presents opaque and hides the video outright;
unmounting the controls presents a transparent frame again, which is why the
picture tracked the UI exactly.
Opt out through the embedder Impeller switch that 3.47 added. Unlike
FLUTTER_ENGINE_SWITCHES it applies to release builds, and it restores the
renderer every release up to 2.17.0 shipped without touching the engine patches.
close#2127
During Android direct play, a single dropped connection or network blip
mid-stream kicked an otherwise healthy ExoPlayer session over to the MPV
fallback - on a Shield this showed as random backend switches minutes
into an episode (log pu4ad: "ffmpeg demuxer read failed: -5" with
contentIsMalformed=true, while MPV reopened the same URL fine).
Three defects lined up behind it, all in the 2.17.0 ffmpeg demux path:
- The AVIO read callback turned the input proxy's stored IOException
(its -1 return) into a bare AVERROR(EIO) without marking javaError, so
the extractor classified the failure as a malformed container. media3
never retries a ParserException, so the designed ERR_JAVA ->
IOException -> load-error-retry path was unreachable. The callback now
latches javaError for a negative read and fails fast on every later
read in the same native call, so matroska resync cannot clobber the
stored message or skip past the failed range.
- A failed refill latches AVIOContext error/eof_reached and avio never
drives the callback again, so even a correctly classified retry would
re-fail on the stale error. nativeReadPacket and nativeSeek now clear
the latch on entry; a genuine end of file (error == 0) is left alone.
- FfmpegRandomAccessSource kept a handle whose read had thrown, and its
position matched the retried request, so the retry was handed the same
dead handle. A failed read now drops the handle and the retry reopens.
A transient failure now surfaces as a retryable IOException, media3
reopens the source, and sample delivery resumes gaplessly; persistent
failures still exhaust the retry policy and reach the MPV fallback as
before.
close#2113
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
Linking a Seerr instance had two rough edges, both on the Jellyfin path.
The URL step prepended https:// to anything without a scheme, so a
plain-HTTP instance on the LAN — 192.168.1.5:5055, or a bare host on
Seerr's default port — failed with "could not reach" unless the user knew
to type the scheme. And the only way to sign in with a Jellyfin account
was typing a username and password, which on a TV remote is misery.
Schemeless input now expands into candidates that are probed together,
but TLS wins by construction: a plaintext success is held while any https
candidate is still in flight and is accepted only once they have all
failed. The sign-in that follows posts a password to whichever URL wins
here, so a slow-but-working https endpoint has to beat a fast plaintext
one, and the wait is already bounded by the probe timeout. When nothing
answers, the failure that reached a server outranks a transport error, so
"this instance has not completed first-run setup" is not masked by "could
not reach https://..." from a candidate the user never typed.
Quick Connect goes through Seerr's own proxy routes (3.4+): initiate,
then check polled at Seerr's own 2s cadence through the shared
pollWithBackoff — a 404 mid-poll means the secret is gone and is terminal
— then authenticate, which mints the session cookie through the existing
sign-in path. The secret rides in a query string, so it is registered for
log redaction the way the Jellyfin flow registers its own. An instance
that predates the routes 404s the initiate and is told apart from a
generic rejection.
SeerrAuthMethod.quickConnect deliberately stores no secret: silent
re-auth is impossible by construction, so an expired cookie lands in the
existing "no stored credentials" arm, unlinks the session, and the
connect flow asks for a fresh code. The affordance is limited to the
Jellyfin credential form, since Seerr rejects Quick Connect for Emby, and
is not auto-started on TV the way the MediaBrowser form is:
/settings/public exposes no "Quick Connect enabled" flag to gate that on.
The waiting panel and its attempt/cancel bookkeeping moved out of
AddJellyfinScreen into QuickConnectCodePanel and QuickConnectFlowMixin so
both screens share one implementation, and the post-frame focus helper
both forms use moved to AsyncFormStateMixin.
Turning what a user typed into URLs worth probing lived inside
JellyfinEndpointDiscovery, next to a private copy of the scheme-detection
regex that url_utils already had. A second backend needs the same rules
with a different default port, and the existing copy carried two latent
defects: input with no host came back unchanged, so a candidate that can
never resolve went to the prober, and a query string survived into the
base URL that request paths are appended to.
expandBaseUrlCandidates takes the ordered guesses a backend wants — a
scheme plus the port to try when the user typed none — and owns the rest.
A typed port beats a guessed one, so host:8096 collapses to one candidate
per scheme and the same guess list covers both bare hosts and hosts with
ports. Candidates are built field by field rather than through
Uri.replace, whose null query means "keep mine", so query and fragment
are dropped for real. Blank input and input with no host expand to
nothing.
JellyfinEndpointDiscovery passes its existing order, so its probe and
persistence behaviour is unchanged, and hasUrlScheme replaces the regex
copy.
The random-access demuxer added two surfaces with no gate behind them. A
download stored through SAF arrives as content://, where an index read has to
open a second descriptor while the loader holds one, and the AVIO callbacks
are reached only by name from C++, so R8 may rename them while every debug
check passes -- the shape #1703 shipped with.
FfmpegExtractorSeekTest now runs the cued fixture over a content:// URI
served by a test-only provider, and FfmpegDemuxerReachabilityTest drives
nativeOpen plus a seek through a media3-free byte source so it can run
minified. Wired into the existing R8 reachability target; verified it fails
when the FfmpegDemuxerJni$Input keep rule is removed.
Resuming or seeking a large MKV on the Android FFmpeg demux path buffered
for minutes with no error and no fallback. The cause was the seam, not the
container: libavformat's demuxers own their seeking — end-of-file index,
back to the header, binary search — while media3's Extractor is forward
only, so every backward jump had to abort the in-flight libavformat call
with a synthetic IO error and replay the whole open. matroskadec attempts
its deferred Cues parse exactly once per context, so that abort burned the
index for the session and every later seek fell back to libavformat's
linear generic scan, reading the file up to the target.
That protocol had produced a bug of this shape repeatedly (a NULL deref
resuming find_stream_info across an abort, a use-after-free on fallback
teardown, matroskadec resync skipping the keyframe cluster) and it was held
together by four empirical retry budgets whose exhaustion degraded silently
— which is why #2096 looked like an unbounded spinner instead of an error.
Give libavformat what it requires instead. FfmpegRandomAccessSource serves
any absolute position from a second DataSource built by the same factory
media3 uses, cloning the DataSpec media3 opened for the item so Cronet,
download caches, SAF and per-item request headers still apply. Reads that
the loader's ExtractorInput can serve still go through it, so sample
delivery keeps feeding media3's byte accounting, back-pressure and
load-error policy; the extractor nudges the loader to follow the demuxer
with one RESULT_SEEK, which is an optimization that can never stall
playback because the read succeeds either way.
Deleted with the protocol: the synthetic AVERROR_NEED_SEEK unwind, the
24 MB replay cache, the Cues priming seek and its read guard, the sticky
AVIO error clearing, and all four budgets (seekAttempts, stickyRecovers,
MAX_RECONCILES, MAX_OPEN_LOADER_ROUND_TRIPS). Opens run straight through,
seeks are one avformat_seek_file on the loader thread, and an IO failure is
an IOException that reaches media3's retry policy instead of a silent
degradation. The demuxer is now the only component that resolves seeks, so
the extractor's sample-derived seek index is gone too.
Verified on a SEI Robotics Android TV box against Jellyfin: the DV P8.1
28.4 GB MKV resume that started at a 4m28s spinner opens in 1.1 s and
resolves its seek in one call, a mid-playback scrub resolves in 1 ms from
the in-memory index, a 2 GB MKV resume opens in 0.5 s, fresh playback needs
no index read at all, and MP4/AVI still open and deliver. New coverage:
FfmpegRandomAccessSourceTest pins the read/reopen contract, and
FfmpegExtractorSeekTest asserts on-device that a cued seek lands on target
without walking the file and that a cueless file still reaches it.
close#2096
The demuxer JNI read AV_PKT_DATA_DOVI_CONF as the bit-packed dvcC/dvvC box
layout, but libavformat surfaces the unpacked
AVDOVIDecoderConfigurationRecord — one byte per field. Profile 8 level 6
streams therefore surfaced as "dvh1.04.00" plain HEVC (visible in #2096's
logs): DoviConvertingTrackOutput's profile 7 conversion could never engage,
and profile 8 was routed to the plain HEVC decoder instead of the Dolby
Vision decoder the media3 demux path selects.
Read the struct fields verbatim and mirror media3's DolbyVisionConfig in
Kotlin: recognized profiles publish video/dolby-vision with the
dvhe/dvav/dav1 RFC 6381 codecs string, unrecognized profiles keep the base
codec's format. A DV P8.1 MKV on the FFmpeg path now reports "dvhe.08.10",
mime video/dolby-vision, and takes the DV P8 passthrough path exactly like
the media3 demuxer.
Playing a downloaded movie or episode failed outright once the storage
holding it was gone — an SD card ejected, a USB volume unplugged, the
files deleted from outside the app (issue #2101). The item still played
fine from the server, but Plezy never tried: it committed to the local
copy and surfaced mpv's open failure.
Resolution already refused a missing local copy and let the caller
stream instead, but only for filesystem paths. SAF `content://` URIs
skipped the check on the premise that they are always playable as
written. They are not: an unmounted volume answers
`FileNotFoundException: No root for <volume>`, and a revoked grant is
just as dead, while the row still reads `completed`.
Probe the stored document through SAF before preferring it, so an
unreachable copy resolves to nothing and every consumer degrades the
way a missing file already did — the player streams, and both
external-player entry points launch against the server instead of a
dead URI.
close#2101
Siri Remote navigation felt sluggish and then over-sensitive next to
native tvOS apps (issue #2006): swipes were priced at a fixed travel
per step, a single flick could glide into a second focus step, hard
lifts coasted several extrapolated steps, and rail scrolls snapped in
65-250ms where native glides.
Retuned the whole path against two hardware instrumentation passes on
an Apple TV 4K: committed-move telemetry through an experimental
UIFocusItem bridge (branch feat/tvos-native-focus-bridge), then a
dedicated native probe app logging every touch sample, pan velocity,
engine hint, focus step, and scroll tick across 101 swipe sessions on
160/230/300pt tiles. What the data showed, now encoded:
- step pricing follows geometry: one step costs the focused item's
extent along the swipe axis plus ~155pt (measured 314/391/410pt on
160/230/300pt tiles), not a fixed distance. Thresholds derive per
axis from the primary focus rect, normalized so a wide-flat control
steps vertically once the finger covers its height. Locked-focus
rows (hub rows, the TV browse rail) vend their selected card's rect
through the new LockedFocusRowNode so the row-wide focus node's
screen-sized rect never prices the step. Scopes, the player's
catch-all surfaces, and unbuilt cards fall back to a fixed 400pt.
- a lift never coasts more than one step: sessions with lift
velocities up to ~11400pt/s never produced a second coast step. The
glide is gated on a sustained drag (two consecutive same-direction
steps), cancelled by reversal pivots and new touches, so a discrete
flick moves exactly one item.
- the native 'inertia' feel is the scroll animation, not focus
physics: the engine's scrollable containers settle over ~450-900ms
of ease-out. TV rail and hub-row navigation scrolls now retarget a
500ms easeOutCubic animation per step, so drags and hold-repeats
chain into one continuous glide that catches up on release.
GLib's GMutex is a raw futex, so ThreadSanitizer cannot see the
happens-before edge g_main_context_invoke_full provides and fails the
thread lane on plane_render_executor_test. Publish the completion
payload through an explicit release/acquire pair, and suppress the
remaining reports that originate wholly inside libglib's own source
bookkeeping. Races on repository memory still fail the run; verified
0/100 failing TSan runs in an ubuntu:24.04 container (20/20 before).
With "Ask for profile on app open" enabled, every screen-off or app
switch on a phone re-pushed the profile picker and PIN on resume, burying
a companion-remote session that survives backgrounding since the
reconnect cycle was made lifecycle-aware. Suppress the resume prompt
while a companion session is live, mirroring the active-playback
exemption — the session already belongs to the profile that started it.
close#2087
VP9/WebM-origin files stalled at 0ms in ExoPlayer on 2.17.0 and fell
back to MPV after ~40s. The ffmpeg demuxer published raw extradata as
the only initializationData entry; Android's Opus decoders consume
their first three input buffers as OpusHead/codec-delay/seek-pre-roll,
so they ate the first two real packets as configuration and silently
discarded every decoded sample, pinning the audio-driven clock at 0.
Vorbis (unsplit Xiph lacing) and FLAC (unmarked STREAMINFO) had the
same shape divergence.
Shape audio extradata into the layouts media3's own extractors emit
before publishing the track format.
close#2088
After set_mpvkit_revision.sh moves the MPVKit pin, macOS builds die in the
SwiftPM integration migration with "could not find the commit <sha>": Flutter
runs the macOS Xcode steps with -skipPackageUpdates, and its one fetch-capable
step only ever runs for iOS, so the local mirror never learns the new revision.
scripts/refresh_apple_spm.sh runs a single -resolvePackageDependencies per
platform to update the mirror without touching the tracked locks, with --reset
for an internally inconsistent SourcePackages directory. The header of
set_mpvkit_revision.sh now points at it.
Non-English users saw English text in a dozen places and blank labels in
sixteen more.
The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.
The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.
Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
Both Linux sanitizer jobs have failed since 974806fe. It registered
plane_render_executor_test with ctest, but the reliability job compiles an
explicit target list and the new target was never added to it, so ctest found
no executable and reported the test as Not Run.
The cross-server aggregation suite still pinned the music Latest row's
`Fields` to the pre-DateCreated set, so the suite has failed on every commit
since 24d7681d added the field. That commit updated the assertion in
jellyfin_client_urls_test.dart but missed the second copy of the same
expectation here, leaving CI red rather than signalling a real regression.
Sub-4K content on a 4K TV was always upscaled by the playback device, so the TV's own (usually better) upscaler never received the native signal and could not apply resolution-specific processing.
Adds a Match Content Resolution setting on Android TV: playback picks the smallest display mode that still contains the video — never downscaling it — and rate-matches within that resolution when frame rate matching is also enabled. A resolution-only switch keeps the refresh rate as close to the current one as possible, and the settle watchdog now verifies the requested mode id so such switches still trigger the Android MPV decoder refresh. Transcodes get their target from decoded dimensions on the post-first-frame path. Mode selection is extracted into a pure, JVM-tested DisplayModeSelector.
close#2073
In-player subtitle search always ended with "Subtitle downloaded, but it
could not be selected": the apply flow polls getVideoPlaybackData for the
new external stream, but the fresh-cache-first fast path (be9197ed) served
the pre-download /library/metadata row for every iteration — playback
start leaves a fresh row, and the first network poll re-stamps it, so the
new stream was never observed and the flow timed out.
Add forceRefresh to PlexClient.getVideoPlaybackData (mirroring
getPlaybackExtras) and pass it from the download poll loop. The forced
fetch still rewrites the cache row, so the subsequent source switch sees
the new track.
On Linux the Home page, a library's Recommended tab and movie/show detail
pages could not be scrolled at all, while Browse, Collections, Playlists,
search, downloads and settings scrolled normally. Those three screens are
exactly the ones whose viewport is covered by horizontal hub rows.
A wheel, trackball, trackpoint or button-scroll event expresses one-axis
intent, but the host reports both axes in the same event: on Linux only
touchpad-sourced scrolling becomes a pan/zoom sequence that resolves in the
gesture arena, and everything else arrives as one PointerScrollEvent
carrying whatever dx the device produced. A Scrollable claims a scroll
signal as soon as the delta along its own axis is non-zero, and the deepest
claimant wins the PointerSignalResolver, so a tenth of a pixel of dx handed
the whole event to the horizontal row and the page behind it never moved.
Collapse a scroll signal onto its dominant axis in the binding, before hit
testing - the last point where the delta can still be corrected, because a
Scrollable has already registered with the resolver by the time it sees the
event. Pan/zoom events are left alone, so trackpad panning keeps both axes,
and a horizontal wheel or tilt still scrolls the row it points at.
close#2081
MPVKit publishes binaries for every commit on its main branch now, so a
version tag is no longer the only resolvable thing. Pinning the revision
means a fork fix is consumable as soon as its binaries land, with no release
ceremony there and no version bump here.
Pinned at 0097fea015fe, which carries the PiP subtitle fix: the iPadOS
Picture-in-Picture window no longer resizes when SRT subtitles appear and
disappear. Nine pin sites move together -- three project.pbxproj
requirements plus the six SwiftPM locks that check_apple_spm_locks.py keeps
in agreement.
tvos/scripts/wire_mpv.rb no longer hardcodes the version it writes; it reads
the revision out of the tvOS project's committed lock, so re-wiring the
tvOS target cannot silently revert the pin. Its test asserts the shape and
that all nine sites name one commit, rather than a literal sha, so moving
the pin stays one command:
scripts/set_mpvkit_revision.sh <40-char sha>
close#2078
In TV mode the sidebar menu floated unreadably on top of the home rows,
the artwork and the settings list — two overlapping sets of text with no
panel behind either.
The rail's surface is transparent on TV so the full-bleed backdrop
continues behind a docked rail, which is safe because the shell displaces
content around it. 2.16.0 turned hover/touch expansion into an M3E modal
panel that overlays content at the collapsed offset instead of pushing
it, so there the rail painted nothing and content showed straight
through the menu.
Keep the transparency rule for every docked shape and paint the surface
whenever the rail floats, reusing the predicate that already gates the
overlay corner radius and edge shadow. The fade now shares the width
morph's duration and curve, so a shorter fade cannot strand a fully
grown, fully transparent panel over the content mid-collapse.
Reproducing needs Force TV mode plus a pointer, which is why this
surfaced on a SteamOS box driving a TV and never on Android TV's D-pad
path.
close#2079
With Audio Passthrough on, (E-)AC-3 Direct Play on Apple TV degraded
into a repeating buzz after roughly half an hour and never recovered
(a 31.25 Hz comb: one stale 32 ms frame looping). During preroll the
system's Dolby pipeline probes the fake endless resource at a fixed
~183 MiB offset; the AO satisfied it with fabricated copies of the
latest access unit, and CoreMedia cached that range by stream offset
and planned its sequential reads around it. Once playback reached the
cached bytes (33 min at 768 kb/s, 40 min at 640 kb/s) it played the
fabricated frames, and its next read landed past the write head, where
the loader kept fabricating frames forever. Seeks rebuild the item at
offset zero, which is why exit/resume bought another 30-40 minutes.
MPVKit 1.0.26 refuses loader reads past the write head instead of
fabricating data; the pipeline falls back to a plain sequential read
from the write head and prerolls normally with nothing poisonous
cached.
Verified on an Apple TV 4K gen 3 with an HDMI capture rig: the 1.0.25
sink audibly and loggably degrades at stream byte 0x0B770000 (8.3 min
into a 3072 kb/s accelerant stream, 40 min at 640 kb/s), while the
1.0.26 sink crosses the same byte with continuous audio on both the
accelerated stream and a full 40-minute 640 kb/s Plex Direct Play soak
through Plezy itself.
close#1776
Jellyfin and Emby users had no way to record live TV from Plezy at all: no
record button in the EPG guide's program menu, and nothing in the recordings
UI, because every recording surface was gated behind a Plex-only DVR adapter.
The recording surfaces are now backed by a MediaBrowser DVR adapter that
synthesizes the neutral (Plex-shaped) payload models from the timer APIs:
- Template comes from `GET /LiveTv/Timers/Defaults?programId=`, offering a
"Record Episode" entry and, for series airings, "Record Series". The
defaults DTO travels JSON-encoded in the template's opaque `parameters`
and setting ids are DTO field names, so the existing template-driven
record-options sheet renders and round-trips them unchanged.
- Create POSTs the mutated defaults whole to `/LiveTv/Timers` or
`/LiveTv/SeriesTimers`; a duplicate one-off create answers 400, which the
adapter rethrows as `RecordingConflictException` so the sheet can show
"Already scheduled" without a backend check.
- Scheduled recordings read `/LiveTv/Timers` minus `Cancelled`/`Completed`
tombstones, rules read `/LiveTv/SeriesTimers` with their child timers
nested. Rule keys carry a `timer:`/`series:` prefix so cancel, edit and
delete dispatch to the right timer space inside the adapter.
Guide programs now carry their recording state: the program id doubles as the
record seed, and `TimerId`/`SeriesTimerId` become the rule keys that drive the
guide's scheduled dot and the Manage action. The series key is stamped only
when an airing actually records, so an episode a series rule skips does not
show a false indicator.
Three neutrality fixes on the shared UI: the record-options sheet only demands
a target library when the template declares one (MediaBrowser records into its
own configured folder), and "Re-evaluate rules" is hidden unless a connected
DVR supports it, since only Plex has that endpoint.
`fetchDvrs` deliberately stays empty so the synthesized per-server Live TV
identity that channel fetches, favorites and playback key off is preserved.
Verified end-to-end against a disposable jellyfin/jellyfin:10.11.11 container
with an M3U tuner and XMLTV guide, driven through the real client: template,
create, duplicate conflict, cancel, series create with child grabs, edit
round-trip and delete.
close#1645
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
A quoted value in the mpv.conf settings screen, copied verbatim from a
real mpv.conf (sub-font = 'NetflixSans-Bold'), reached mpv_set_property
with the quotes included: a string property silently selected a
nonexistent font family and fell back to the bundled font, and a
numeric property failed mpv's parse and was only logged. Either way the
line did nothing. mpv's own config-file parser strips one pair of
matching quotes around the whole value; parseMpvConfigText now does the
same before the startup pass applies the entries.
close#2025
On a 32-bit Amlogic TV box, 57% of UI frames while moving focus along a rail
missed the 16.68 ms budget, and the median frame was already over it at 20.7 ms.
The GPU was idle throughout (raster p90 9.1 ms, zero frames over budget) -- all
of it was main-isolate work, split roughly evenly between layout and semantics.
Five separate causes, all measured:
- The horizontal rail used `itemExtentBuilder`, so `RenderSliverVariedExtentList`
walked every preceding index on each realized child, in every layout pass, and
re-resolved the trailing slot inside the closure. Pinning the trailing cell to
the card extent makes the list uniform, which restores O(1) offset, index and
max-extent math.
- The sidebar-expand tween wrapped the whole shell with a `LayoutBuilder` inside
its builder, so ~15 ticks per focus flip rebuilt `SideNavigationRail` -- and its
non-virtualized child list -- during the layout phase. The tween now wraps only
the content `Positioned`, matching `SideNavigationBleedBuilder`.
- Each nav item crossfaded through two `Opacity` subtrees, i.e. 14-18 offscreen
save layers per frame for the whole 250 ms morph. It now fades colour alpha on
the leaf, the same substitution `AnimatedDimScrim` already documents.
- `HorizontalScrollWithArrows` kept a scroll listener on platforms where its arrow
chrome is compiled out, so crossing either scroll boundary `setState`d a whole
rail row of cards. Two full-row rebuild storms per traversal, for arrows that
cannot appear.
- The rail's semantic proxy rebuilt its label and six closures on every
`_RailFocusModel` notification, including vertical-scroll flips.
Also: one merged listener per `SettingsBuilder` instead of six, and the media card
semantic label is cached rather than rebuilt and then discarded by the rail's
`ExcludeSemantics`.
Measured on the device, median of three runs: frames over budget 120/212 -> 32/246,
total UI-thread work 4067 ms -> 1845 ms, layout p90 18.2 -> 8.1 ms, build p90
7.95 -> 2.42 ms. The sidebar scenario went from 121/148 frames over budget to
32/184.
The offline guarantee rests on hydration being genuinely network-free, not just
on the call sites picking the right entry point. Assert it directly: a hydrated
service must not react to a connection row appearing afterwards — which is what
the boot-time legacy migration does — and `start()` must then pick that row up,
proving the watch belongs to the live side.
Device verification of the airplane-mode cold start is not included: it needs
physical access to re-enable the test box's network. This test covers the same
invariant deterministically.
Two cold-start findings from the same pass. They share a call site in
`MainScreen`'s post-frame block, so they land together.
## Cronet was 33% of time-to-interactive
`createPlatformClient()` built the shared `CronetEngine` inline, so whichever
consumer happened to create the first HTTP client paid for it — and that landed
between `database_ready` and `credentials_loaded`, i.e. squarely on the path to
the first usable screen.
Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by
temporarily forcing the existing `_cronetBroken` fallback so no engine is ever
built:
| phase | engine built inline | engine never built |
|---|---|---|
| database_ready | +455 | +456 |
| credentials_loaded | +1171 | +703 |
| binding_settled | +1331 | +827 |
| main_screen | +1394 | +932 |
So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule
loadModule2NoCrashUtils` then `HttpFlagsLoader` reading
`com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider
*enumeration*, not selection — `CronetEngine.Builder(Context)` calls
`isEnabled()` on every registered provider, and `PlayServicesCronetProvider`
answers that by installing the Play services Dynamite module. `play-services-cronet`
arrives transitively through `media3-datasource-cronet`, and `package:cronet_http`
offers no way to choose a provider, so the only lever available in Dart is *when*
the cost is paid.
Android's `createPlatformClient()` now returns a client that resolves its
delegate per request: the tuned IOClient that already backstops a broken Cronet
until the shared engine exists, Cronet afterwards. Per-request matters — a
`MediaServerHttpClient` builds its client in a constructor initializer and lives
for the process, so deciding once at construction would have pinned primary
media-server traffic to HTTP/1.1 forever, which is a silent steady-state
regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine
from `MainScreen`'s post-frame block.
Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines
(`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build
now runs from +1023 to +1419, entirely after the first screen, and produces no
Choreographer or Davey report — the UI is static waiting on hub content there, so
there are no frames to drop.
## Plex Home refresh raced the offline decision
`PlexHomeService.start()` conflated disk hydration with going live: it decoded
the cached `plex_home_users_*` entries *and* subscribed to connection changes,
installed a refresh timer and fired `_refreshAll()`. It was invoked straight from
a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi
these devices typically have — it started requests that would time out during the
exact window the startup gate needs. Its immediate neighbour
`ActiveProfileBinder` is explicitly not auto-started for this reason and says so
in a comment; the same argument applied here and had simply not been followed.
`start()` is now the live/network entry point and `hydrate()` is the disk-only
half, coalesced and lifecycle-guarded like `start()` already was. The provider
`create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow
picker hydrates, because it reads `current` immediately and is reachable while
offline. Only `MainScreen` goes live, gated on `!_isOffline`, with
`_handleOfflineStatusChanged` picking it up if the session later regains network —
otherwise an airplane-mode launch would never refresh Plex Home again.
Hydration still `_emit()`s, so `stream`'s replay contract holds even when the
network side never starts, which is what keeps a late listener behind a
`combineLatest` off a permanent spinner.
The measurements behind the preceding commits needed a repeatable way to drive a
real Android TV box and read both sides of the frame. `dumpsys gfxinfo` alone is
not enough (it sees the HWUI composite, not Flutter's UI and raster threads), and
DevTools is not scriptable.
- `vmclient.mjs` -- Dart VM Service client over WebSocket, plus percentile and
timeline helpers and a CPU self/total-time reducer over `getCpuSamples`.
- `scenarios.mjs` -- repeatable D-pad workloads. Keys are sent as one batched
`input keyevent` invocation per burst, because a separate invocation per key
costs more on the device than the interaction being measured.
- `bench.mjs` -- runs a scenario and correlates Flutter's own frame phases
(`Animator::BeginFrame`, LAYOUT, SEMANTICS, BUILD, PAINT, `Rasterizer::DoDraw`)
with HWUI framestats, reporting medians over repeats.
- `profile.mjs` -- CPU self/total time by function plus an allocation profile.
- `launch.sh` -- cold-launches the profile build, sets up its own port forward and
prints a host-reachable VM Service URI. Passes `--ez enable-dart-profiling`,
which only `flutter run` supplies by default, and wakes the display first
because `am start -W` silently reports no `TotalTime` when the screen is off.
Not referenced by `lib/` and not packaged; `tools/` is outside the APK.
Nothing sat between the full-screen `RasterizedGradient` scrim and the
position-driven leaves, so `markNeedsPaint` from the timeline slider walked past
the gradient and re-rasterized the entire chrome -- top bar, button row,
timestamps, timeline -- on every 250 ms position tick. That also partly defeated
`RasterizedGradient`, which exists because Skia gradients cost ~10 ms per
full-screen pass on this GPU class.
Around that, four things drove root `setState` calls that only needed to change a
leaf:
- Held D-pad seek called a root `setState` per key repeat, even when the readout
was already visible in the same direction and only the accumulated seconds had
changed. The seek amount now flows through a `ValueNotifier` that
`DoubleTapFeedback` reads, so a burst updates one `Text`.
- `DoubleTapFeedback`'s drift chevron ran `..repeat()` with no stop condition, and
kept ticking at 60 Hz for the ~300 ms the readout spends at zero opacity after
every burst. The controller is now gated on visibility.
- The auto-skip countdown ran a 5 Hz timer that root-`setState`d the controls tree
for the whole intro/credits window, to animate one progress ring.
- The play-next and still-watching countdowns root-`setState`d the player screen
once a second, which re-created `PlexVideoControls` with its ~50 props and
rebuilt the entire chrome to change one digit. Prompt open/close deliberately
stays on the state path, because `playNextFocusNode` is threaded through those
props and D-pad focus depends on it.
Two of the four position subscribers existed only to recompute pointer-hover
tooltips that a D-pad viewer never sees, rebuilding a button subtree 4x/s and
rescanning the chapter list each time; the "ends at" readout reformatted a string
4x/s for a value that changes once a minute. All three now dedupe.
Finally, the buffer path did two sequential `copyWith` calls on a 15-field
`PlayerState` and handed `BufferRangePainter` a freshly allocated list every tick.
`List` has no value equality, so `shouldRepaint` was unconditionally true.
Measured on device during playback with the chrome raised: 6 of 344 UI frames over
budget, p50 4.39 ms, layout p90 1.09 ms. During a held D-pad seek -- the worst
frame-time window on the TV path, because the readout and the decoder flush land in
the same frames -- 1 of 354 frames over budget, p50 4.28 ms, zero raster frames over
budget.
Cold start on the target TV box reaches its first frame 21 ms after `dart_main`,
so nothing Dart-side gates the splash. Everything below is on the path to the
first *useful* frame, which is a strictly serial chain and where the viewer
actually waits.
- `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an
applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole
`ThemeData` again. It was rebuilt five times per cold start, two of them before
`runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by
palette plus `TargetPlatform` -- the platform matters because `ThemeData()`
derives tap target size, visual density and typography from
`defaultTargetPlatform`, so a palette-only key would be wrong under a debug or
test platform override.
- `initializeDateFormatting` ignores its locale argument and builds CLDR symbols
and patterns for all 121 locales synchronously. It blocked the gate ahead of the
database open for data that only content screens use.
- `DownloadStorageService.initialize` ended in a `path_provider` round trip plus
mkdir at the tail of the gate, contradicting the comment above it that already
explained offline artwork is not a launch requirement.
- `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from
`initState` of the widget whose first build produces the first app frame, and
the RSS watchdog installed a periodic timer there whose first useful sample is
15 s away regardless.
- `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main
isolate, uncached, on every registry read and on every Drift re-emit -- and the
binder writes tokens during the startup sweep, so writes re-triggered reads.
Decryption is memoized by ciphertext, with `invalidateCache()` wired into the
preference-store repair path so a repaired install cannot serve stale plaintext.
- `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around
the legacy migration are deliberately not merged; only genuinely concurrent
callers share a snapshot.
- `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit
purely to compare, allocating two maps and two strings each time.
- The splash rendered one `CircularProgressIndicator` per pending server on top of
the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of
`awaitBindingSettle` -- competing with the startup work they were reporting on.
Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms,
`credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is
unchanged at ~18 ms, as expected.
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.
- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
`resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
the same provider in `ResizeImage`, so it decodes again under a different cache
key. The comment already said the intent was only to warm the disk cache, so it
now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
viewer sits still and evicting the rail posters behind it. The fade is already
zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
`getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
`backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
`CachedNetworkImage` directly, keeping the sized request but losing the
`plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
re-downloaded on detail open. The season strip also discarded its height bound.
Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
The remembered window placement restored invisible when its monitor was
gone (undocked laptop, powered-off TV), briefly flashed a blank window at
the restored spot before the first Flutter frame, and could lose the
maximized state: the exit path hides the window before a multi-second
teardown, so a debounced save landing in that gap recorded SW_HIDE, and a
window closed while minimized-from-maximized restored as a normal window.
Validate the saved rect against current monitors and keep only the size on
a miss, apply the placement with SW_HIDE so the first-frame callback stays
the single show, skip persisting while the window is hidden, and honor
WPF_RESTORETOMAXIMIZED when deciding to relaunch maximized.
The detail hero builds its own pill around InlineRatingBadges since the
hero-chip rework, leaving MediaRatingBadgeGroup without a caller and the
unused-code check red. Remove it and inline the style helper its removal
left with one call site.
PR #1666 merged without compiling: it maps existing connections into the
add-server discovered list, but DiscoveredJellyfinServer gained a required
dialect with the Emby backend. Carry the connection's dialect through and
list only connections matching the screen's dialect, so the Add Emby screen
does not offer existing Jellyfin servers.
The three browse chips opened bottom sheets on every form factor. On
desktop platforms they now anchor dropdown popups to the chip: grouping
is a radio menu, sort shows the direction on the active field
(re-selecting toggles it) plus Clear, and filters use a two-level menu
with per-category values and an All row to clear one category. Phones
and TV keep the existing sheets.
The backend cards drew the outline focus border; the wrapper now sits
inside the opaque Material with useBackgroundFocus so the fill paints
above the surface, matching the other settings surfaces.
The Active badge and connection chips used opaque primaryContainer /
surfaceContainerHighest - both resolve to the card surface color in the
mono scheme, so they punched dark holes in the focused tile's background
highlight, and boxed pills fight that fill however they are tinted. The
Active badge becomes a muted check icon + label next to the name, and
connection chips become plain muted icon + text entries, matching the
app's other meta rows.
Switching to the Explore tab hands focus to the first hub, and
HubSection unconditionally ran Scrollable.ensureVisible, scrolling the
app bar and search field off-screen in touch mode. The scroll is now
gated on keyboard/D-pad input mode, mirroring FocusableWrapper's
autoScroll.
The hero metadata strip wrapped to a second run, leaving the Rate chip
alone on its own line (or clipping it away on short heroes). The strip
now measures chips and sheds them by usefulness - scores pill first,
then quality labels, edition, certification, runtime - never the year
or the interactive Rate chip, mirroring the TV FittedMetadataLine.
Episode rows replace the 'E0' primary-container chip with an inline
'3. Title' prefix that mirrors the server-provided number, including a
genuine episode 0. Phone section gaps drop from 24px to 12px so Cast,
Extras, and the related hubs share one rhythm; TV keeps its own values.
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.
Moving UP between hubs showed the focused card's glow immediately: the
glow paints in the root overlay, unclipped by the rail viewport, so it
flashed over the spotlight while the target row was still offscreen.
The rail now suppresses only the glow for the 250 ms vertical scroll
and lets it fade in once the row settles. FocusGlowOverlay defers
attached-portal shows out of build, which OverlayPortalController
asserts against.
Profile tiles and buttons drew the outline focus border while every
other settings surface highlights with a background fill. Tiles nest
the FocusableWrapper inside the Card so the fill paints above the
opaque card surface; buttons opt into useBackgroundFocus.
The QR sign-in screen stacked Retry above Cancel with intrinsic widths,
reading as sparse on a TV canvas and pushing Cancel off the bottom edge.
Both wait states now share one centered row, matching the player's
initialization-error surface.
On a Dynamic Refresh Rate display (e.g. Surface 120Hz panels), stopping
playback with Match Refresh Rate enabled left Windows pinned to a fixed
60Hz with DRR disabled: the legacy EnumDisplaySettingsW capture only sees
the DRR base rate, and a DEVMODE ChangeDisplaySettingsExW restore cannot
express the CCD-level DISPLAYCONFIG_PATH_BOOST_REFRESH_RATE selection.
Capture a virtual-refresh-rate-aware QueryDisplayConfig snapshot of the
topology before the first display mutation and restore it through
SetDisplayConfig (saving to the database to repair the 24/48/60Hz
registry-workaround write-back), keeping the DEVMODE path as fallback.
The HDR toggle mode dances re-apply the same kind of snapshot, and crash
recovery returns to the persisted display configuration via
SDC_USE_DATABASE_CURRENT, verified against the recorded mode before the
legacy restore takes over.
close#2055
The TV media detail page felt cramped on 1080p TVs (540 logical px): the
bottom rail's title strip, card label bands, and next-hub peek consumed
nearly half the screen and cut the next hub's posters mid-card at the
screen edge, while the hero packed logo, metadata, genres, summary, and
action row into 5-10 px gaps against the back arrow, shedding summary
lines to fit.
Slim the shared rail metrics (hub strip 36->30, next-hub peek 30->20,
label band 42->36, person labels 58->52, height slack 14->10) and the
detail page's rail card scale (0.8->0.72), then spend the freed height
on the hero: larger gaps between metadata/genres/summary/actions, a top
offset that clears the back button, and an info column widened from 57%
to 60% of the screen. Movie and show details now keep three spaced
summary lines, and the wider metadata line fits one more rating badge
before shedding.
The #1893 metadata-line regression test pinned the exact badge-shed
count at its test viewport; it now asserts rendered badges stay fully
on screen while keeping the quality-label guards.
On TV the detail hero caps the description at three lines with no way to
read the rest, and the fitted metadata line sheds rating badges and
quality labels when they do not fit, leaving that information
unreachable by D-pad.
The hero information block is now a focusable target: D-pad up from the
action row lands on it, and select (or tap) opens a sheet with the
complete metadata fields, every rating badge, the genres, and the
untruncated description. Long descriptions page with D-pad up/down.
close#2042
The Android performance overlay showed only sample rate and channels for
EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec
from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide
(hence AAC working), and the bitrate from Format.bitrate, which Matroska
carries only when the muxer wrote BPS statistics tags.
Fall back to the already-transmitted sample MIME type for the codec name
(audio and video), and measure the audio bitrate in the FFmpeg demuxer
from packet sizes over their pts span — the same source mpv uses for its
audio-bitrate property — when the container declares none.
close#2063
The Auto demuxer mode routed only media3's weak container families
(AVI, ASF/WMV, MPEG-PS, Matroska/WebM) through FFmpeg and kept MP4/TS
on media3's extractors, with a second any-container FFmpeg instance
behind media3's list. With the goal of fully switching to the ffmpeg
demuxer, the split served no purpose and the catch-all could never add
coverage once the primary accepts everything.
Collapse the preference to two modes: FFmpeg (default), which demuxes
every progressive container ahead of media3's list, and media3 only as
the user-facing escape hatch in case a file misbehaves. media3's
extractors stay behind FFmpeg, so anything FFmpeg cannot sniff still
reaches them. The catch-all role and the Auto container list are
removed; unknown persisted wire values (including the retired "auto")
resolve to FFmpeg.
Verified on a Pixel 7: MP4, TS, and MKV all demux through FFmpeg under
the default ("sniff accepted mov,mp4 / mpegts / matroska,webm") with
forward and backward seeks landing; JVM suites and analyzer pass.
Direct play on Android kept accumulating container patches: AVI with
XviD packed-bitstream timestamps and missing VOL csd played broken,
ASF/WMV and MPEG-PS/VOB had no media3 extractor at all, and MKV needed
a custom extractor stack for zlib-compressed subtitles, LOAS/LATM
audio, cueless seeking, and font attachments.
Demux progressive containers with libavformat behind media3's
extractor API. An AVIO bridge serves libavformat from the
ExtractorInput: every position divergence defers into a RESULT_SEEK
round trip, header reads replay from a block cache while
avformat_open_input restarts, and avformat_seek_file executes seeks
with bounded loader round trips. Packets feed media3's TrackOutputs,
so decoders, passthrough carriers, Dolby Vision RPU/EL conversion
(dvh1 codecs string surfaced from DOVI side data), and the subtitle
pipeline are untouched: embedded fonts feed AssHandler over JNI, ASS
reaches libass as per-sample dialogue, SRT/VTT render as cues, VobSub
maps through media3's VobsubParser, and LOAS/LATM AAC unwraps via
LatmTrackOutput.
Under the default Auto preference FFmpeg demuxes AVI, ASF/WMV,
MPEG-PS, and Matroska/WebM and sits behind media3's list as an
any-container fallback; MP4/TS keep media3's extractors. A playback
setting exposes auto / FFmpeg first / media3 only, and the JNI
load-failure path falls back to stock media3. The custom Matroska
extractor stack and its reflection keeps are retired.
Verified on Pixel 7 (API 36), Box R 4K Plus TV (API 34), and SHIELD
TV (API 30): instrumentation playback suites, the minified R8
reachability gate, and an on-device container matrix with forward and
backward seek landings plus visual subtitle checks.
close#2052
The player UI was choppy and slow while video played, and scrub-bar
thumbnails rarely appeared until the video was paused (worst with 4K
HDR content, whose per-frame tone-map render is expensive). The cause:
mpv's render and the plane's eglSwapBuffers ran on the GTK main thread,
which also rasters Flutter's UI and dispatches input, so every UI
repaint and pointer event waited out the video frame render.
Move the render + swap onto a dedicated plane render thread
(PlaneRenderExecutor). All Wayland protocol state stays on the main
thread: Present() splits into PreparePresent() (gates + frame-callback
request) and CompletePresent() (first-frame scale flush, ack watchdog,
mid-flight hide/rect-loss re-detach). The plugin serializes one job at
a time, defers rect application and HDR transaction starts to the job
completion so a resize never races the swap and a staged colour
transition can never pair an old-colour buffer with a new description,
and drains the worker before disposal - which is what lets
RenderToSurface render without holding native_mutex_. A worker wedged
inside a driver call is abandoned after a bounded wait and the
session's player and plane are deliberately leaked instead of freed
under it. PLEZY_PLANE_RENDER_MAIN_THREAD=1 restores the old inline
behaviour as a temporary escape hatch.
Measured in a headless-sway container with a 4K test file (llvmpipe
inflates render cost the way DV tone-mapping does on real hardware):
idle-playing UI commits went from ~354 ms to the keep-alive's ~100 ms,
and pointer reads from ~185 ms bursts back to input rate; the #2067
hide/backoff/recover log signature is byte-identical.
close#2057
Switching workspaces on KDE or a wlroots compositor hides the toplevel
without any GTK visibility change, and the compositor stops answering
the video plane's frame callbacks. Once the ack watchdog spent its miss
budget it stopped dead: the giveup destroyed the only outstanding
wl_callback, mpv's edge-triggered redraw latch was already saturated by
a render that bailed on frame_pending(), and no visibility change ever
comes - so video stayed frozen on the last buffer until a resize or
fullscreen toggle, while audio kept playing.
Back off to a slow re-present timer instead of stopping. A present only
happens when mpv actually has a new frame, so a hidden playing plane
settles at one commit per ~1.5 s and recovers within a second of being
shown again; a paused hidden plane goes dormant and is revived by mpv's
next update edge.
close#2067
Plex's tag.tag- removes comma separated tags and has no escape syntax, so
removing a tag containing a literal comma ('Action, Comedy') split it at the
separator and could over-remove siblings named 'Action' or 'Comedy'.
Fields whose removals contain a comma now defer to one removal per request
after the main edit; every request restates the kept tags and the lock, so
the sequence converges regardless of order. Removals without commas keep the
exact single-request wire shape.
The JVM-reflection scan only recognized string-literal lookups, so a
Class.forName or getDeclaredField written with a constant, variable, or
concatenated name was silently skipped and a release build could lose the
member while the check stayed green — inverting the fail-loud behavior the
checker applies to every native lookup it cannot trace.
Unclaimed reflective occurrences now error like the cannot-trace paths, and
the forName pattern requires the closing paren so a concatenated argument is
not half-claimed as a bare literal prefix. Production reflection stays
all-literal, so repository validation is unchanged.
Hiding the chrome in the frame between the post-frame opaque mark and the
next build trusted an opaque flag the renderer never realized: hide() kept
controlsPresented while the fade-in target never rendered, so no fade-out
ran, markControlsHidden never arrived, and Back/Escape stayed swallowed
until a later show/hide cycle repaired the flag.
The controller's opaque flag now follows the real fade-in completion via
AnimatedOpacity.onEnd, so any hide it observes can rely on a fade-out that
will actually run. Branches that mount the chrome directly at full opacity
keep marking immediately, since those insertions fire no onEnd.
The Latest Albums see-all sheet offers a Date Added sort, but its rows were
requested without DateCreated, so addedAt mapped null and the sort silently
compared nulls — the same gap the earlier DateCreated work closed for catalog
and hub rows. DateCreated is a direct dto property, not one of the per-row
COUNT fields that motivated this set's slimness (#1552), so the cost profile
of these folder-dto requests is unchanged.
Backing out of a focused text field on TV fires onBack on KeyDown and arms
BackKeyUpSuppressor so the orphaned KeyUp cannot run a second back. When the
closing IME session swallowed that KeyUp entirely, the armed state persisted
and silently consumed the next back press anywhere in the app.
A matching KeyDown now proves the suppressed press ended without its KeyUp
reaching us: the arming clears and the fresh press acts normally. The select
suppressor keeps consuming KeyDowns because the hotkey recorder arms against
the very KeyDown it re-dispatches; KeyRepeat while armed is still consumed.
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.
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.
Long-pressing a PIN-protected local profile in the picker offered Manage and Delete with no verification, so any user could open its detail screen, clear its PIN, or delete it outright. Both actions now verify the target's PIN first (active profile and Plex Home profiles keep their existing flows).
HDR state was recorded for the display playback started on, but the restore gate queried whichever monitor the window sat on at exit; moving the window to another monitor could skip the restore, clear the change flag, and delete the recovery record, leaving the original display stuck in HDR. The gate and any repeat toggle now resolve the recorded target.
A repeat SetHDREnabled issued after the window moved also stayed pinned to the recorded display: playback moved from HDR monitor A to monitor B re-toggled A, returned success, and left B untouched even though the Dart caller had probed B. With a change live, the operation now restores the recorded display first (retiring its recovery record exactly as a normal restore) and proceeds as a fresh change on the current monitor. If that restore fails the call refuses and keeps the old record so no display diverges from its recovery state; if the current monitor cannot be determined it stays pinned to the recorded display.
Two callbacks with the same state could both exchange the authorization code upstream (concurrently or by replay); the session is now atomically claimed under the proxy lock before the exchange.
GET /posters/ and the /auth/result long-poll had no per-IP limiting or concurrency bound, and every poster lookup serialized through an exclusive store lock; both endpoints now use the established limiter and non-expired hits take a read lock. The limiter also tracks active transfers per IP with caps below the global limits (4 for fetches, 2 for uploads), because one unauthenticated client could otherwise take all 16 global poster-fetch slots and hold them through slow ServeContent reads, starving everyone else with 429s; concurrency checks precede bucket charges so a capped denial consumes no admission tokens.
/auth/result gets its own per-IP budget instead of sharing /auth/start's burst-3 bucket, where two concurrent NAT'd sign-ins 429'd on the fourth request. It is charged only after the poll secret validates (bogus requests keep the generic 410 and cost nothing), denials carry an honest Retry-After, and the Dart poller - which treated 429 as terminal and abandoned a valid session - retries them until the session's 10-minute lifetime expires.
Jellyseerr shifts MediaStatus codes 6/7 relative to Overseerr (6=blocklisted, 7=deleted vs 6=deleted), and the shared unconditional mapping misread both. The product is now detected from the presence of mediaServerType in /settings/public, persisted on the session, and used to resolve raw wire codes; blocklisted titles are shown as non-requestable.
Request statuses failed(4) and completed(5) previously decoded as pending, blocking re-requests and mislabeling failures; both are now modeled and active requests are defined positively as pending or approved. Seerr also marks a request Failed on arr-push failure while leaving the media status Processing, and precedence checked processing first, so failed titles rendered as Processing and the request sheet kept blocking re-requests; a failed request with no live pending/approved request now wins over stale pipeline status in both the catalog state and the sheet's blocked labels.
Reauthentication completing from a stale session snapshot wholesale-replaced the session and could downgrade a concurrently detected product discriminator back to unknown permanently (the settings cache never reapplies it); adoption now merges the known product.
The non-dismissible file-info loading dialog was popped only from the card's own context; if a list refresh removed the card mid-fetch, both pop sites were skipped and the modal stuck forever. The spinner is now owned by ScopedLoadingDialogController and dismissed in a finally.
Post-restart completion recovery joined directory/filename, losing the base-directory component the Task constructor strips on custom roots. An unresolvable queue head (offline server) broke out of the drain loop, stalling every other queued download; the drain now excludes it and continues. The download tree also read all-cancelled or partial containers as completed.
The queued-download resume was a one-shot on the first online client, so a persisted row skipped because its server was offline stayed queued until restart. The resume now re-fires whenever a server comes online that the last resume did not cover (including reconnects), and a resume landing mid-drain re-drives the pass instead of being swallowed by the processing guard.
Deleting an episode fanned out one network playback-extras request per sibling row; the reference scan is now cache-only, retaining thumbnails when a sibling cannot be resolved. Season/show deletion removes rows sequentially, so that conservative retention would have counted siblings queued in the same batch - a sibling, or the container row whose extras cache is always a miss, made every episode retain its thumbnails and orphaned the files once the batch's rows were gone. The fan-out threads the batch's rating keys through so rows scheduled to disappear neither retain thumbnails nor contribute in-use paths; genuinely surviving rows still trigger retention.
The mandatory profile picker checked its re-entry flag before several awaits, so an initialization notification could stack two requireSelection routes; the flag is now claimed before the first await and the late-profiles edge only fires on a real transition. Returning from playback also reran the full metadata loader, replacing the whole detail screen with a spinner and resetting the selected season; playback returns now use the non-loading watch-state refresh.
A show/hide inside one frame left the chrome flagged presented with no fade to clear it, so back and Escape did nothing until a later full cycle repaired the flag; hide now retires presentation directly when the chrome never became opaque. The sync-offset rows and slider seed also read the global pref while writes and the player used the scoped store, so a title/library-scoped offset displayed as 0ms and the first slider tap jumped from the wrong baseline.
With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates.
An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment.
The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
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.
A Jellyfin live retry that failed before open stop-reported the still-current session (recover returns the receiver), killing /Sessions/Playing for the rest of the session; the discard now skips a recovered session identical to the current one. An in-place reload failing before the open boundary left the progress tracker disposed and nulled, so the resumed stream never reported again and the exit flush was skipped; the re-wire now also runs on the rollback path against the restored metadata. Dismissing the Still Watching prompt via back or next also never re-armed the sleep timer; it now counts as the same acknowledgement as Continue.
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.
AssMatroskaExtractor and MatroskaLatmSupport reflect MatroskaExtractor's private extractorOutput/subtitleSample fields by name, but no keep rule covered them: R8 renames the fields and every MKV direct-play in a release build fails constructing the extractor. Adds the keepclassmembernames rule (descriptors verified against media3 1.11.0 in the Gradle cache) and extends check_shrinker_rules.py to scan Kotlin/Java reflection across all android src/main roots including libass.
A response cancelled before or during body consumption completed as a successful empty body, so cancellation-triggered aggregation could overwrite valid state and a cancelled download could commit an empty file. The managed client now delivers an abort error, and a request whose own abort has fired classifies secondary teardown errors as cancelled (timeouts keep their type). A Plex server whose /hubs leg failed was also counted as succeeded when only the optional music leg returned, letting Discover replace good hubs with a music-only result.
Removing a tag containing a space or symbol silently did nothing: the removed values were pre-encoded and the transport encoded them again, so Plex matched nothing while the UI showed the tag gone. Folder listings and the shared list wrapper also converted every transport failure (401, 500, timeout) into a successful empty list, so an unreachable server rendered 'no folders'/'no results' with no retry while Jellyfin surfaced an error. Failures now propagate to the existing error paths, and the match screen gained one.
Disconnecting a gamepad while holding a D-pad direction or a deflected stick left the synthetic key repeat running forever, since only window blur tore that state down. The disconnect event now runs the same teardown: stop repeats, release held keys, clear pressed/suppressed sets and stick latches.
A cleared skip-marker regex saved as an empty pattern, and RegExp('') matches every chapter title: all synthetic markers became intros or credits, and an end-of-file credits marker could auto-advance the episode. Blank input is now rejected at both validation and save, and the chapter fallback treats already-persisted blank patterns as absent.
Jellyfin gates DateCreated behind explicit Fields, so browse/hub rows mapped addedAt as null: the Date Added sort was a no-op and unplayed Next Up rows sorted dead last in merged Continue Watching. Both base field sets now request it.
Editing a release date re-posted the full DTO with the stale ProductionYear; the year now follows the edited date and clears with it.
The guide requested programmes by MinStartDate, dropping anything already airing; the lower bound is now MinEndDate so overlapping programmes stay in the window.
On the default Android ExoPlayer backend the native core never emits a speed property, so PlayerState.rate stayed at 1.0 forever: the speed sheet checkmark, keyboard speed stepping, long-press 2x restore, and media-session rate all computed from a stale 1.0. setRate now mirrors its value into state like setVolume already did.
On mpv backends, a selected --secondary-sid subtitle later in track-list order overwrote the primary selection because parsing ignored main-selection; the track sheet then badged the secondary as primary and replaced the user's primary on tap. parseTrackList now treats main-selection 0/absent as primary and 1 as the secondary selection.
Tapping Repair on the startup-failure screen always threw: the repair flow received the gate State's own context, which has no Navigator, MaterialLocalizations, or ScaffoldMessenger, so both the confirm dialog and the fallback error snackbar raised instead of rendering. Thread the bootstrap home builder's context (below the MaterialApp) into the repair flow and its failure snackbar.
With two profiles bound to two Jellyfin/Emby users on one server, a legacy bare machine-id lookup (offline downloads, cached playback metadata) could resolve another user's cache row, serving their resume position and token-stamped image URLs. Mirror the write-path guard: when the bare scope matches more than one user, log and return nothing.
A 2-letter preferred language (es, ar, ru, ...) prefix-matched unrelated 3-letter track codes (est, arm, rum), so profile-based audio/subtitle selection could pick a wrong-language track whenever it preceded the wanted one in container order. Route the matcher through languageMatches, which compares exact codes, region variants, and ISO-639 variations.
While a Skip Intro / Skip Credits prompt is up and the chrome is down,
Back walked the screen's staged chain and exited playback. On a remote
nothing else means "no thanks": left/right seek, OK takes the skip,
up/down raises the OSD. Declining an intro cost you the episode.
Adds a stage for it alongside the sheet and content-strip stages that
already run locally in the player controls. Gated on the same condition
as Select's skip path, off on phones (#1938), off without canControl,
and off while a screen-level prompt owns the key.
The claim is latched for the whole press: handleBackKeyAction acts on
the key-up, and the button's own 7s auto-dismiss can fire in between.
Co-authored-by: cajunflavoredbob <cajunflavoredbob@users.noreply.github.com>
Recording indicators never appeared in the EPG and the program sheet kept
offering "Record" for already-subscribed programmes, so rules were created
repeatedly (nine duplicate subscriptions in the linked report). Two defects,
both confirmed against the official web client bundle:
- The sheet's scheduled-state check called
/media/providers/<identifier>/media/subscriptions/mapping/<ratingKey>,
which 404s: PMS mounts that route under the numeric MediaProvider id from
/media/providers. The check failed instantly and unconditionally on every
platform, so the button always read "Record".
- The guide derived its dot solely from cross-matching grab metadata, and
the grab refresh issued right after scheduling raced the server
materializing the grab, wiping the optimistic key before one frame showed.
The grid response already tags subscribed airings with subscriptionID /
grandparentSubscriptionID - the signal the official client renders from.
LiveTvProgram now carries those attributes; the guide checks them first
(grab matching stays as the secondary signal) and keeps local
schedule/cancel actions authoritative until the next grid load. The sheet
resolves Record vs Manage from the tagged rule key without a network call,
falling back to the fixed numeric-id mapping route for untagged airings.
Grab parsing also accepts the nested airing under "Video", which PMS uses
for every non-scheduled grab status in JSON.
close#2009
With Audio Passthrough on, every (E-)AC-3 track on Apple TV dropped
100-220ms of audio every 1.79s. The system pipeline behind the
compressed AVPlayer path reads unboundedly far ahead of the playhead
and consumes its buffer in fixed ~1.8s quanta; the AO's 2s feed lead
meant each refill overran the write head and the renderer skipped the
missing audio to stay on clock. Bitrate- and route-independent, and
invisible to the AO: item status, time control, and the feed margin
all stayed clean while the sink output gapped (#1300, #1776).
MPVKit 1.0.25 feeds the elementary stream 8s ahead of the observed
playhead -- 4x the pipeline's refill quantum -- sizes the ES window
from the configured lead, exposes it as
--ao-avfoundation-compressed-lead, and keeps waiting during preroll
while a slow source (a realtime-pinned server transcode) is still
making priming progress instead of abandoning Atmos for PCM.
Verified on an Apple TV 4K gen 3 with an HDMI audio capture rig
against EAC3 768k and 640k streams: 21 dropouts/min on 2.15.0, zero
across 10.7 minutes with the new lead; a 997Hz sine encoded as EAC3
through the full passthrough path is statistically identical to
first-party AVKit playback of the same tone; a rate-limited server at
0.95x realtime now prerolls late on the compressed path instead of
downgrading, and once the lead is filled mpv's cache pauses absorb
the deficit with the margin intact.
Opening Plezy on a TV box resumes the resident process rather than
cold-starting it, so the Libraries grid kept showing the in-memory
content from the previous session -- hours stale -- until the user
switched libraries and back. Nothing on the resume path refreshed tab
content.
MainScreen now latches genuine backgrounding and, on a resume more than
five minutes later, refreshes LibrariesProvider and sweeps the content
tabs through each screen's in-place Refreshable.refresh() -- skipped
while offline, before startup priming, during playback, or with no
connected servers. LibrariesScreen.refresh() now refetches the selected
library's loaded tabs (the toolbar refresh action) instead of
re-selecting the saved library, which never reloaded them because tab
widgets only reload when the library's globalKey changes.
close#2043
A continue-watching episode card was 180dp on home but 232dp in the
grid behind "see all" at 480dp: hub rows widened the resolved poster
cell (x1.5) while grids widen the max extent (x1.8) before the integral
column packing, and the TV shelf multiplied the rail's tall card by the
same 1.5. Rows now adopt the grid's packed wide cell via
MediaGridDelegate.wideCellWidth, the TV shelf passes the hub's wide
flag to TvBrowseRailLayout.cardWidthFor, and the single widening
scheme is stated in MediaGridDelegate. Grids are untouched.
Live TV "What's On" and catalog related rows are the two TV surfaces
without a TvBrowseRail path; HubSection's own clamp ([210, 340],
unscaled) rendered ~40% larger cards than every neighboring rail and
gave 720p TVs the same 210dp floor as 4K. Delegate the shelf card
width to TvBrowseRailLayout.cardWidthFor so the clamps scale and match
the rails.
Home hub rows showed ~3 large posters per row while the hub's "see all"
page packed 5 small ones on the same 360dp phone. Hub detail was the
only surface on the padding-aware target-count formula, which resolves
ceil(lerp(5, 2, f)) columns regardless of screen width (and 9+ columns
on TV). Drop the flag so hub detail shares the fixed-extent formula
with every other grid, and delete the now-unused
getMaxCrossAxisExtentWithPadding and its usePaddingAware plumbing.
close#2039
Root cause of the broken 'Sign in with Plex' and update links on
portable builds: the bundle ships Ubuntu's libgio, whose compiled-in
helper path (/usr/lib/x86_64-linux-gnu/glib-2.0/gio-launch-desktop)
exists only on Debian-family hosts. glib then falls back to a bare PATH
search, and distros like Fedora keep the helper in /usr/libexec outside
PATH, so every URL/desktop-file spawn failed. Installing the RPM masked
it only because system glib is built with the right path.
Bundle the build host's gio-launch-desktop next to the libgio it was
built with (bundle-libs.sh now fails the build if libgio is bundled and
the helper cannot be found) and export GIO_LAUNCH_DESKTOP from plezy.sh,
which glib checks before its compiled-in path. Covers the tarball and
the deb/rpm/pacman packages alike, since they all ship the same bundle
and launch through the wrapper.
close#1477
Transport from the controls layer ran unawaited with no error handling:
a tap racing player teardown (NOT_INITIALIZED on Android) or a wedged
mpv event queue (SET_PROPERTY_FAILED on Windows) crashed the app. The
controls path now tolerates failures the same way the remote transport
path one function above already does.
The most frequent Dart crash in production: CompanionRemoteProvider's
async crypto rebuild can call stopListening() on a discovery service
that was already disposed, and _emitHosts() then threw 'Bad state:
Cannot add new events after calling close'. Emitting after dispose is
now a no-op.
Deployed 2026-08-20. Adds dartsymbolmap chunk-upload support and
crashpad minidump ingestion; fixes the method-scoped SPA fallback
that 405'd every non-GET API request.
Old: sha-319e0eb@sha256:1e5a2d8ab80e703de4a8a8b15d858ce931609e2226bf1e6d04979c0ca52a3005
New: sha-c996cf3@sha256:540d985c51f953704c8b97c6c568d0ffc107b91223dd5279136cce1c5522598b
Index verified on two hosts; linux/amd64 + linux/arm64 present.
Migration 18 (event_attachments) rehearsed on a cloned DB copy.
Rollback: prior digest + /root/bugs-db-backup-20260820-082218 on the host.
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
Plex "Other Videos" libraries returned items as type=movie with
subtype=clip, which mapped to MediaKind.movie and rendered 2:3 poster
cards that cropped the generated 16:9 video-frame thumbs.
Items keep the movie kind so downloads, add-to, delete-from-server, and
detail navigation stay available, but subtype=clip now renders wide with
the thumb-first artwork clips already use. Sections marked subtype=clip
in /media/providers map to the clip library kind, giving Plex home-video
libraries the same folder-first grouping and wide grid cells as
MediaBrowser homevideos views. Wide-only hubs also keep 16:9 cards in
the poster episode modes, matching the TV rail's gate.
close#2036
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
The controller extraction left a bare $_mediaControls.suspendedForTvBackground
in the lifecycle diagnostic string, so the line logged the controller
instance instead of the boolean it exists to surface during TV
background-suspend debugging.
The Android TV background suspend (#1911) kept its grace timer, the
suspended latch, and four pre-stop position/track fields loose on the
State, with the latch/rollback/consume invariants enforced only by
convention across three lifecycle methods and the redelivery loop.
Move them into TvBackgroundSuspendState: latch() sets the snapshot and
the suspended flag together, clear() rolls both back when the native
stop fails, and consumeForRestore() drops the suspended latch before
handing the snapshot to the restore reload — the ordering the bounded
stop-report redelivery depends on. Pure eligibility predicates stay in
tv_background_suspend_policy.dart; orchestration stays in the
lifecycle part.
The live fallback ladder, retry latch, timeline suspend/resume, and
exit-on-resume flags were poked directly from the error handler, the
play-intent path, the playing-state listener, and lifecycle handling.
Give the live part named operations (_beginLiveLadderRetry,
_retryLiveStreamForPlayIntent, _resetLiveLadderOnPlaybackRestart,
_stopLiveSessionForTvBackground, _consumeLiveExitOnResume) and move
the timeline suspend/resume helpers over from lifecycle, so
LiveTvSessionState is mutated only by live_tv.dart and the initial
tune in the start composition root.
Adjacency, loading flags, the Play Next prompt/countdown, the
transient-retry budget (#1867), the completion latch, and the per-
screen adjacency loader were thirteen loose State fields written by
the episode parts, the reload engine, and the stream listeners. Move
them into EpisodeSessionState — the episode analog of
LiveTvSessionState — so the vertical's mutable state has one home and
the reload engine's touch points (clear prompt on open, reset
adjacency and retry budget on swap, record failure reason) are visible
as writes to one object. Logic is unchanged; the screen disposes the
state object instead of the raw timer.
The dead-stream recovery budget, its progress-based refill, and the
parked latch (#1520) were four State fields written from the commit
chokepoint, the position listener, the reload engine, seeking, and the
transport-intent handlers. Move the state machine into
SpuriousEofRecovery with explicit verbs (interceptEof, retry,
clearPark, resetBudget, onPositionAdvanced); the reload call rides one
injected callback that fills the common in-place-reload arguments. The
reload outcome enum becomes the public MediaReloadOutcome so owned
helpers outside the screen library can consume it.
The mutually-exclusive playback transition state, its lease identity,
the idle completer, and the playback generation counter were four State
fields manipulated by centralized screen methods. Move them verbatim
into PlaybackTransitionGate (tryAcquire/owns/advance/release/forceIdle/
waitForIdle/beginGeneration) so lease discipline is enforced by one
type and parts can no longer touch the raw fields. The transition enum
and lease type become public in the gate's library; leases are only
mintable through tryAcquire.
_hasRenderedFirstFrame had five writer files with hand-rolled
snapshot/restore pairs in the reload and channel-zap rollbacks, and the
paired _hasFirstFrame ValueNotifier was reset independently along the
open path. Move both flags into FirstFrameGate with explicit verbs —
markReady, resetUiForOpen, resetRenderedForAttempt, reset,
forceUiReadyOnFailure, snapshot/restore — so the UI-vs-reporting
asymmetry and the transactional rollbacks are enforced by one type.
The uiReady notifier keeps its identity across attempts; the video
surface, controls, and buffering overlay hold it by reference.
Unit-tests the gate's verb semantics.
Shader preset application, ambient lighting enable/restore/toggle, and
video zoom/boxfit lived as an extension reading per-attempt services
off the player State. Move them into VisualEffectsController, a plain
State-owned helper; the shader, ambient-lighting, and video-filter
services are injected as late-bound getters because all three are
re-created per playback attempt and nulled in teardown. Rebuilds keep
flowing through the single requestRebuild callback.
The CompanionRemoteReceiver callback install/uninstall, home-fallback
save/restore, provider sync handle, and receipt-time volume/seek
dispatch lived on the player State. Move them into
CompanionRemoteBinding, a plain State-owned helper: the binding
instance replaces the State as the receiver's playerOwner token with
identical identical()-guard semantics, and the provider is captured at
bind time because context.read can fail during dispose. Subtitle/audio
cycling stays on the State — the drain loop is bound to the playback
transition lease — and is passed in as callbacks.
The Android TV background suspension latch, availability policy, and
resume/rewind restore logic lived as an extension on the 149-field
player State, with the suspension flag read and reset from three other
parts. Move them into MediaControlsScreenController, a plain
State-owned helper following the established player pattern: the
manager, player, and current item are injected as late-bound getters
because they are re-created per playback attempt. The controller now
solely owns the suspension latch; teardown clears it via
resetSuspension() instead of writing the State field directly.
episode_navigation.dart fused two responsibilities: the episode
next/previous vertical and the screen's generic in-place media
transition engine (_switchPlaybackSource, _performPlaybackSourceSwitch,
_selectSourceSubtitleLocally, _reloadMediaInPlace,
_reapplyScopedPlayerPrefsForItemChange), which lifecycle restore,
spurious-EOF recovery, Watch Together, and the companion remote also
drive. Move the engine and deferTranscodeSubtitleSelection verbatim
into playback_reload.dart so the episode part contains only the
episode feature. No code changes beyond the file split.
Two private formatters mapped the canonical server resolution label to
display text (media_quality_labels, media_version) with edge-case
drift, and several single-consumer helpers added indirection without
callers:
- add shared resolutionDisplayLabel in resolution_label.dart and
migrate MediaVersion.displayLabel and the quality-label builder to it
- drop the resolutionLabelFromHeight compat re-export from
jellyfin_mappers (no remaining consumers) and its stale doc note
- delete TraktCatalogSource.membershipKeysFor, byte-identical to the
CatalogWatchlistMachinery default
- derive the rating sheet backend label from
MediaBrowserDialect.productName instead of a hardcoded switch
- inline EndpointFailoverManager into failover_http_client.dart, its
only importer, and remove endpoint_failover_interceptor.dart
MAL token refresh waited on the general 20s request timeout while the
Trakt and MDBList refresh paths use the dedicated 15s refresh timeout,
so a hung refresh held the 401 retry longer on MAL than on its
siblings. Align MAL with the shared refreshTimeout.
Google Play rejected 2.16.0 (versionCode 142) for an Android Automotive
navigation dead end: on a car, "Sign in with Plex" resolves to the
in-app QR wait, which offered only Retry — and the AAOS system bar has
no back button, so a touch user could not leave the screen without
killing the app. Add a Cancel action to the QR and browser-polling
states that aborts the attempt and returns to the initial sign-in
actions on every platform.
Verified on an API 34 Automotive emulator: Cancel returns to the
landing with the Jellyfin/Emby paths reachable.
The App Submission API stopped resolving apps by package name and now
returns 400 "No app found with the entered Inputs"; it requires the
amzn1.devportal.mobileapp app id from the console URL. Read the id from
AMAZON_APPSTORE_APP_ID, validate it in preflight, and keep the package
name for console-facing messages.
Audio Normalization on the mpv path made every video start with stuttering
audio on Linux: dynamic-mode loudnorm always outputs float64 at 192 kHz, so
the AO opened at f64/192k and PipeWire had to convert/resample on the
deadline-critical path (~4x the per-cycle DSP work) while playback startup
load was still settling. Appending mpv's native format filter pins the
chain back to 48 kHz float, so the conversion happens once on the buffered
decode side and the AO opens a normal stream. Integrated loudness output is
unchanged (-14 LUFS, verified via ebur128). mpv's own format filter is used
instead of lavfi aformat so the fix does not depend on which lavfi filters
each platform's bundled ffmpeg compiles in.
Addresses the normalization half of #1720; the residual startup
micro-stutter with normalization disabled is a separate regression.
Sleeping the device mid-video with "Ask for profile on app open" enabled
left the app wedged on an unresponsive Choose Profile screen (Nvidia
Shield). The picker is pushed on the root navigator, but the player's
focus self-heal only consulted its own nested profile-session route, so
it yanked D-pad focus back behind the picker whenever it lost it.
Route currency now walks every enclosing navigator (isRouteChainCurrent)
before the player reclaims focus, primes loading-phase navigation focus,
or claims the surface on window focus. The resume-time prompt is also
skipped entirely while a video player is active: waking mid-stream
resumes the stream instead of stacking the picker over the session.
close#2034
Closing an episode context menu (or its Rate / File Info sheets) on a
show opened from a Home section scrolled the detail page back to the
top, and on shows with many seasons jumped the season selector back to
the entered-from season. The Home path parks invisible focus on the
initial episode/season target; dismissing the menu restored focus to
that parked node, whose focus-gain auto-scroll then yanked the viewport.
Focus chrome is already keyboard-mode-only, so make the focus-gain
reveal match: FocusableWrapper, FocusableChipStateMixin, and
FocusableTileStateMixin now scroll into view only during keyboard/D-pad
sessions. The gate reads the tracker's live state (new
InputModeTracker.currentMode) because the inherited provider is one
frame stale on the first navigation key of a session. The touch OSK
search submit keeps its jump-to-results via an explicit reveal, matching
the existing pointer-mode convention of pairing requestFocus with an
explicit scroll.
close#2031
Plex Discover's hub, search, and related endpoints ignore includeGuids,
so items opened from those rows carried no tmdb id and the Request
action never appeared — only watchlist items (whose endpoint does
return Guids) could be requested. The gate was frozen in initState from
the row item; it now reads the detail-enriched item, whose metadata
fetch does bring the tmdb id.
close#1959
When mpv bitstreams (audio-spdif), audio-params describe the IEC 61937
carrier - 192 kHz "stereo" for E-AC-3 - so the performance overlay told
users their 5.1 track was playing as 2ch stereo. Every recent "EAC3
downgraded to PCM" report in #1300 reads back this string while the
receiver route stays healthy.
Show the source track's channel layout and sample rate instead, plus an
explicit Passthrough row naming the bitstreamed codec, on both the
mpv-channel and Android mpv-fallback stats paths.
Finishing an episode of a show set to rewatching stomped the entry back
to watching. The scrobble write now reads the entry's status and rewatch
count in the same request as the episode count, keeps REPEATING /
is_rewatching entries rewatching, starts a rewatch when progress lands on
a completed entry, and bumps the rewatch count when a rewatch completes.
close#2026
Swipe navigation felt sluggish next to native tvOS apps: Plezy priced a
focus step at 0.55x the focused item's extent, throttled repeats to 140ms,
and stopped dead on finger lift - the inverse of the native engine's feel.
Retuned AppleTvRemoteTouchService to on-device measurements of the native
focus engine (issue #2006):
- one focus step per fixed 400pt of touch travel; drop item-extent scaling
(measured step distance was identical across a 230pt and a 345pt axis)
- repeat cadence 140ms -> 60ms (native median)
- fast lifts glide one or two extra steps (>=2000 / >=8000 pt/s) within
~130ms of the lift; reversal pivots and new touches cancel the glide
Posters and episode thumbnails looked noticeably soft on Fire TV sticks and
similar boxes: the auto-detected reduced tier capped artwork DPR at 1.5 and
tightened tile decode caps, so every tile was fetched at 56% of its pixels
and upscaled twice on large 4K panels.
Drop the reduced-tier DPR cap and the poster/square/thumb decode caps so
tiles fetch and decode at full TV density on every tier; raise the reduced
image-cache budget to the 64MB TV baseline to absorb the larger tiles.
Backdrops keep their scrim-masked ~720p reduced caps, and the display budget
factor stays pinned to 1.0 there, so the low-RAM art ceiling is unchanged.
close#2020
Adding an Emby server on Apple TV locked the app up after entering the
URL: the server probe answered inside the URL keyboard's dismissal
animation, so the username field's system keyboard silently never
presented while its editing session kept ownership of every remote
press — leaving only Menu, which suspends the app. Jellyfin was
unaffected in practice because Quick Connect skips the username focus.
Engine 3.44.0+6 defers first-responder takes until the in-flight
keyboard presentation clears and routes remote presses to the engine
whenever no keyboard is actually on screen, so the session can no
longer strand the user.
close#2011
Direct Play of any (E-)AC-3 track on Apple TV developed persistent
micro-stutter: mpv's audio clock was derived from es_pts on the
compressed path, which is credited a whole 32 ms burst the moment a
payload lands, sawtoothing the clock on a ~0.8 s cycle that video
retires as visible skip/hold pairs. AAC content was unaffected, which
is why the symptom tracked Dolby audio and survived every VO-side fix.
MPVKit 1.0.23 measures the clock from IEC samples actually handed to
ao_read_data instead. Verified with an HDMI capture rig against the
CI-built binary: EAC3 display cadence is now statistically identical
to AAC (was 47.6 skips/min plus 453 multi-frame holds over 38 min).
close#1776
Embedded subtitles on mkvmerge-muxed files with compressed subtitle tracks
(the anime-release convention, reported against AV1 encodes) never rendered:
the track selected fine but nothing ever appeared on screen.
Two defects compounded. ContentCompAlgo 0 (zlib) is the Matroska default
value, so mkvmerge omits the element and the explicit-value detection never
engaged - media3 ignores an empty ContentCompression and silently emits
compressed samples. And for text subtitle tracks TrackOutput-level inflation
can never work: MatroskaExtractor prepends the plaintext timecode prefix to
the still-compressed payload and truncates the sample at the first NUL byte
before any TrackOutput runs, while AssTrackOutput feeds that same internal
buffer to libass, which then parses zero events and reports every frame as a
changed empty render.
Treat the presence of ContentCompression as zlib until an explicit
ContentCompAlgo says otherwise, and for SRT/ASS/SSA/VTT tracks inflate the
block frame payload before the parent's subtitle sample assembly, leaving the
TrackOutput wrapper to the formats it can actually handle. Blocks that are
laced, corrupt, or over-bound pass through byte-identical. The detection fix
also makes the existing whole-sample TrackOutput inflation engage for
PGS/VobSub zlib tracks, which media3 does not truncate.
Verified with mkvmerge zlib/plain fixture extraction tests and end-to-end on
an API 36 emulator against a Jellyfin direct-play mux mirroring the reported
file.
close#2023close#2022
The custom sidebar read as an ad-hoc widget: collapsed rows were the
expanded layout clipped to the strip, the libraries tree used a
staircase of indents and mixed row heights, and hover expansion pushed
the whole content area sideways.
Rebuild the rail on M3 Expressive geometry: collapsed destinations are
icon-only 56x32 pills centered in an 80px strip (48px icon-only on TV),
expanded rows are fixed-height destinations with a full-width stadium
indicator, and the two layouts crossfade/lerp during the width morph
instead of clipping. The libraries section is flattened into full-width
destination rows under a chevron header at standard destination
metrics, hover/touch expansion overlays content as a rounded, shadowed
panel over a dimmed scrim instead of pushing it, and rail width, item
morphs, content translate, and bleed all share one duration/curve.
On a Wayland HDR session, colors flickered between HDR and washed-out SDR every ~5 seconds: the mpv-leg timeout in apply_hdr_state was armed after SetHdrOutput returned, so a synchronous reply (the no-op short-circuit that runs on every playback restart and re-describe) orphaned the timer. Five seconds later it fired against a healthy plane and withdrew the live HDR image description; the next re-apply re-attached it and armed the next orphan.
Arm the timeout only while the reply is genuinely outstanding. An asynchronous reply still removes it, and a genuinely wedged core is still bounded.
Verified in a headless-sway Docker repro against the same PQ/BT.2020 file: 2.15.0 logs the false 'mpv never answered the output colour-space switch' warning 5 s after plane bring-up and again after source recognition; the fixed build logs none across bring-up, HDR playback, and seeks.
close#2016
HEVC files played through the mpv backend on the Nvidia Shield show a
solid blue screen with audio: under hwdec=mediacodec, gpu-next samples
the decoder output as a samplerExternalOES that libplacebo declares in
both shader stages, and the Tegra GLES linker rejects the pair ("struct
type mismatch between shaders for uniform"), failing every frame. The
in-chain gpu fallback never engages because gpu-next initializes fine,
and mpv/libplacebo master reproduce it unchanged.
DV reshaping — the reason gpu-next exists on Android — only happens
under software decode, so offer gpu-next exactly there and keep the
legacy gpu VO for hardware sessions on both the primary mpv backend and
the ExoPlayer fallback core.
close#2010
Searching a name shared by a movie, a show, and audio drowns the wanted
item in episode and track rows. The search screen now offers kind chips
(All, Movies, TV Shows, ...) above the results; chips derive from the
pre-rank candidate pool, and selecting one re-ranks that kind's
candidates with the full display budget, so a kind crowded out of the
trimmed All view still surfaces everything the servers returned for it.
Chip switches never refetch, and the filter falls back to All when a
refined query no longer returns its kind.
close#2001
Removing an item from Continue Watching on Emby posts HideFromResume,
which only the dedicated /Users/{uid}/Items/Resume route honors: the
IsResumable /Items query and the per-series /Shows/NextUp lookups kept
returning hidden rows, so removed items blipped back on every refresh.
Emby's dedicated route conflates both shelves (in-progress items plus
one zero-position next episode per started series), so every playback
surface now reads one hide-aware window from it and splits the rows by
PlaybackPositionTicks: positive rows feed Continue Watching, zero-
position episode rows feed Next Up. The 1+N per-series Next Up
reconstruction is deleted; one recency scan still supplies the play
dates and the NextUpDateCutoff window. Jellyfin request strings are
unchanged.
close#2003
Browsing artwork showed PMS PhotoTranscoder log lines flagged
"upscaled: 1" on every request. The flag merely echoes the upscale=1
query param Plezy sent on every cover request (PMS prints it before
locating the source, including on cache hits and pure downscales), but
the param had a real cost: whenever the requested box exceeded the
source — hero art at the 4K display budget (3840x2160 requests against
the near-universal 1920x1080 agent art), portrait backdrops on >1080p
phones — PMS genuinely enlarged the image server-side. Verified against
PMS 1.43: ~2.5x the transcode time and transfer bytes for zero rendered
detail, since the client covers the slot on the GPU regardless and the
fit-policy decode bounds never enlarge.
Send upscale=0 on cover requests too: downscale output is byte-identical
and oversized requests now return the native image. Jellyfin needs no
change — MaxWidth/MaxHeight never enlarge.
close#1975
Closing the window now runs a graceful exit (so trackers get their
terminal scrobble), but the teardown kept the window on screen for
seconds while screens visibly emptied as servers disconnected.
Hide the window as the first step of exit teardown, bounded so a
stalled platform channel cannot hold an already-accepted exit open, and
cover every graceful-exit entry (window X, Cmd+Q, OS-initiated), not
just the close button. Add a terminal MultiServerManager.shutdown() that
closes the status streams before draining clients: the still-mounted
widget tree must never observe an "all servers gone" snapshot mid-exit,
which would flip the app into offline UI and dismantle screens (and
with it the teardown-driven AppLifecycleListener used-after-dispose
assert). A health result landing after shutdown is a silent no-op.
Supersedes the flag-based approach in #1999.
Exiting the player after enough progress moved the played item to the front of Continue Watching left D-pad focus at its old position, now occupied by a completely different item.
TvBrowseRail and HubSection now remap the focused index to follow the focused item identity when a hub reorders underneath it, falling back to the same series replacement entry when a finished episode is swapped for the next one, and HubSection focus memory is remapped so re-entering the row lands on the same item.
close#1987
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
A relay connection can win the phase-1 race (plex.tv edge answers fast),
gets saved as the preferred endpoint, and the cached URL then wins the
deterministic head-start probe at every bind — pinning future sessions to
relay's 2 Mbps cap even after direct connectivity returns, surfacing as
HTTP 500s on Original quality. A session that lands on relay legitimately
(direct down at connect time) stays there all evening: re-optimization
only fired on connectivity events.
Relay is now a fallback tier in the race: all probes still start at once,
but a relay success is held until every direct candidate has failed, and a
cached relay URL gets no head start. A single persist gate refuses relay
at all four endpoint-save sites, classified against both the live server
and the connect-time capture so rotated relay URIs cannot slip through.
While the active endpoint is relay, a bounded-backoff re-probe (30s/60s/
120s) re-races the candidates so a returning direct endpoint promotes
away without a restart.
Direct Play on Apple TV develops recurring micro-stutter that worsens
the longer playback runs and clears temporarily on pause/play
(issue #1776). Since 2.4 the avfoundation VO has presented frames
against a media-time CMTimebase anchored and rate-servoed to follow
mpv's audio-slaved schedule; clock skew between the two domains can
only be retired by rate slewing or a re-anchor snap that retimes every
queued frame at once. The media timebase exists for PiP, which tvOS
does not have.
Bump MPVKit to 1.0.22 and opt tvOS into its new
avfoundation-presentation=host mode: samples carry mpv's scheduled
display time against a free-running host-clock timebase, so drift is
absorbed per frame inside mpv and no timing state accumulates in the
VO. The queue lead returns to 128ms; 500ms only dampened media-mode
snaps and quadrupled their blast radius. 1.0.22 also paces the
compressed-passthrough feed off the most-advanced playhead reading,
fixing the periodic audio dropouts passthrough users reported on 2.14.
There was no way to tell from the detail screen that a movie or episode
has multiple versions (theatrical/extended cuts, 1080p/4K encodes); the
only entry point was the hidden Play Version item in the overflow menu.
The Play button now becomes a Material 3 Expressive split button when
the item carries more than one version and its server is reachable: the
main segment keeps plain Play (which already resumes the remembered
version), and a narrower chevron segment runs the existing Play Version
flow (version picker, quality picker when the backend can transcode,
preference save). The flow itself is extracted from the context menu
into a shared promptAndPlayVersion helper, and FocusableActionBar gains
per-action spacingBefore so the joined pair can sit tighter than the
rest of the row. Transcode-only quality picking stays in the overflow
menu so the chevron keeps signaling a real version choice.
close#1881
DTS-HD files play silently on the Onn 4K Plus: DTS decode is license-gated
in its firmware, so c2.amlogic.audio.decoder.dtshd initialises, drains and
advances the playback position while rendering silence. Route DTS-family
decode to the bundled FFmpeg decoder whenever the track will actually be
decoded - passthrough off, downmix, normalization, a failure block, or a
route that cannot bitstream DTS in any shape. Bitstream-capable routes are
untouched: media3 selects direct output before it ever consults the decoder
list, so raw passthrough, the IEC 61937 carrier and the tunneling gate keep
their exact behavior. Kodi ships the same policy as its only configuration:
its MediaCodec audio whitelist is empty and DTS always decodes in FFmpeg.
close#1995
Changing playback speed, shader preset, aspect ratio, or a sync offset in
the player always rewrote the global default, so a show watched at 1.5x
forced 1.5x onto movies, an Anime4K pick followed the user into
live-action libraries, and a file-specific subtitle offset leaked onto the
next title.
Each of those settings now has a scope under Settings > Video Playback >
Remember Player Changes: don't save, everywhere (default, pre-existing
behavior), per library, or per show/movie. Player writes route through the
configured scope; playback start and in-place item changes resolve the
value for the current item, falling back to the global default. Items
without the needed identity (live TV placeholders, Jellyfin/Emby downloads
without a stamped library) keep the global behavior. Resolution consults
only the configured scope, so entries saved under a previous scope become
inert instead of shadowing the new one.
close#1984close#1322close#1616
On iOS and Apple TV the lock screen, Control Center, and iPhone remote card only offered previous/next track for video, so the transport buttons restarted the episode or jumped to the next one instead of skipping a few seconds.
MediaControlsManager suppressed the MPRemoteCommandCenter skip commands on all Darwin platforms because they displace the next/previous buttons — the right call for music, the wrong one for video. The suppression is now a per-surface policy: the video player opts in with preferSkipOverTrackButtons and advertises the in-player small-skip step (seekTimeSmall, default 10s) via setSkipIntervals, while music keeps next/previous as its lock-screen transport. Skip events already routed through MediaControlRouter to _seekRelative on every platform, so the OS-echoed interval drives the actual seek.
close#1994
On Fire TV, invoking Alexa and dismissing its overlay popped the profile
picker over a still-playing session: MainScreen treated every resumed
lifecycle event as an app open and re-applied "ask for profile on open".
The Alexa overlay only produces inactive -> resumed, which the handler
could not tell apart from a real return to the foreground.
Track the deepest lifecycle state since the last resume in a small gate
(ProfileSelectionResumeGate): only a genuine backgrounding (hidden,
paused, or detached) arms the prompt for the next resume. This also
keeps iOS working, where returning from the background arrives as
hidden -> inactive -> resumed.
close#1990
On Shield Experience 8.x (API 28) ExoPlayer force-decoded TrueHD to
multi-channel PCM, and enabling passthrough on the mpv backend made every
non-AAC file wedge on an infinite buffering spinner with no audio pipeline
at all.
Two defects behind one symptom pair:
- The MAT/DTS-HD carrier oracle stopped at API 29 because
AudioTrack.isDirectPlaybackSupported does not exist below it, so API 24-28
never offered the carrier even on routes that genuinely bitstream it. Below
API 29 the HDMI AudioDeviceInfo is the only vouching signal, so the carrier
is now offered when an HDMI output explicitly advertises IEC 61937 at
192kHz/8ch; unspecified (empty) capability arrays deliberately do not
count, because an unvouched IEC track that initialises without being
bitstreamed renders as full-scale noise. A route that advertises and still
refuses the track fails AudioTrack init into the existing force-decode
recovery.
- The mpv audio-spdif list was gated on the raw encodings the route
advertises, but mpv's audiotrack AO opens every spdif format as a stereo
ENCODING_IEC61937 track clamped to the 48kHz mixer rate. E-AC3, TrueHD and
DTS-HD MA structurally cannot survive that shape, and a route can advertise
raw encodings while its HAL takes no IEC track at all - naming any codec
there leaves mpv's audio chain stuck before AO init (reproduced on a Shield:
spdif_ac3 selected, no AudioTrack ever opened, playback never starts). The
list is now capped to ac3,dts and additionally gated on the route accepting
the stereo IEC shape, using the same tiered oracle as the carrier. The
primary mpv backend previously wrote a hardcoded five-codec list from Dart;
it now asks the plugin to derive the value from the audio route, like the
ExoPlayer fallback already did.
Verified on a Shield (API 30) with a full-capability EDID: TrueHD rides the
carrier and survives speed transitions (instrumentation), and an E-AC3 title
that previously wedged under mpv+passthrough now decodes and plays with
audio-spdif=ac3,dts.
close#1991
The rate-mismatch regression greps the audio diagnostics for the literal
'MAT/IEC 61937 carrier', but 658469f9 reworded the sink's engage message to
'TrueHD (MAT) via IEC 61937 carrier' when DTS-HD joined the carrier. The test
self-skips on carrier-less CI emulators, so the rot only surfaces on real
hardware, where the fallback behaves correctly and the assertion still fails.
Match the shared 'via IEC 61937 carrier' fragment both codecs emit.
Brings in the feedback-driven fixes: user-assigned Plex playlist
posters, square music-grid gutters, grouped artist discography
(albums/singles & EPs/live/compilations for Plex), and the guide
day-jump rework (mounted reloads, immediate day apply, focus
retention).
Square-card (music) grids had zero delegate spacing - the only
separation was each card's internal 3px padding, and square shapes are
excluded from the full-card spacing path, so playlist and album tiles
sat nearly edge to edge. Square grids now get an 8px cross/main gutter
(automotive keeps its larger spacing); poster, list, and full-card
layouts are unchanged.
Artist pages showed one flat album grid, unlike Plex's own clients.
Plex listing rows never carry Format/Subformat tags (even with
resolveTags=1), so fetchArtistDiscography follows the unchanged album
listing with one batched /library/metadata/{ids} request - whose rows
do include the tags - and classifies each album individually
(EP/Single, then live, then compilation, case-insensitively). A failed
tag fetch degrades to the flat list, and single-album artists skip the
lookup entirely. Jellyfin/Emby carry no album taxonomy on the wire and
return a single albums group.
The artist screen renders one titled section per non-empty group
(single-group artists keep the flat grid), with the grid focus index
space offset per section so D-pad traversal crosses section
boundaries.
PMS versions before the ~1.43 hub refresh omit a show's inherited clearLogo
image from episode/season hub rows, so the Discover hero fell back to the
title for TV series while movies (which carry their own logo) and the show's
detail page (which reads the show metadata directly) kept theirs.
Resolve the missing owners' logos in one bulk /library/metadata request
(comma-joined rating keys) and stamp them onto the rows, mirroring the
grandparent lookup Plex Web performs. Best-effort: a failed lookup never
fails the shelf and is retried on the next refresh.
The guide replaced itself with a bare spinner on every window change,
unmounting the day-picker anchor and the guide focus node - after one
day or time change the pickers silently no-oped and the remote went
dead. Picking a day also applied nothing by itself: the window only
moved after the second (time-slot) menu, so backing out of it read as
'the day picker does not work'.
The grid now stays mounted during in-session reloads behind a light
loading overlay, a day pick re-anchors the window immediately (the slot
menu is an optional refinement), D-pad SELECT arms the key-up
suppressor before opening the menu, and the day chip uses the existing
'Tomorrow' translation.
Plex emits an auto-generated composite mosaic for every playlist, so the
displayImagePath fallback order (composite first) meant a poster the user
assigned in Plex could never appear. Prefer thumbPath and fall back to the
composite; no-op for Jellyfin/Emby, which never set a composite.
Fire TV Stick 4K Max (and other Fire OS devices) advertise ENCODING_DTS_HD on
the HDMI route but only implement the DTS-HD basic profile, so media3's raw
path builds an AudioTrack that drains normally while the receiver hears
silence. The same routes do bitstream the 192kHz/7.1 ENCODING_IEC61937 carrier
TrueHD already rides, so DTS-HD MA access units are now packed into DTS type IV
bursts (a port of FFmpeg's spdif_header_dts4 at the 768kHz HD rate, the same
bytes Kodi's IEC packer produces) and played through that carrier.
The TrueHD MAT carrier sink is generalized into IecCarrierSink with one
IecCarrierPacker per codec. While the carrier route exists DTS-HD is binary -
the carrier or decoded PCM, never the lying raw path; routes without the
carrier keep the pre-carrier raw behavior. Master Audio peaks the carrier
cannot hold strip to the always-fitting core substream for ~60s, exactly as
FFmpeg does, and the packer output is pinned byte-for-byte against FFmpeg
spdif golden fixtures.
close#1988
Main stopped compiling: the v21 column drop treated cached_at as
never-read, but the fresh-cache-first playback metadata gate
(ApiCacheSingleton.getIfFresh, from the playback start-latency fix)
reads it on every Plex and Jellyfin playback start, and a later
cleanup removed the put() stamp the gate depends on.
Reinstate the column, keep the v21 migration to the connections
half only (it never shipped in a release), restore the explicit
put() timestamp - on conflict the upsert only updates the
companion's columns, so without it a refreshed row would keep its
original write time and read as permanently stale - and copy
cached_at through the pinned-metadata rescope statement again. The
v21 migration test now pins that api_cache is left untouched.
Plex Live TV lost DVB subtitles when 2.10 moved the live path to HLS
(#1983): the transcode starts with subtitles=none, which drops a
tuner's bitmap subtitle streams from the output entirely - unlike
in-band CEA captions, they are separate elementary streams.
The tune response now surfaces the part's embedded bitmap subtitle
streams as selectable tracks in the player's subtitle menu. Picking one
points the part's server-side selection at that stream and rebuilds the
live transcode with subtitles=burn, preserving the time-shift position.
Off stays the default so tuner captions are never auto-burned (#1590),
and channels without bitmap streams keep the native track list. The
selection survives stream recovery by re-mapping onto the re-tuned
session's streams and resets on channel zap. Jellyfin live sessions
record subtitle selection as intentionally unsupported: their one
negotiated URL has no rebuild to deliver it through.
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.
Participant.lastKnownPosition had zero reads or writes; SessionState.disconnected was never constructed, making isInSession's comparison tautological (now _session != null); the session indicator's onLeaveSession callback had no production passer; enterRoom's bool was awaited and discarded. Music engine: MusicPlayContext.id and sleepTimerEndsAt were write-only, and the coordinator constructor seam was never injected.
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.
classifyPosition's return enum was discarded by its only production caller (rearm is the side effect that matters); the latch's tolerance/retry knobs were never tuned; _loadAdjacentEpisodes' result was unread by all three callers; countsAsApplied carried a subsumed disjunct and _syncMediaControlsAvailability a stale-generation guard with no suspension point before it; _SettingsView.audioSync/subtitleSync could never be current (both routes early-return into the sync bar); PerformanceStats.dvRpuOutputTooSmall/dvPlaybackReason and ContentStrip.chaptersLoaded were written but never read.
plex_pin_auth_flow's QR knobs, ProfileNameField's navigation params, MusicDetailHeader.wideArtworkSize, and _buildReorderableList's bool were never passed; DownloadType.manage was never constructed; MediaContextMenu.onTap was declared but never invoked — removed with its four card passes (each card's own gesture handlers stay); the pin dialog's obscure chain was always true and _PinKey.label's icon branches unreachable; libraries_screen._isInitialLoad was write-only; LiveTvActionsMixin loses dead findChannel (the generic parameter stays — required by the on-clause, the report was wrong); add_jellyfin's focus-map removal branch ran only against an empty map; the recommended tab's hub-key null fallback was dead behind _ensureHubKeys. media_card also loses its always-false seasonal-rank comparison from the CatalogRankScope removal.
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.
getServerBoundPlexClient/getClientForLibrary existed only for their own unit tests; MetadataEditAdapter.backend and Plex's unreachable prefKey fallback and music-kind branches had no production path (edit is double-gated on supportsKind); MainScreenFocusScope's focus aspect, isSidebarFocused, and focusContent were write-only; resolveActivePlexIdentity.preferredAccount was never passed; ProfileRegistry.get was unused outside tests — its removal exposed a stale test contract, now pinned correctly: list() deliberately never serves plex_home rows.
Parse-only DTO surface deleted across trackers and catalog models: quality-preset storageKey/fromStorage round-trips (persistence uses EnumPref on .name), PlexHome's placeholder identity fields (id stays — deriveHomeSecret reads it), SeerrUser.email/avatar, SimklSearchResult.endpointType, SimklDetail.type, SimklAllItemsEntry.status/isShow, TraktCatalogEntry.rank, CatalogItem.relevance, and the never-produced CatalogRankScope.favorited/.seasonal members with their label arms. Tracker root: OAuthProxyStart.expiresIn, TrackerSession.scope/isExpired, copyWith slimmed to its one real parameter, TrackerHttpClient.service. TrackerAccountStore.service and the trackerAccountStore() alias are live (persisted-blob validation, callers) and stay.
PlexMediaItem.subtitleMode/extraType were mapped from DTOs and never read (subtype stays — the detail screen's trailer picker destructures it); MediaPlaylist.copyWith, PagedMediaListState.mapItems, isMusicContent/isCollection, and MediaHub.copyWith's eight never-passed params had zero call sites; LiveTvActivityResult wrapped reloadGuide results nobody unwrapped (now Future<void>); PlayerLogLevel.none was unreachable — nothing parses or produces it; MediaSourceInfo.getPartId() was an accessor over its own public field.
TvTextInputAutoOpenBehavior.onFocus/.onFirstFocus were handled by both TV-keyboard state machines but selected by no caller; FocusableWrapper.longPressDuration duplicated the controller's own 500ms default; FocusableActionBar.mainAxisSize was never passed; FocusableActionBuildState carried focusNode/isFocused/isKeyboardMode that no builder reads (AGENTS.md updated to document the surviving showFocus/animationDuration pair). includeFocusSemantics stays — focusable_media_card passes it.
connections.isDefault was maintained by three write paths (upsert preservation, remove-promotion, setDefault) that no production code ever read back; api_cache.cached_at was documented 'optional future use' and written on every cache store. Both columns are dropped in a v21 migration with row-preservation coverage; the dead write machinery, recordAuthSuccess, updateSyncRuleLastExecuted, getDownloadOwnerCount, and OfflineActionType.fromId (rejection semantics live in the sync service's explicit switch) go with them.
Recovery-image compatibility: committed tvOS snapshots written by older builds still carry isDefault in connections rows, and the restore decoder deliberately rejects rows that do not round-trip exactly. A retired-columns allowlist now strips such keys before the strict check, so pre-retirement images stay restorable — the legacy-plaintext restore test caught this and now pins it. _bindServerStatusListener in main.dart also loses its unused provider param and resolver indirection.
The screen's player stream subscriptions were enumerated by hand in four places (re-wire, rollback, dispose, plus a parallel nulling list) kept in sync only by prose — _cancelPlayerStreamSubscriptions is now the single cancel-and-null authority, living beside the fields. PlayerChromeFocusTarget was a one-value enum with request/consume plumbing, now a bool with identical post-frame consumption timing. The desktop tap and controls-overlay tap duplicated the click-toggles-playback/double-click-fullscreen contract around shared state, now one helper. SyncOffsetControl shipped two layouts of which production only ever rendered compact — the full layout existed solely for tests, which now cover the shipped layout. TrackControlsState's never-non-null onLoadSeekTimes/onSyncOffsetChanged sheet-compat callbacks are gone.
_getButtonCount re-implemented every button-visibility condition from build() and had already drifted: the screen-lock button incremented the built index but was never counted, so with rotation lock and screen lock both visible the reported total was one short and arrowRight on the last button silently died. Navigation now bounds against the actual built list, so there is no condition table left to drift; the dead onLoadSeekTimes sheet-compat callback invocation goes with it.
Grep-verified dead surface across shared widgets: the app-menu system's minWidth/maxWidth/childPadding/alignmentOffset knobs threaded through four layers with no caller ever passing them; FocusableListTile's suppressInitialSelect/hoverColor/textColor/iconColor machinery (~55 lines) reachable by no call site; OptimizedMediaImage.playlist with zero constructions; TvSpotlightBackground's primary-action button that neither caller can render; MediaGridDelegate.createDelegate whose only caller was a test (MediaGridGeometry.resolve is the single delegate composition now); and the CustomAppBar->Config->DesktopTopBar chain collapsed onto DesktopSliverAppBar with its ~11 never-passed passthrough params (snap kept — real callers pass it).
PlexMappers advertised a parse-and-map FromJson flavour of every mapper 'for callers that haven't already parsed a DTO' — no such production caller exists; every call site uses the DTO-typed flavour. Tests now parse the DTO and map it, preserving every assertion. The kBlurArtwork JSON-walking branches in the DTO factories (title/summary vowel rotation on the decode hot path) are deleted with the flag's title half.
TextInputDiagnostics.enabled and kBlurArtwork were mutable/const-false flags nothing ever set, yet the diagnostics side eagerly built log strings on the gamepad and key-simulator hot paths on every event. Both flags are now const bool.fromEnvironment (PLEZY_TEXT_INPUT_DIAGNOSTICS / PLEZY_BLUR_ARTWORK) so the branches const-fold away, every eager interpolation sits behind a guard, and screenshot blurring stays one dart-define away. The vowel-rotation title obfuscation had no consumer need beyond artwork blur and is gone.
HostCoordinatorCallbacks and GuestReconcilerCallbacks existed only to be unpacked by the controller through ten forwarding lambdas into its own flat fields. The engines now take individual nullable callbacks; the controller keeps its late-binding lambdas (the provider assigns the public fields after construction), and the test harnesses forward the callbacks they exercise.
SettingsService.resolveMuteToggle had zero production callers — the live mute policy moved to VideoVolumeController.toggleMute long ago, leaving two diverged implementations of the same rule. The two TV-aware pref defaults in the same file now read through the PlatformDetector facade like the rest of the app.
TV state had two interchangeable entry points — PlatformDetector.isTV/isAppleTV and the raw TvDetectionService.*Sync accessors — with call sites split arbitrarily between them. App code now goes through the facade; the raw accessors document that they exist for the facade and tests.
TraktClient's static per-refresh-token coalescer, initiating-vs-joiner branches, session-adoption identity checks, and both updateSession push methods defended a multi-client world production never creates — the one long-lived client is shared via TrackersProvider, and the two throwaway clients never refresh. Refresh now matches MalClient/MdblistClient's instance-coalescer shape. Simkl's _watchlistLoad field shadowed the identically-named coalescer on the mixin it applies (legal today, a trap on any future library merge) — renamed with comments distinguishing the two caches.
persistAndBindConnection's addToManager/visibleServerId parameters and post-commit manager phase had no production caller (the Jellyfin flow defers runtime pickup to the profile binder) — the function is void now. A failed Plex account add threw a StateError that PlexPinAuthFlow re-rendered as a raw 'Bad state:' string beside the already-rendered inline error; the flow's normal return already stops the spinner, so the inline error is the single surface.
The profile picker requested first-tile focus twice (FocusableWrapper.autofocus plus a one-shot post-frame requestFocus for the same node) — autofocus alone remains, verified by the picker's D-pad tests. The end-session/try/catch-resume skeleton was copied across deleteProfile, the Plex sign-out, and connection removal; withEndedProfileSession now owns the pause/recover scaffolding while each flow keeps its own success-path resume decision and error policy.
The audio-playlist play flow, the playlist download flow (including its inline MediaItem synthesis), the delete-with-confirm flow, and the fetch-artist-then-navigate flow each existed twice — once in MediaContextMenu and once in the playlist/album/now-playing screens, with 'Match PlaylistDetailScreen' comments standing in for shared code. They now live in music_navigation.dart and download_utils.dart with per-caller strings preserved; the menu's collection/playlist download handlers fold into one parameterized handler, its duplicated sync/download menu tree is built once, and the generic picker dialogs move to collection_picker_dialog.dart (pure move).
The pause/resume/ticker state machine (three gates, lifecycle observer, TickerMode subscription, timer sync) was triplicated across the What's On, Recordings, and Guide tabs — now LiveTvRefreshMixin, with the guide's drift catch-up layered on its hooks and the lifecycle test asserting the same contract against the shared implementation. Six hand-rolled multi-server loops share forEachLiveTvServer (the channel/favorites loaders deliberately iterate per DVR entry and say so). Recordings' Completer-based load drain became the same whenComplete coalescing shape the screen's channel loader already uses.
LibraryFilterSortLoader had one call site that constructed it with a constant client resolver and bypassed it for MediaBrowser anyway; the Plex branch now loads both futures inline with types instead of casts. LibrariesScreen's ItemUpdatable application ended in an empty no-op that would have burnt a network fetch had anything ever called it. The chips-bar grouping sheet re-built the browse-options grouping page's chrome by hand; both paths now render the same BottomSheetPageScaffold page.
TvosMenuPolicyPublisher batched an idempotent fire-and-forget publish behind a transaction-depth counter that could never exceed one across its three non-nesting call sites — the call sites now publish directly and the pure predicate keeps its test coverage. AppleTvRemoteTouchService exposed 14 injectable knobs of which only six are exercised anywhere (verified per knob); the other eight are private constants or internal construction now.
The catalog detail screen and the card badges each kept their own enum-to-i18n switches; the genuinely shared mappings (season name, rank scope, request state, status/format/relation labels) now live in catalog_labels.dart, while badge precedence and the deliberately different availability/4k wording stay local with comments naming the divergence.
The downloaded-episodes-for-a-season filter+sort was written out three times in media_detail_screen (with drifting show-id expressions, preserved per call site), and the failed/cancelled download branches in the action buttons duplicated the same six-step retry pipeline verbatim. One helper each.
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.
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.
PlayerAndroid.command() emulated four mpv commands ('loadfile', 'seek', 'stop', 'sub-add') that no caller ever sends through the interface, while the commands actually dispatched ('change-list', 'drop-buffers', 'sub-seek', 'screenshot') fell into the default branch and vanished. The override is now a documented no-op, which is what every dispatched command already observed.
Four model surfaces parsed, coerced, and re-serialized data with zero readers: PlayQueueResponse's selectedItemIndex/offset/sourceURI/version (version stays as a parse-time validity gate), PlexUserProfile's seven non-track-selection account fields, MediaSubscriptionCreateRequest's hints/params/providers, and the whole CatalogPlayState pipeline (parsed by the Plex catalog source, merged and round-tripped on every CatalogItem, read by nobody).
PlayQueue was a freezed union whose Plex variant was never constructed (server queues flow through PlayQueueResponse), whose pattern getters had zero call sites, and whose backendId was written but never read — it is now a plain four-field LocalPlayQueue. The MediaItem compatibility factory re-listed the entire ~65-field union surface twice while every caller passes at most 22 fields; it now declares exactly that union. fetchLibraryContent had zero production callers (the UI pages through fetchLibraryPagedContent), so the interface method and both implementations are gone, with Jellyfin's drain folded into its paged entry point.
Two part mixins re-declared members owned by siblings (five playback methods with full 17-parameter signatures, _safeFetchItemsArray with four lint suppressions), violating jellyfin_client.dart's own 'declared exactly once' contract — they now live on _JellyfinClientInternals, with _safeFetchItemsArray declared as its positional core so no suppressions survive. LiveTvSupport.resolveStreamUrl's only caller anywhere was Jellyfin's own startPlayback, so it is now a private negotiation step and Plex drops its permanent null stub; the negotiation tests observe the same contract through startPlayback and the heartbeat wire. JellyfinApiCache's unpinForOffline/isPinnedItemId (zero callers, kept only for Plex symmetry) are gone, as are MediaSubscriptionCreateRequest's never-populated hints/params/providers expansions in the Plex live-TV serializer.
Every identity/pending mutation nested a per-instance SerialFutureQueue inside the static tvOS-recovery queue, so the per-instance layer added no serialization domain (reentrancy is already prevented by the durability Zone check). The unguarded updateSyncAttempt/deleteWatchAction pair had zero production callers — the sync service deliberately uses the revision-guarded variants.
Tests migrated to the IfUnchanged pair by passing the row's current revision.
Connection.status was write-only health state (only the Jellyfin refresh wrote it, nothing read it — MultiServerManager owns real server status), ConnectionKind duplicated MediaBackend line for line, the token-to-PlexAccountConnection pipeline existed three times (sign-in, dev-token seed, legacy migration) with drifting label/dedup policy, and JellyfinConnectionAuthService.validate/refresh/signOut had no production callers.
Connection.kind is now MediaBackend (persisted 'plex'/'jellyfin'/'emby' strings are identical, no migration); buildPlexAccountConnection in plex_account_setup.dart owns identity resolution with the three callers keeping only their genuine deltas; the test-only auth trio and its jellyfinSignOut timeout constant are gone.
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
Several Play entry points gave no feedback while their network round
trips ran: a show or season Play button silently awaited a seasons
fetch (with a 10 s completer window) and a first-episode fetch, and
Plex collection/playlist launches showed no spinner at all. Where a
spinner did exist, the first request was gated on the dialog's first
frame instead of running concurrently with it.
Extract executeWithLoading's dialog plumbing into
ScopedLoadingDialogController (mount-aware dismissal, idempotent,
never pops a foreign route) and reuse it for the show/season Play
path. Start the launcher operation before awaiting the dialog frame so
the first round trip overlaps the render, and show the loading
indicator for Plex list launches too.
The playback data resolve — the one network request that must land
before open() — was kicked off only after the SharedPreferences load,
the Windows display-mode sync, the music-session teardown in
claimVideo(), Player construction, and (Android/Exo) a native
setLogLevel, so none of that setup overlapped the round trip.
Move the kickoff to immediately after the settings reads; the resolve
depends only on settings and provider lookups, so display sync, player
construction, and the whole mpv property chain now hide behind the
network latency instead of preceding it.
Also remove redundant work elsewhere on the start path: memoize
PlayerAndroid.getHeapSize (asked again on every open for an immutable
device value), skip the ten subtitle-style settings reads on mpv
backends where setSubtitleStyle is a no-op, and stop navigateToVideoPlayer
awaiting SettingsService.getInstance twice per launch — the external-player
read now sits behind the supportsExternalPlayers guard.
Every mpv open rebuilt the HTTP header list with one awaited channel
round trip per change-list command — with Plex's 9-13 identity headers
that is ~12 serialized round trips sitting between the playback resolve
landing and loadfile, on every mpv backend.
Dispatch the clr and append commands without awaiting between sends and
await them together: the method channel delivers messages in send order
and the native side executes them in arrival order, so the
clr-before-append contract holds while the latency collapses to one
round trip. Failures still propagate out of open() unswallowed.
Videos take noticeably long to start. On Plex, tapping Play refetched
/library/metadata/{id} network-first even though the detail screen wrote
a strict superset of that exact payload under the same cache key seconds
earlier. On Jellyfin/Emby, playback start issued a full-detail item GET
before the PlaybackInfo negotiation, and the video controls issued the
same heavy GET again while the stream was opening (#1784) — the most
expensive queries a small home server serves, paid two to three times
per start.
Add ApiCache.getIfFresh over the existing cachedAt column and serve
rows younger than playbackMetadataCacheFreshness (5 min) without a
round trip. Plex guards with the strict stream-detail check
(_plexMetadataHasStreamDetail) so a thin row written by
getPlaybackExtras' lean fetch still goes to the network; Jellyfin's
row has a single full-shape writer, so fetchPlaybackBundle and
fetchPlaybackExtras share the new fetchItemFreshCacheFirst. Any miss,
stale, thin, or malformed row falls through to the unchanged
network-first path, and offline behavior is untouched.
On TV, pressing UP in the library Collections or Playlists grid snapped
the list back to the top and dropped focus on the tab chips, making long
lists impossible to navigate. Default directional focus traversal scrolls
the found card into view via Scrollable.ensureVisible, whose
outer-scrollable pass routes through the NestedScrollView coordinator and
resets the inner grid position to zero on every UP press.
Give the shared paginated card grid explicit per-card d-pad navigation,
matching the browse tab: managed per-index focus nodes, row/column moves
that request focus directly, first-row UP to the tab bar, and
first-column LEFT to the sidebar. Focus changes now scroll only through
FocusableWrapper's delta-based auto-scroll.
close#1977
tvOS logs flood with "HTTP client drain timed out" / "close deferred"
warning pairs after every Plex endpoint race, and each sweep leaks the
losing probes' sockets for the OS connect timeout (~75 s of SYN
retries on Darwin).
A request stuck in TCP connect cannot be aborted: package:http's
IOClient only registers the abort handler once openUrl completes, so
the graceful drain never finishes and ManagedHttpClient defers the
inner close that would have reclaimed the socket.
Fix at both layers: every IOClient now gets an explicit
HttpClient.connectionTimeout matching MediaServerTimeouts.connect, and
ManagedHttpClient gains an opt-in forceCloseOnDrainTimeout escalation
used by all dart:io-backed clients — close(force: true) promptly fails
in-flight requests, unlike the native-callback clients (CupertinoClient)
the deferral exists for.
close#1972
Canary asserts stock media3 1.11.0 still reports these files unseekable
and snaps seeks to the start — when a media3 upgrade makes it fail, the
TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired.
The second test drives the production wrapper stack and requires the
seek to hold.
media3 1.11.0 builds the Matroska seek map while parsing Cues, which for
files whose Tracks element follows the Clusters is before any track is
known — the map permanently reports unseekable and ExoPlayer coerces
every seek to t=0, snapping playback to the start. Route seeks through
TrackAwareSeekMap's per-track cue lookups using the track IDs observed
on the extractor output.
The scripts reorganization moved upload_symbols.dart from scripts/ into
scripts/release/ but kept the single-.parent root resolution, so the
script searched scripts/build and scripts/debug-info and every CI symbol
upload failed with "no symbols found". The sibling checks scripts were
updated to .parent.parent in the same move; match them.
On a Wayland session whose compositor hands clients no GPU device
(Cinnamon/Muffin 6.6.3 on Mint 22.3), Mesa falls back to llvmpipe and
libva-wayland's vaInitialize segfaults inside mpv_render_context_create
the moment mpv is given the wl_display - the app silently closes when
playback starts. 2.14 was the first release to hit this because its
bundled libmpv is the first with VAAPI compiled in, and the device init
runs eagerly at render-context creation, so the hardware-decoding
toggle cannot avoid it.
Skip MPV_RENDER_PARAM_WL_DISPLAY when GL_RENDERER names a software
rasterizer: zero-copy Wayland interop does not exist on llvmpipe anyway,
and mpv's hwdec=auto still reaches the GPU through a DRM render node,
which works even on these sessions. Accelerated sessions keep the
zero-copy handoff unchanged. Verified in a container against Muffin and
weston on llvmpipe: the handle is withheld and playback works.
Dolby Vision Profile 5 files play with pink and purple colors on Android
devices without DV support (#1902): vo=gpu cannot apply DV RPU reshaping,
so the raw IPTPQc2 base layer reaches the screen. The shipped libmpv AAR
builds mpv 0.41 with libplacebo, where gpu-next is the upstream default VO
and reshapes DV correctly.
Prefer vo=gpu-next with an explicit vo=gpu fallback on both the primary
mpv backend and the ExoPlayer failure-fallback path, drop the
vd-lavc-film-grain=cpu override so film grain applies on the GPU under
gpu-next, and report current-vo in player stats.
Verified on a Pixel 7 (no DV display or decoder): gpu-next initializes on
Mali-G710/GLES 3.2, SDR hardware decode is unchanged, and a 4K DV stream
decodes to dolbyvision/bt.2020/pq with correct colors under software
decode. Reshaping still needs software decode: FFmpeg 8.0's mediacodec
wrapper exports no DOVI side data, so hardware-decoded DV keeps playing
the base layer untouched.
Hovering the back button in bottom sheet headers drew the circular
highlight 12px right of the arrow: the 48px hit target was positioned
from the stack's padded origin while the glyph sits flush at the
leading content edge. Move the horizontal padding onto the header row
and position the target so the InkResponse box centers on the glyph.
One command releases to Play, Amazon, App Store Connect (iOS + tvOS), the GitHub build farm, Microsoft Store, and the GitHub release + cask, with checkpointed resume. Replaces the fastlane release flow; first used for 2.14.0.
scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
On macOS 27 beta, CoreAudio rejects ao_coreaudio's channel-layout setup
with paramErr (-50), and since 2.14.0 pinned ao=coreaudio as the only
output, the failed init left every video playing with no audio and no
selectable audio track. Append avfoundation as the fallback, mirroring
upstream mpv's macOS probe order: every format still opens through the
HAL-backed CoreAudio path when it works, and the fallback only engages
when CoreAudio's init fails outright.
close#1964
Enabling audio passthrough on macOS 2.14.0 silenced every AC3/EAC3/DTS
item: the macOS player now pins ao=coreaudio, where audio-spdif redirects
to coreaudio_exclusive. That needs an IEC61937-capable device Mac setups
essentially never have, and with a restricted ao list mpv has no PCM
fallback, so the failed AO init stalls playback with no audio. The toggle
never delivered real bitstreaming on macOS anyway (2.13 decoded through
AVPlayer), so stop offering and applying it there.
With HDR playback enabled on Windows, letterbox bars rendered dark gray
instead of black on HDR displays, for both SDR and HDR content. The
bundled libmpv (20251228-git-a58dd8a) carries a libplacebo that maps the
gpu-next background clear color through the display's reported black
point in HDR mode; libplacebo ff2799a67 (2026-01-07) fixes it by using
infinite contrast for the background. The pin had already been past the
fix (20260303) but was downgraded to 20251228 when downloads moved to
SourceForge.
Bump to 20260809-git-dd5d17d328 (mpv v0.41.0-920, libplacebo v7.371.0),
verified to contain the fix commit.
close#1965
Gapless playback still gapped between tracks on network streams: mpv
only opened the armed next track's stream at the moment the current one
ended, so any server whose connect+probe outlasts the audio output's
buffered tail (~0.5s) - remote Plex over TLS, a transcode session
starting up - produced an audible dropout at every transition.
Enable mpv's prefetch-playlist when arming a network track so the open
happens while the current track still plays. Measured on a Pixel 7 the
boundary goes from 60-233ms of inline network work to 9-16ms with no
network activity at all; a failed or superseded prefetch falls back to
the old boundary open. Local fdclose:// arms keep prefetch off: an
early open would consume the fd while playlist-pos still reads 0,
breaking _clearArmedNext's "provably never opened" close proof.
close#1869
- A Plex account, or a Jellyfin or Emby server with user credentials
### Setup
@@ -190,7 +190,7 @@ scripts/setup_hooks.sh
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
```bash
python3 scripts/run_maestro.py basic
python3 scripts/maestro/run_maestro.py basic
```
## Contributing
@@ -205,4 +205,4 @@ Plezy is licensed under [GPL-3.0](LICENSE).
- Built with [Flutter](https://flutter.dev)
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
- Playback powered by [mpv](https://mpv.io) via our [mpv-build](https://github.com/edde746/mpv-build) pipeline (started as a fork of [MPVKit](https://github.com/mpvkit/MPVKit); the Android Kotlin/JNI glue descends from [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), and [libass-android](https://github.com/peerless2012/libass-android)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.