A television raised the whole OSD and timebar on every playback start. The
chrome controller is born visible, and its auto-hide clock cannot arm until the
first frame lands, so the controls did not merely appear early: they appeared
exactly when the picture did, and then sat over the opening five seconds of
every movie and episode. The timeline is gated behind the first frame, so the
bar materialised on top of the video rather than over the loading spinner,
which is what makes it read as a pop-up rather than as chrome that was already
there.
The route now opens with no chrome on TV. Nothing is lost: the loading spinner
and buffering overlay are their own overlays, the screen focus node owns back,
and the first D-pad press raises the controls the way it already does after
every auto-hide. Pointer and touch platforms keep the chrome, where the
viewer's hand is on the surface and the title and back affordance belong over
the spinner.
Initial presentation now follows initial visibility. They were separate:
seeding only visibility would leave the route claiming its chrome was still
presented, so PlayerNavigationCoordinator would read back as "hide the chrome",
hide() would no-op against chrome that was never up, and the press would be
swallowed instead of leaving the player.
Controls that mount with the chrome already down now claim focus themselves.
Focus normally reaches them through the hide transition, and their own
autofocus cannot win it back because the screen node took it during the loading
phase. Left alone, the screen node kept primary focus and its self-heal raised
the entire OSD on the first D-pad press, which put the chrome straight back
over the picture and bypassed the transient seek and transport indicators.
Both player spinners now carry a label. They were bare progress indicators, so
a screen reader announced nothing at all while the picture was coming up, and
the TV Maestro flows had no way left to tell a loading player from a playing
one once the Pause button stopped appearing on its own.
The two TV flows are repaired to match. They waited on that button, and now
wait for the labelled spinner to clear, which cannot happen before the media is
opened. 05 additionally reaches Search by D-pad rather than a percentage
coordinate, because a tap flips InputModeTracker to pointer mode and collapses
the rail it is aiming at, and it gates on the play-next prompt's own Cancel
action: "Next Episode" is also the credits skip button, so the old assertion
could pass without the prompt ever opening.
close#1765
A reporter on #1732 ran three successive builds against a preference store of
10336 bytes, every one of them zero, and reported each as "still failing". The
gate classified it correctly every time and the consented repair would have
cleared it in-process, but nothing on the failure screen said so: Retry was
first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside
it as a tonal afterthought. Retry re-reads the same document, so for a
corrupt-store failure it is an action that cannot succeed however many times it
is pressed — and it was the one the screen recommended.
Repair now takes the primary styling, the focus node and first position whenever
it is offered, and the body text says plainly that retrying will not help.
Retry keeps its place for every other failure, where a locked database or a
denied directory really can change between attempts.
The consent dialog was also promising an outcome it could not always deliver.
Servers and profiles survive a repair only because their tokens are ciphertext
in the database and the key that decrypts them lives in the store, so a store
the key cannot be read out of signs the user out of everything — exactly the
all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without
touching it, and the dialog now names the real cost from that. The retained copy is
labelled as holding credentials unless the bytes prove otherwise: what the
salvage recovered says nothing about what the file still contains, because a
store truncated mid-value keeps most of a vault key in plaintext while the
salvage pattern — which needs the value's closing quote — matches nothing at
all. Only an all-zero file drops the warning, so the one case that cries wolf
is the one that provably holds no secret.
`describe()` finally carries whether a repair was on offer. That line is the
difference between a report a maintainer can act on and two days of guessing
whether the button was even on screen.
close#1732
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor
took `rating` and fell back to `audienceRating` only when it was null, so a
Plex movie carrying four attributed scores surfaced one, and which one was
whatever the server happened to put in the scalar slot. #1755 asked for a
setting to choose the source; showing all of them answers it without one.
The data was already on the wire and being thrown away. `/library/metadata/
{id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels,
TMDB — with no extra query parameter, but PlexMetadataDto declared no field
for it, so json_serializable dropped the key. The identical parse already
existed in plex_catalog_source for the Explore tab and had simply never been
wired to library items.
Model the scores as a list rather than widening the scalar pair. The neutral
MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage
and audienceRatingImage, which the list subsumes — Plex sends those images
on listings too, so the same field covers both response shapes and no caller
narrows to a backend type to read a score any more. CatalogRatingSource is
promoted to lib/media as MediaRatingSource instead of growing a second
near-identical type beside it, and plex_catalog_source's _ratingsFor becomes
the shared plexRatingSources so one implementation serves both paths. There
is no persistence to migrate: MediaItem.toJson has no production caller, the
offline path re-parses raw Plex JSON through the same mapper, and Plex's
audienceRating sort is server-supplied data, not a model read.
Cards and the dashboard still show fewer scores than detail screens, and
that part is a real Plex limit rather than a shortcut. Section listings send
only the scalar pair; includeRatings, includeElements=Rating,
includeFields=Rating, includeChildren and includeExtras were each probed
against a live server and none surfaced the array, while includeGuids=1
demonstrably does add Guid[] — the probe works, the parameter does not
exist. Hydrating every card would be one request per row, so listings render
whatever their own response carried, which is one or two attributed scores
rather than the single one they showed before.
Jellyfin has no per-source array at all: the server collapses whatever its
fetchers found into CommunityRating and CriticRating. CommunityRating's
provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or
a local NFO, last writer wins — so it stays the generic `audience` source
with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a
0-100 percent and is divided by ten explicitly rather than folded by
magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have
promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin
reuses CommunityRating for the EXIF 0-5 star.
The badges share one slot on every surface. On the phone hero the scores go
in a single pill because that chip row is a height-clipped Wrap and a chip
per source would push year, certification and runtime out of the visible
band on short heroes; on the TV detail line and the dashboard spotlight the
group occupies the one metadata slot so bullet separators do not multiply.
The group announces itself as a single semantics node naming each source,
because a bare row of four percentages tells a screen reader nothing about
which score is which. rating_utils drops parseRatingImage and
isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper —
and the source-key resolver and label map, previously private to the Explore
detail screen, become the shared pair both screens use. The label strings
move from explore.ratingSource to common.ratingSource accordingly, which
costs no translations because every non-English value was empty; running
clean_translations also scaffolds startup.quitPlezy and
startup.restartRequiredBody, which were already drifted.
Verified against the live server the probes came from: a detail response now
yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the
production mapper and badge resolver, and the listing response for the same
title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures.
Coverage adds mapper ordering, dedupe against the array's repeat of the
scalar, out-of-range rejection, the Jellyfin scale and photo guard, the
CatalogItem conversion that feeds Explore's dashboard hubs, and the three
render surfaces including the semantics announcement.
close#1755
A seed-and-restart repair writes the salvaged credentials straight to disk and
leaves this process's store closed, because the plugin still holds the bad
document in memory. repairCorruptStore says so plainly — "nothing may write a
preference before that restart … the caller keeps the app on the failure
screen precisely so nothing does" — but the caller did not. Clearing the
repairing flag re-enabled Retry, and pressing it reopened onto the stale map,
whose first write would flush it back over the seed and orphan every
ciphertext token in the database.
The repair hook returned a bare bool, which cannot express the difference
between "retry now" and "never retry in this process", so replace it with
StartupRepairResult. The restart case latches terminal state on the bootstrap,
withdraws Retry and Repair rather than grey them out — a disabled control
still invites another press — and says what to do instead, which nothing did:
repairNeedsRestart was a dialog title with no body anywhere. Desktop gets a
Quit button through the existing AppExitService seam; Copy and Upload stay
live everywhere, because a stuck user still needs the diagnostic out.
close#1732
A 404 on the media stream means the server resolved the item but could not
open the file behind it — moved, deleted, or on storage that went away.
Jellyfin maps the resulting FileNotFoundException to 404, and PlaybackInfo
never stats the file, so negotiation succeeds and only the stream request
fails. Playback then died with a snackbar reading "Failed to open
[REDACTED_URL]" before popping the route, which tells the user nothing and
leaves nothing useful in a bug report.
Generalize the HTTP-500 log probe into PlayerError.httpStatusFromLog and
latch every status in fatalPlaybackHttpStatuses. Each latches on its own so
the 503 that stream-lavf-o deliberately retries cannot mask the fatal status
behind it. A 404 now raises a dedicated modal naming the cause and the fix.
On Android a 404 previously failed the "Response code: 500" string test and
fell through to the ExoPlayer→MPV fallback, showing "switching to compatible
player" before failing again on the same request. Read the real status off
HttpDataSource.InvalidResponseCodeException instead and skip the fallback:
an HTTP status is not a codec problem.
Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.
The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.
Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.
The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.
Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.
Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.
Refs #1732
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
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.
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.
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
`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>
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.
Complete and revise every shipped locale against the current English source, preserve locale-specific plurals, and map script-specific Chinese locales through device, Intl, duration, and Plex boundaries.
Co-authored-by: emgeje <mgj@mgj.hu>
Co-authored-by: junyou1998 <junyou1998@gmail.com>
New TV-only Appearance toggle that pins the spotlight artwork to the
top-right corner (68% width, 72% height) with the left and bottom edges
feathered into the scaffold background, so the logo, metadata, and
shelves sit on a calm surface instead of the image. Default is off —
the full-bleed spotlight stays exactly as it is.
The artwork keeps its full-screen request size: the corner box only
crops the layout, so transcode URLs and image caches are unchanged and
toggling the setting stays instant.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
close#1520
Classify player EOF signals against the best-known duration: a mid-file
EOF means the stream died (transcode reaped or idle connection closed
during a long pause), not that the media ended — it must never mark the
item watched, prompt Play Next, or exit a movie. Recover with a bounded
in-place reload at the parked position; if the server is still refusing,
park on the old frame and rebuild the stream on user play/seek or when
the server-status monitor sees it come back online.
Queue sheet/panel now renders the whole playback order — dimmed played
tracks above the current one — instead of only up-next. Opens scrolled
to the current track and follows advancement while parked on it.
Un-gates music libraries; adds CardShape.square plumbing (circular
artists), artists/albums/tracks browse groupings with per-grouping
sorts, audio playlists tab, artist + album detail screens with dpad
track rows, music context-menu actions, and a stub MusicPlaybackService
seam for the upcoming engine.
Extract the manage/reorder-libraries sheet from the libraries screen into
a shared widget with a public launcher, and add a settings tile (top
group, gated on having libraries) that opens it. The libraries screen
keeps its side-nav refresh and re-select-on-hide behavior via launcher
hooks; the TV dialog path lives in the launcher so the entry works there
too.
Desktop overlay sheets defaulted to a fixed 400px max height, which left
the reorder sheet ~280px tall. Both default-constraint sites now use 75%
of window height, matching mobile.
close#1468
Badge the mobile Library options icon with a dot while filters are
active, and replace the "This library is empty" state with a
"No items match the active filters" message plus a reset button.
close#1470