Formatting was clean through 53288116 and then drifted across three commits on
2026-07-30: 1bf7aac7 left one source unformatted, f13f5af6 a second, and
daab4f1e four more. CI's Verify formatting job checks the whole tree, so it has
had six files to report ever since. No pre-commit hook is installed in this
checkout, so the aggregate check never ran locally to catch them.
Formatted with the dart_style revision Dart 3.12.0 bundles, which is what the
pinned Flutter 3.44.0 CI toolchain runs, rather than with a newer local SDK; the
two disagree about some argument-list splits. The current stable formatter
accepts this result as well, so both report the tree clean.
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.
Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.
Two failure paths found while consolidating are fixed here too.
The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.
The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
Simkl only heard about an item once playback crossed the media server's
watched threshold, so stopping partway recorded nothing at all: no resumable
position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the
player lifecycle instead, carrying the measured progress. Seeks report
nothing, as Simkl asks.
The terminal stop owns watched state for in-player playback, so real-time
trackers are excluded from the threshold markWatched fan-out and one watch
never produces two writes. Progress is reported as measured — it doubles as
the user's resume position — so when a server threshold configured below
Simkl's own 80% rule would leave the watch unrecorded, the tracker records it
through /sync/history rather than inflating progress. Manual, container,
offline-replay and external-player marks keep using /sync/history. Only
/scrobble/stop accepts a 409, which is the sole action documented to return
one.
Reports go out one at a time because Simkl serialises scrobble writes per
user and fails queued ones with a 400; overflow sheds the oldest non-terminal
report so an episode swap cannot drop the previous item's stop. A playback
session is pinned to the account bound when it began and every send re-checks
that binding, so a profile switch or a disconnect/reconnect can neither
redirect a queued report nor misfile the watched fallback.
Also close the paths that lost the terminal report entirely: app exit flushes
it instead of dropping it, the desktop window button goes through the app
shutdown rather than exit(0), a detached VOD player reports a stop, and a
finished item reports completion at EOF instead of waiting for teardown. A
session that opened at 0% is still closed on stop, or Simkl keeps showing the
item as playing until its runtime elapses.
close#1719
41ffaa7f2 gated picture-in-picture on FEATURE_AUTOMOTIVE and added a
settings case for it, but the assertion that case leads with — a stored
auto-PiP true surviving a read — needs supportsPictureInPicture() to be
true, and that gate ends in Platform.isAndroid || isIOS || isMacOS. The
term is false and unmockable on the Linux and Windows runners, so the
case passed on a macOS host and could never pass in CI: sanity checks
have been red for six commits on this one failure out of 4723. f13f5af6e
recorded it as a pre-existing Windows-host failure, but it entered in
this window and is red on Linux too.
Extract the gate's decision into a pure pictureInPictureAllowed that
takes the host's own capability as a parameter, the way
driver_distraction.dart already splits automotivePlaybackAllowed from its
ambient wrapper. The boolean algebra is unchanged, so the three callers
keep their behaviour; what changes is that the automotive and TV vetoes
become observable where every Platform branch is false, instead of being
vacuous on the host that gates the release.
The settings case keeps the pref-level contract on both host classes: a
stored true survives where the host supports PiP, and the gate pins it
off where it does not.
Verified with the host term forced false to emulate a Linux runner: both
files stay green, as does the full suite on macOS.
With the new playback setting enabled, episode advance carries no audio or
subtitle preference at all, so both resolve from the streams selected on
the server for each individual episode. This serves setups that curate
selections server-side (e.g. Plex Auto Languages) and is independent of
"Remember track selections", which keeps gating only the write-back of
manual changes.
close#1717
Plex treats a subtitle stream as forced when its title says "Forced" even
with the API flag unset. Every forced comparison now uses that effective
forced-ness on both sides: the match scorer, the low-metadata hard gate,
the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation.
Carrying a track choice into the next episode no longer reuses the
same-item identity matchers. A sealed SubtitlePreference (off / track
reference / semantic intent) replaces the id-'navigation' pseudo-track
through the whole preference channel, and cross-item intents hard-require
language and forced-class parity. When the next episode has no track of
the same class, the intent declines and selection falls through to the
server's own per-episode choice instead of latching onto a full track by
position and persisting that mistake back to the server.
Intents wait for pending native tracks under the same catalog-completeness
rule as source ids, so an early decline cannot retire the selection
listener before the real track arrives.
Ref #1716
Every artwork budget in the image pipeline was tuned for 1080p surfaces:
the transcode request clamp (1920x1080), the per-type decode caps
(poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV
image-cache bytes. Those numbers are exact on phones and on the many TV
boxes that composite the app at 1080p, but a TV compositing at 4K
renders every capped image below its slot and GPU-upscales the result:
hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x,
shelf posters by ~1.13x - the softness reported against the official
Plex client in #1697, and the class #860's min-2x-DPR fix could not
reach.
DevicePerformance now latches a display budget factor - the display's
shortest physical axis over 1080, capped at 2x - whenever the image
cache budget is applied (startup, post-mount, effects-setting changes).
The transcode clamp, the full-tier decode caps and the TV cache bytes
all scale by it, so a 4K surface fetches and decodes 4K backdrops and
proportionally larger cards. The reduced tier stays pinned to 1.0, and
sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art
(~33MB per decode) cannot starve mid-RAM boxes; latching once per
session keeps transcode URLs - and with them the disk cache keys -
stable across rotation and rebuilds.
Whether a given TV composites at 1080p or 4K decides whether any of
this can help, and logs never recorded it: the startup banner and the
log-upload header now carry a display line (physical, logical, DPR,
latched budget) so uploaded logs answer that question directly.
The two pre-existing Windows-host test failures (automotive auto-PiP
gate, backdrop temp-dir teardown lock) reproduce unchanged on the base
commit.
Two defects sank Explore's Plex integration. Discover started rejecting
X-Plex-Container-Size=500 with a 400, so the watchlist membership
snapshot never loaded: hearts stayed unknown and toggles dead. The
snapshot now pages at 100, and getWatchlist refetches a rejected page in
chunks of the row fetch's field-proven 25, so the next cap drift degrades
gracefully instead of failing and callers' offset math survives either
way.
Worse, every Plex catalog item reached the library matcher carrying only
its Discover rating key: listings were fetched without includeGuids, so
the lookup rested entirely on exact plex:// guid equality between two
metadata universes (Discover duplicate entries break it, notoriously for
anime), and the title fallback can never confirm a candidate without
external ids to intersect - "Not in your library" for owned titles the
MAL provider matched fine. Discover listings now request Guids, the
detail screen re-runs the matcher when enrichment gains id forms
(generation-guarded so the slower bare lookup cannot overwrite the
richer verdict), the matcher keys its memo by id fingerprint so the poor
form's cached negative cannot answer for the rich one, and the Plex
client stops burning title requests that external-id verification is
guaranteed to reject.
Discover requests are now logged like every other API surface; this bug
shipped blind because they were not.
close#1715
Four sections of the catalog detail screen spent more room than their data
justified.
Franchise relations drew one hub shelf per label. Real payloads make that
absurd: MAL returns twelve relations for Attack on Titan across six labels,
and "Side story" and "Sequel" each hold exactly one title, so each spent a
header, a scroll row and one card. Flatten the labelled groups into one
"Related titles" section of compact rows — poster thumb, label, title and year
— that flow into columns on wide viewports. D-pad moves through the grid by
index and still hands off to the cast strip above and the recommendations
shelf below, which keeps its shelf because taste-based recommendations are
meant to be browsed.
Drop the MAL picture gallery. It was a horizontal strip of unfocusable poster
variants of the title you are already looking at, and it cost a page-height of
scroll; the `pictures` field comes back out of the detail request with it.
Draw attributed scores behind their own brand mark where the source has one,
the way the media detail screen already does: Rotten Tomatoes fresh/rotten and
upright/spilled, IMDb and TMDB, each on the scale that source publishes.
Sources with no mark (critic, audience, tracker scores) keep their written
label. Plex's own badge state is derived from the 60% tomatometer threshold it
encodes in `image.rating.ripe`.
Flow the definition rows — original title, studios, country, budget, box
office, crew — into two or three columns once the window is wide enough.
A 1440-wide window drew a 140-pixel label, a short value and 1,000 pixels of
nothing per fact.
Verified against live MAL and Plex Discover payloads on macOS: the Attack on
Titan page drops from 4,082 to 2,115 logical pixels, Dune: Part Two from 1,282
to 1,154.
The 1x1 keep-alive repaint loop was extended to Windows in a87aa296 to
paper over the legacy compositing path's resize desync (#227); the DComp
rework replaced that presentation path entirely. On the DComp engine the
100ms repaints become DirectComposition commits during playback, and
once fullscreen focus engages VRR (FreeSync/G-Sync) every commit forces
a scanout off the video's cadence - the micro-stutter of #1707.
The widget now owns the platform decision behind a test seam, and a new
quiescence test pins the hidden-chrome player UI to zero scheduled
frames so no future ticker can silently reintroduce the defect.
Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.
Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.
Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.
Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.
Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
The sheet collapsed an item to `Media.first` / `MediaSources.first` and
rendered a fixed set of rows, so split files, extra versions, per-track
properties, HDR classification and Dolby Vision were all invisible.
Model the payload the way both servers shape it — versions own parts,
parts own streams — and project every property either backend populates
onto `MediaStreamDetails`. The field set comes from sweeping both test
servers in full through the clients' own request shapes (Plex 6842
Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763
streams / 61224 attachments), so file presence, Dolby Vision layers,
dynamic range, sample rate, spatial audio, sidecar provenance, embedded
attachments, rotation and the lyric stream type all survive. A coverage
test fails when a server key is neither carried, folded into a sibling,
nor excluded with a reason.
File Info also stopped trusting the shared `/library/metadata/{id}`
cache row: `getPlaybackExtras` writes it without `includeStreams` /
`checkFiles`, so the sheet could render with no stream table at all.
Detect that shape and refetch once under the request context captured
before the cache read, so the outgoing token and the cache namespace
stay on one profile.
Rework the layout to match: a summary chip row, then flat tonal cards
with a two-column field grid that collapses to one column on narrow
viewports, per-stream cards with flag chips, and a copyable monospace
path row. The card fill is a tonal step off the text colour rather than
the `bg` token, which is one shade from the sheet surface on OLED.
The skip badge doubled as an armed state: while it was up, any single tap
in the same-direction zone seeked again. The badge is also raised by
keyboard, D-pad, media-transport and live seeks, so one remote press armed
one-tap seeking on the touch surface with no double tap at all. It stayed
armed for 1200 ms plus the fade and renewed on every tap, leaving the side
zones - 35% of the width each, over 70% of the height - unable to raise
the chrome.
Pair taps off the pending single-tap timer rather than differencing
DateTime.now(). The window is then one deadline that a clock adjustment
cannot stretch, suppressing touch taps disarms a half-finished pair, and
_lastSkipTapTime belongs to the desktop double-click paths alone.
Consecutive completed skips still accumulate into one running badge total.
Pressing pause or seeking while the player's on-screen controls were hidden raised
the entire OSD, covering the subtitles the viewer was rewinding to read. Transport
keys now answer with a transient indicator and leave the chrome down; Select,
D-pad Center and a centre tap remain the deliberate way to bring the controls
back.
Play/pause confirms with an icon-only translucent disc at the centre of the frame,
72px around a 44px glyph, which grows and fades in, holds half a second at rest,
then runs the same motion in reverse. Seeking shows the amount plus a single
chevron on the same line at the edge it travels toward, with no backdrop at all:
anything large enough to read as a surface is large enough to cover picture and
subtitles, so legibility comes from shadows instead. Only the chevron moves, and
it eases outward across most of its cycle and returns briefly, holding a visible
opacity floor rather than blinking out. Type is scaled per platform, since a
television is read from across the room. The existing text pill stays for genuine
notices - rate changes, chapter titles, zoom, errors - because an earlier centred
pill overlapped ASS \an8 subtitle placement, which is the readability complaint
this feedback exists to answer.
Every relative seek entry point now shares one coalescing primitive. The keyboard
shortcuts fell through to KeyboardShortcutsService and previously reported
nothing, and both they and the remote's chapter fallback rebased each press off
player.state.position, so a burst against a slow backend pinned every request near
one step while the indicator climbed to a total that was never committed. A
released key commits its pending target immediately and resets the acceleration
tier, including on live TV where seeks bypass the accumulator. A chapter seek with
nowhere to go, past the last chapter or already at the start, no longer announces a
jump it does not perform.
Rewind-on-resume follows the resolved intent rather than the current state, so a
directed pause on an already-paused video neither resumes nor rewinds. Indicators
carry their own liveRegion semantics nodes: their labels previously merged into the
full-screen "show playback controls" target, corrupting its accessible name, and
they keep announcing "Paused"/"Playing" and the seek amount from icon-only visuals.
close#1676
MAL/AniList season entries never matched the library show they belong to.
Both backends use the catalog title as a server-side filter before verifying
external ids, and a title like "Mushoku Tensei: Jobless Reincarnation Season 2"
cannot reach a show stored as "Mushoku Tensei: Jobless Reincarnation". Measured
against a 267-show Plex library, 3 of 113 mapped sequel entries matched.
The reverse lookup now takes two ordered title candidates instead of one: the
entry's own title and its season-stripped form, with typographic punctuation
normalised because both servers miss on a curly apostrophe. That matches 77 of
113. Widening it further to romaji/native/synonym variants reached only 81, so
the cap stays at two rather than spending up to five more requests per lookup
that finds nothing.
A sequel's year is its own season's, not the parent show's, so the +/-1 year
window is dropped for one - it would exclude the very show being looked for.
That also keeps a miss at the two requests the single-title lookup already
spent. A Plex Discover item additionally skips the title search entirely by
filtering on the plex:// guid its own rating key already is, which costs no
extra request and needs no cloud lookup.
A season 2+ entry only matches when the server really has that season, which
costs one children fetch on a match. Only a season both TVDB and TMDB agree on
is gated: which provider a library numbers its seasons by is a server setting
no dataset supplies and none of it is inferable from the ids an item exposes,
so a disagreeing reference is left ungated rather than gated on a guess.
The match cache is keyed per source and per entry rather than by canonical id,
which every season of a series shares: all five Mushoku Tensei entries collapse
to imdb:tt13293588, so one season-gated result would have poisoned the rest.
Entries whose Fribb row carries no provider id at all remain unmatched. That is
an upstream mapping gap, not something to guess around with extra lookups.
close#1704
Plezy declares appCategory="video", so on Android Automotive OS it is a
parked app bound by car app quality DD-2/DD-3: audio must stop when the
vehicle starts driving and must not be resumable while driving. Two paths
kept audio alive. Music playback ran under a mediaPlayback foreground
service whose lifecycle observer was registered for Apple TV only, so it
never paused when Android backgrounded the app. Video pausing hung off
AppLifecycleState.hidden, which Flutter only synthesizes once Android
delivers onStop; a car without the Automotive compatibility mode delivers
onPause alone, which maps to AppLifecycleState.inactive and the player
ignored.
Gate every path that can start audio on a new lifecycle predicate,
automotivePlaybackAllowed, which permits playback on a car only while the
app is resumed and fails closed on an unknown lifecycle state. That covers
explicit play, gapless arming and track transitions, live retry and
channel switch, frame-rate-match resume, VOD/live startup, and the queue
navigation commands of the OS media session, plus a last-resort pause for
when the platform player resumes itself on native audio-focus regain.
Playback authority on the media-session router is deliberately left alone:
the router consumes a denied event, so gating it would swallow PauseEvent
and leave the OS unable to stop audio. Reacting to lifecycle callbacks is
the mechanism the platform documents as sufficient, so no android.car
dependency is added.
The music queue no longer requests POST_NOTIFICATIONS on a car, where the
foreground service and its notification never start: there is nothing to
authorize, and the prompt would take focus and make the gate discard the
first play intent.
Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV
verdict, so a rotary-only head unit no longer inherits the leanback
experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE,
which cars lack, so the app's UI cannot stay on screen while driving, and
nothing forces a preferred orientation on a fixed-orientation display.
Audio passthrough defaults on for Android TV, scoped to ExoPlayer because
mpv force-passes through every codec named in audio-spdif and has no
decode fallback. That scoping did not survive the ExoPlayer to mpv
handoff: PlayerAndroid queued the raw ac3,eac3,dts,dts-hd,truehd list as
a pending mpv property and prepareMpvFallback replayed it verbatim, so a
sink that bitstreams only Dolby formats was told to force TrueHD and
DTS-HD anyway. mpv selected spdif_truehd, the audio output never
initialised, and playback froze at its start position while still showing
a first frame — the stop timeline reported the position it opened with.
Treat passthrough as a request and resolve the codec list against the
route when mpv actually starts, so an HDMI or AVR change between
ExoPlayer startup and the handoff cannot replay codecs from the old sink.
Gate each codec on the exact advertised encoding rather than media3's
passthrough probe: that probe answers DTS-HD by downgrading to the DTS
core, and mpv reads "dts,dts-hd" as "dts-hd" alone, so accepting the
downgrade would name DTS-HD MA to a core-only receiver and lose DTS too.
The scoped lookups run in sequential batches, and MediaServerHttpClient
applies a per-call timeout to connect and receive separately. A silent
endpoint therefore cost up to two request timeouts per batch, and six
batches of that outlast the single request the scoped form replaced —
the enrichment could hold Continue Watching longer than the query it was
introduced to fix.
Give the pass one deadline instead of a per-batch check. It aborts the
in-flight batch and is also raced client-side, because aborting only asks
the transport to stop and not every client honours abortTrigger. Whatever
phase a lookup is stuck in — silent connect, delayed headers, stalled
body — the pass now ends at the deadline with whatever dates it has.
Plezy's device profile declares every subtitle format with
`Method: External`, so Jellyfin answers PlaybackInfo with
`DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded
in a direct-played container. Direct play never fetches those URLs, but
the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed
rows only match a native track loaded from the same URL. No embedded
track could satisfy that, so `selectSubtitleTrack` reported "still
pending" forever: playback started with subtitles off and logged the
five- and thirty-second waits, and the server's default subtitle had to
be picked by hand on every item.
Restrict sidecar identity to the rows an open actually fetched as
sidecars. A row that stays in the container loses `key` and
`usesExternalDelivery` and matches on metadata again; genuine
`IsExternal` files keep theirs, and remuxed or transcoded renditions
still resolve their sidecars by URL.
Also declare every subtitle format Embed-first so a direct-played
container reports embedded delivery in the first place, and make the
pending contract match its purpose on every backend. The
complete-catalog escape is no longer Plex-only, so a Jellyfin row the
native player has not produced keeps the pass pending instead of
committing an unrelated default and retiring the listener that was
waiting for the real track. A source id absent from the catalog no
longer defers a decision that can never change, and the thirty-second
deadline resolves from what has arrived instead of re-deriving the same
deferral and applying nothing.
close#1696
The Next Up shelf dated its rows from one server-wide
`/Items?SortBy=DatePlayed&Recursive=true` scan. Jellyfin 12.0-rc3 builds
that sort key by OR-ing an item's own progress with its alternate
versions' (`ItemId == e.Id || Item.PrimaryVersionId == e.Id`,
jellyfin/jellyfin#17044), which no index can serve, so the user's whole
UserData table is scanned per sorted row. Measured on identical
10,120-item libraries, that scan cost 25ms on 10.10.7 and 5.8-13.3s on
12.0-rc3 while pegging a core, so it blew the call's 10s budget and
starved every other client of the server for tens of seconds. Upstream
fixed the order mapper after rc3 in jellyfin/jellyfin#17422.
Ask each pending series for its own newest played episode instead:
`ParentId` bounds the sort input to that series, and the same 21 series
now resolve in 1.5s against the rc3 server with byte-identical dates.
The lookups run four at a time under a shared wall-clock budget and a
short per-request timeout, so a silent endpoint costs less than the one
default-budget request this replaced, and a `count: null` shelf can no
longer fan out one request per started series. Endpoint failover stays
off so a slow enrichment row cannot move the client off a working
endpoint.
close#1699
Cycling backdrops reach a fallback path only once every rotating path
has failed to load, but every hero passed the rotation-agnostic backdrop
list as the rotation set and the aspect-ordered candidates as the
fallback. One servable wide backdrop was therefore enough to hide the
square background for good, so phone detail and Discover heroes
cover-fitted a 16:9 backdrop into a portrait box instead of showing the
square image Plex supplies.
Give the rotation set the same aspect-aware preference the candidate
list already has: near-square containers rotate the square background
alone and keep the backdrops behind it as fallbacks.
close#1700
Explore only reached search through an app-bar icon that pushed a separate
screen. Touch and pointer builds now carry the field inline under the app
bar: results replace the shelves while the query is non-empty and the
shelves return when it clears. TV keeps pushing CatalogSearchScreen, since
a text field cannot share the spotlight scaffold with the bottom-pinned
browse rail and the on-screen keyboard.
Pull-to-refresh and the toolbar refresh action re-run the live query
instead of reloading hidden rows, and switching catalog source re-runs the
query against the new source rather than leaving the previous source's
results under its name.
Plex Explore showed only the Watchlist row. `/hubs/sections/watchlist`
answers with placeholder hubs — every entry carries `placeholder: true`,
`size: 0` and no `Metadata` — so `fetchHubs` mapped each one to an empty
page and dropped all of them. That is true no matter what the profile has
watchlisted; the shelves never rendered.
Read `/hubs/sections/home` instead, the section Plex's own web client
renders on its Home > Trending tab, and hydrate each placeholder from its
own key (six at a time). `directory` shelves list browse categories and
`clip` shelves list trailers, neither of which becomes a catalog item, so
they are skipped before spending a request. A shelf that fails degrades to
the ones that succeeded; a pass where every shelf failed still throws.
Discover ignores container offsets on hub keys and truncates with `limit`
instead, so a hub is one page: View All takes the whole shelf in a single
request rather than replaying page one, and hub requests drop `Media` and
`Image` elements the catalog layer never reads.
Since real device names started reaching the header, an accented one
made login impossible: dart:io refuses header values above 0x7F, and
CFNetwork puts the raw code unit on the wire as a Latin-1 byte, which
Kestrel rejects as a malformed request with 400 before Jellyfin routes
POST /Users/AuthenticateByName.
Encode every field the way the official Jellyfin SDK does; the server
already reverses it with WebUtility.UrlDecode, so the wire value stays
pure ASCII while the device list shows the real name. Quotes, commas
and `=` no longer need stripping either. sanitizeHeaderValue, which
still guards the Plex headers, now folds Latin letters to their base
form instead of emitting bytes no transport accepts.
close#1685
Detection now runs off the UI isolate and covers every platform where
the answer can be trusted.
Availability was a plain platform check, so Linux always listed VLC, mpv
and Celluloid, macOS always listed VLC and IINA, and Windows always
listed VLC and PotPlayer whether or not any of them existed. Each player
now has a detector that asks exactly the question its launcher asks:
`sh -c 'command -v'` for PATH launches so the kernel performs the
executable check, NSWorkspace/Launch Services for `open -a`, `where.exe`
plus the concrete install paths for Windows VLC, and the registered URL
handler for PotPlayer and the iOS players.
Detection is asynchronous and memoised behind KnownPlayers.probe rather
than a Process.runSync in a static initialiser, which forked three
shells on the UI isolate during ExternalPlayerScreen.build. It is
prewarmed from startup, fails open when a probe throws, and keeps the
selected player listed when a detector misses it so a false negative
cannot leave the list with nothing selected.
iOS and tvOS gained LSApplicationQueriesSchemes entries for vlc and
infuse. Without them canOpenURL returns false for both schemes, so
_launchUrlScheme was already refusing to hand off to either player.
Android keeps the platform check: package visibility needs native
declarations, and a wrong answer there hides a working player.
A room whose peers have all left is a code nobody is using, but the relay
kept it bound to the creator's reconnect capability and rejected every
other create with room_exists. The app compounded it: enterRoom only
promoted to host on room_not_found, so tapping a recent code landed the
user in the retained room as a guest of a host that was never coming
back, until the cleanup sweep finally dropped the room.
Create now replaces a room with no connected peers, and enterRoom hosts
the code when its probe join finds an empty room. An occupied room still
rejects create, including from its previous owner, and a host that is
merely disconnected still reclaims its peer ID through join with the
matching token.
Persisting the deferred choice suspends, so the source switch can be
superseded before the pass is armed. Return early when the continuation is
stale, and refuse to arm a disposed or inactive TrackManager at all.
The per-callback generation checks only stopped the work; the subscription
and the five-second timer were still allocated on a manager whose dispose had
already run, so nothing would ever cancel them.
Return initialized show and season rules from the backfill query so their
coverage is recomputed from download ancestry. Rule execution links only the
unwatched episodes it inspected, so the cached flag let a sibling list
cleanup delete episodes the show rule covers.
Keep the action enabled and let a press retry the snapshot, as the media
detail action bar already does. A disabled sole action left the detail screen
with no initial D-pad focus on TV.
Distinguish an unresolvable URL from a failed load at the error-widget
boundary. A transiently null media client during a profile switch or
reconnect marked the primary poster dead in a process-global set, pinning the
item to fallback artwork for the rest of the session.
Only the load that commits a favorites set writes the loaded flag, so a
refresh keeps the previous set authoritative. Clearing it up front widened
the guide to the full lineup for the whole round-trip and moved the D-pad
cursor when it collapsed back.
Log and continue instead of letting the relay release abort the back handler,
matching the session screen and overlay. A guest pressing back with an
unreachable relay stayed in the player.
Match the tolerant list predicate the import path already uses. The platform
preference cache returns List<Object?> after a restart, so the exact
List<String> pattern silently dropped tracker library filter ids and a
restored profile resumed scrobbling libraries the user had excluded.
Supplementary repair runs over downloads whose video is already complete, so
it no longer emits a downloading transition, and artwork updates carry the
row's real status instead of asserting downloading. Previously a reconnect
left completed downloads stuck at "downloading 0%" until the next DB read.
Storage exhaustion fails every active row in one transaction, so
failActiveDownloadsForStorageFull now returns the affected keys and each one
gets a failed event; only the triggering key was announced before.
The post-recovery database open also closes its handle before rethrowing. A
failing storage-full write abandoned a drift background isolate and its
SQLite handles on every retry.
Persist every user-entered URL that is not positively known to belong to a
different server, matching reconcilePreviouslyStoredBaseUrls. Requiring a
successful identity probe deleted a stored LAN endpoint whenever the box was
asleep or the user saved from outside the network.
Persist the choice before arming the deferred selection pass. The screen
callback routes to onSubtitleTrackSelectedByUser, which invalidates the
pending selection, so arming first retired the very listener that applies
the choice once mpv discovers the sidecar.
The existing test stubbed the persist callback and so could not observe the
invalidation; it now routes through the manager like production does.
Back left the Manage Libraries sheet open on Android with no way to
dismiss it. The host answered the platform pop with
`BackKeyCoordinator.consumeIfHandled()`, which dedups the focused key
path against the platform pop. Only TV routes one Back through both;
touch platforms never deliver Back to the sheet's key handler, verified
on device — a physical Back produced only `popRoute` and no key event.
So there was nothing to dedup against, and the global one-shot marker,
once set by any other handler, silently swallowed the only signal that
closes the sheet.
Scopes the dedup to TV. The TV regression that guarded this never set
the TV override, so it asserted the swallow on every platform and hid
the defect; it now enables the override and a touch counterpart pins the
dismissal.
Also blocks semantics behind the barrier. The barrier takes every
pointer event but left the screen underneath in the semantics tree, so
assistive tech and UI automation still saw rows that could not be
activated — Maestro read an occluded settings row as visible and tapped
its stale coordinates into the sheet. Flutter's own ModalBarrier blocks
semantics for the same reason.
Verified by replaying the failing Maestro sequence
(.maestro/subflows/settings_deep_checks.yaml lines 42-60) on a device:
back dismisses the sheet, Services opens, and back returns to settings.
On phone layouts main_screen pushes SettingsScreen as its own route, and
that route carried no OverlaySheetHost. showAdaptive could not find one
from the tile's context, so Manage Libraries fell back to
showModalBottomSheet. The sheet also owns a focused Back handler, so a
single Android Back arrived twice — once as a key event, once as
popRoute — and the two route-based paths raced, tearing down Settings
along with the sheet.
Installs one route-local host when no enclosing host exists, and opens
the sheet from a context below it. OverlaySheetHost then holds the route
while a sheet is open and deduplicates the key path, so one Back closes
only the sheet.
When a source advertises subtitles the native track list has not
produced yet, applyTrackSelectionWhenReady keeps an automatic selection
armed for up to thirty seconds. That late pass re-runs
TrackSelectionService against the stored preferences, so a track the
user picked in the meantime was silently reset. The Maestro codec suites
caught it: the English E-AC3 and Japanese DTS-HD flows select an audio
track, and fifteen seconds later the deadline puts the preferred
language back.
Adds explicit user-selection entry points that retire the pending
automatic selection first, and routes the sheet callbacks and the
remote's cycle shortcuts through them. Subtitles get the same treatment,
because the same pass re-selects them.
Bumping the generation is sufficient: TrackSelectionService re-checks it
in the statement immediately before each select call, and a mutation
already in flight was dispatched before the user's and so lands first.
`dart format --set-exit-if-changed` over lib and test rewrites these.
The analysis job never reached its formatting step, so the drift went
unnoticed. No behaviour changes.
StubMusicPlaybackService is a base for test doubles with no production
caller, so `check-unused-code lib` flagged it and the analysis job
failed. Moves it to test/test_helpers/, where shared fakes belong.
`clean_translations.py --strict` reported seven unused keys.
Three are genuinely dead: TrackSelectionHelper.getEmptyMessage was
removed as unreachable in 4307c49c, and the sheets that need an empty
state carry their own strings. Removes them from every locale.
The four accessibility keys are false positives. tv_number_spinner
aliased the subtree as `final a11y = ...accessibility`, which the static
scanner cannot follow — its docstring says as much. Binds the documented
`final t = Translations.of(context)` instead and hoists the two labels,
so the semantics stay identical and the scanner sees the chains.
Complete the contributed translations against the current English source and
register the locales in the language picker.
Fills the 51 keys the contributions predated, including the whole
downloads.backgroundWarning block that every locale must translate, and
restores the ${count} placeholder the sync-rule confirmation had dropped in all
three files.
Also corrects contributed strings: Azerbaijani "Imkan (Kopyalama)"
(possibility/copying) for resolution and several Turkish spellings, and Kazakh
Latin-script leaks ("Keyinirek", "Subtitr") plus Uzbek loanwords ("бош", "бир",
"муаммо") that do not read as Kazakh.
Consolidates #1689, #1690, and #1691.
Co-authored-by: Omc725 <98108290+Omc725@users.noreply.github.com>
Complete the contributed translation against the current English source and
register Turkish in the language picker.
Fills the 51 keys the contribution predated, including the whole
downloads.backgroundWarning block that every locale must translate, and
corrects a few contributed strings: "Sesi Kıs" (volume down) for mute,
"Disket" (floppy) for disc, "bitiş hızı" (finishing speed) for bitrate, and a
"Kısayol Ayaıla" typo.
Consolidates #1683 and #1688, which contributed byte-identical files.
Co-authored-by: Omc725 <98108290+Omc725@users.noreply.github.com>
Settings rows carried their own platform-conditional typography and
density, so on desktop and TV they rendered a 16px title, 14px subtitle
and 80px row while every other row in the app — the Focusable*ListTile
defaults plus ThemeData.listTileTheme's `dense: true` — renders 13/12
in 61px.
Drop the overrides instead of re-tuning them: the tile defaults already
encode the app's row style, and the explicit title styles were redundant
under a dense ListTile (they also masked the disabled/selected title
color). settingsOptionTitleStyle now only serves group children that are
not ListTiles, and matches the dense title unconditionally.
SettingsGroup hands its children that same compact density, so the plain
ListTiles used as non-interactive info rows stop standing 11px taller
than their interactive siblings.
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.
Audio session, per the guide's sequence:
- Adopt the long-form playback profile in one atomic call at app launch
and activate the session there. The SDK only accepts that policy with
category Playback, a Default/MoviePlayback/SpokenAudio mode and no
options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
system resolves it. Apple only resolves it for CarPlay and AirPlay, so
an unresolved value means unknown, never "not Dolby".
Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):
- Add a sample-buffer arm. It reads the asset with AVAssetReader at
outputSettings nil and hands the untouched compressed buffers and the
untouched format description straight to the renderer, with a variant
that rebuilds the description the way playback builds it. Every
existing mode went through AVPlayer, so nothing exercised the path
playback actually uses; this is what tells us whether the renderer or
our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
resolves the rendering mode and the supported channel layouts, so it is
what makes those observations reachable at all, and the AVPlayer arms
now allow external playback so every arm can be compared on the same
destination.
- Add a session-mode toggle for the one profile difference between the
guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
descriptions, the magic cookie and the renderer status, and release the
session on stop so a failed run cannot contaminate the next one.
Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.
This does not yet fix#1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.
Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.