A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:
A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.
English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.
Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.
Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.
Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.
All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.
scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.
One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.
close#1856
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.
With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.
The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.
NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.
Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.
The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.
The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.
lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.
Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
Every tracker auth dialog (Trakt/Simkl/MDBList device-code and MAL/AniList
OAuth proxy) now shares the same PendingAuthDialog affordances: a QR code for
the sign-in URL, a large copyable URL with the scheme stripped, the browser
launch button (hidden on Apple TV, which has no browser), and the polling
spinner. On wide viewports (TV logical 960x540, desktop, phone landscape) the
QR pane sits beside the instructions so the dialog no longer clips on tvOS,
and the content is scrollable as an overflow safety net. Device activation
codes scale down instead of wrapping.
Watchlist membership was only reachable from Explore cards and the
detail screen's action row, which drops the bookmark first on narrow
screens with no fallback in the overflow menu. Add an entry to
MediaContextMenu for movies and shows whenever a connected catalog
source can hold the item, covering card long-press everywhere and the
detail screen's overflow.
External-id resolution is session-cached per item on
CatalogSourcesProvider and shared with the detail screen. A cold cache
labels the entry "Add to Watchlist" and always adds (idempotent), so a
press can never turn into a surprise removal; "Remove" is offered once
cached membership proves it. Several capable sources open the same
per-source chooser the detail screen uses.
close#1822
The Explore tab appears for every Plex-backed profile because the Plex
Discover catalog source connects implicitly, with no way to opt out
short of a Jellyfin-only profile. Add a Show Explore Tab switch to
Appearance > Navigation that hides the tab in the bottom navigation and
side rail. UI-only: catalog sources stay connected so watchlist
surfaces keep working while the tab is hidden.
close#1844
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android +
ExoPlayer only, default off). When enabled, the text SubtitleView is
sized to the full container instead of the letterboxed video rect, so
SRT/VTT/mov_text cues render in the black bars below widescreen video
and font size and the position setting become relative to the physical
screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged;
mpv already places plaintext subtitles in the margins by default.
close#1730
With "Default to Favorite Channels" enabled and no favorites stored —
or only favorites left over from a since-rebuilt lineup — the favorites
filter reduced the guide to zero channels and GuideTab rendered just
the timeline bar: no rows, no message, no sign a filter was active.
Users read it as Live TV being broken; the Aug 8 report in #887 shows
44 channels and 447 grid programs loading in the log while the
screenshot shows a blank guide and 0 favorite channels.
When the filter removes every loaded channel, the guide tab now shows
an empty state naming the cause with a "Show All Channels" action that
clears the filter. The action stays D-pad reachable: the tab-bar focus
handoff falls through to the action's focus node while the empty state
replaces GuideTab, and activating it hands focus back to the restored
guide content. Favorites that match no loaded channel get the same
treatment as an empty favorites list.
Verified: flutter test test/screens/livetv/, analyzer parity,
clean_translations --check --strict, and slang codegen freshness.
Refs #887.
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.
Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:
Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.
Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.
The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.
Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.
close#1738
Refs #1815, #1622.
ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a
server that keeps refusing the stream at open time left a silent black screen:
ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached
the screen. A new open-phase watchdog arms on the first 503 seen before any
frame renders and, after 20s without one, synthesizes a server-http-503 error
that shows an actionable dialog. Mid-stream 503s and live TV keep their
existing ride-out paths.
close#1830
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.
BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus
Flexible(child:), and each sheet body shrink-wraps its own scrollable. The
scaffold's shrinkWrap flag is gone: its old true branch put the child on an
unbounded axis, where an over-tall list overflowed instead of clamping and
scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px
to 118px for one chapter and the two-column track sheet from 750px to 154px
for one audio and one subtitle track, both still clamping at the cap.
Add SheetSplitColumns for the three side-by-side sheet layouts. A bare
VerticalDivider has no intrinsic height, so it inflated those rows to the cap
on its own; the rule now paints from a Positioned.fill that cannot size the
Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics.
Because sheets are bottom-anchored, a content-driven height moves the sheet's
top edge and everything above the change point. Three surfaces opt out for
that reason and say so at the call site: SubtitleSearchSheet and its language
picker keep filling, since both refilter under an autofocused field;
FiltersBottomSheet holds the outgoing page's height through its loading
transient; and RatingBottomSheet no longer hides MAL/AniList rows
asynchronously, which used to slide live rating controls down two rows several
hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet
boundary rather than editing a widget with 33 filling call sites.
The host gains an AnimatedSize keyed per sheet session so nested pushes ease
while a replacing show adopts its own height, a 720px absolute height ceiling
on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold
so short sheets neither close on a nudge nor become undismissable.
Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
Nav bar labels that overflow their tab slot on phones are shortened to
idiomatic short forms: fr (Bibliothèque, Téléchargement, Recherche),
ru/bg/it (Live TV), pl (Home).
All 21 locales get the ~280 keys that were empty (falling back to
English at runtime): explore detail/badges/stats, fileInfo, startup
repair flow, mediaMenu delete dialogs, addServer, rating sources,
downloads sync-rule removal, and more.
settings.displayScale was missing entirely in 16 locales; the new
exoplayer playbackBuffer keys (upstream feat) are translated too.
Fixes mis-translations found in review: es/zh/zh-Hant sidecar-format
wording, sv adaptation, nb transcoding.
Regenerated with dart run slang; translation hygiene and i18n tests
pass.
Close#1823
ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from
one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or
less free, with no way to raise it. On hardware where mpv cannot render at all
that ceiling is the whole buffer budget.
Playback Buffer offers Auto, Large and Extra Large. The durations are taken
from jellyfin-androidtv and jellyfin-android so the same words mean the same
thing across Jellyfin clients; Auto keeps the memory-tiered values that
shipped. Named tiers rather than a duration because a duration would be a
promise the load control cannot keep: prioritizeTimeOverSizeThresholds is
disabled, so targetBufferBytes stops the loader even below minBufferMs and the
byte cap binds first above roughly 23 Mbit/s.
The tier crosses the method channel as a string and resolves in the core,
where an unrecognised name falls back to Auto. LoadControlPolicy clamps the
resulting pair: media3 validates the ordering with Guava Preconditions, an
unconditional throw R8 does not elide, so a bad pair would be an
IllegalArgumentException out of player construction rather than a bad buffer.
The two play-start thresholds stay fixed even though the Jellyfin tiers move
them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from
BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the
stall watchdog indict a player that is obeying its own load control.
That leaves the byte target as a second, often smaller ceiling, and nothing
surfaced either. The resolved values now reach getStats, and the overlay's
Buffer section gains a Cache Limit row reading "120s / 128MB" next to the
buffered-ahead duration, so a tier that appears to do nothing on a
high-bitrate file explains itself.
close#1816
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.
The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
Tunneled playback on an AFTMM judders continuously through 23.976p direct play.
The #1802 reporter isolated it: turning off Tunneled Playback with every other
setting unchanged makes it smooth, and their log shows tunneling active for the
whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing.
Audio Passthrough looked like the trigger only because it is the one user-facing
switch that decides it. Passthrough off, or Downmix to Stereo on, both force the
Dolby track to decode to PCM, which trips the #1458 guard and takes tunneling
down with it. Passthrough on with downmix off is the only combination that keeps
a bitstreamed track, so it is the only one that stays tunneled.
Withdraw tunneling on that model for content at or below 30fps. The cut-off
keeps 4K50/60 tunneled, which is the workload Amazon documents the feature for.
The mechanism stays unconfirmed: tunneling fires no VideoFrameMetadataListener
and stops media3 counting frames in the codec, so nothing app-side can measure
the cadence. Only the trigger is established, and the quirk is scoped to it.
That needs a frame rate the app did not have. Neither MatroskaExtractor nor
Mp4Extractor populates Format.frameRate, and a tunneled session renders no
frames back for the native detector, so the server's rate now rides on the open
call. It is sent only for direct play, matching _primeDisplayCriteria: a
transcode's metadata describes the source, not what the server is about to send.
Also move Audio Passthrough out of the in-player settings sheet. It configures
the audio output route rather than the current playback, and applying it
mid-stream bounces the audio renderer and re-decides tunneling. Settings > Video
Playback already owns it, next to Tunneled Playback, which is applied the same
way. That description now mentions stutter, not only black HDR video, so the
workaround is findable on hardware this quirk does not cover.
The mpv backend failing to start the same 4K file is a separate defect and is
not addressed here; its uploaded log is no longer retrievable.
"Delete from server" read identically for an episode, a season and a
whole show: same menu label, same dialog title, same red button, and a
body that named nothing. The menu header did not disambiguate either,
because MediaItem.displayTitle collapses an episode to its show name.
A reporter deleted a whole series from the detail hero's ⋮ believing it
acted on the episode he had highlighted, and the confirmation gave him
nothing to catch it with. Every one of those strings now names the kind,
and the body names the exact item — show, season and episode number, and
episode title.
Deleting a single item also destroyed files the confirmation never
mentioned: a Plex multi-episode file (S01E01-E03.mkv) takes its other
episodes with it, and a split item takes every part. The dialog now
reports that up front and, on success, emits deletion events for the
siblings the server destroyed so their rows do not linger.
The scope behind that warning is only asserted when it is established.
MediaItem.allPartFiles drops parts with no path, so a non-empty set
proves nothing about the ones it filtered out; a version is trusted only
when every part reports a file. A browse row that omits paths is missing
evidence rather than proof of a distinct file, so both the target and
each candidate sibling fall back to the detail endpoint before any
conclusion — otherwise a thin row, including the file-less part
PlexMappers fabricates for an empty payload, would look like a server
that withholds paths. When the answer cannot be established the dialog
says so in an error-tinted block and its button reads "Delete anyway",
separating a transient probe failure from a server that never sends
paths. It deliberately does not refuse: Plex withholds paths from
restricted users the server itself authorizes to delete, so failing
closed would take the feature away from them permanently.
Probing a season stays bounded in both directions. Siblings resolve one
at a time, so a season of thin rows cannot fan out a detail request per
episode, and expiry cancels the walk rather than merely abandoning it —
`Future.timeout` completes the future the caller awaits but leaves the
work behind it running, which would resume on the next sibling once the
outstanding request answered. A cooperative flag is checked before each
lookup, so at most the one already in flight outlives the deadline; the
neutral client exposes no abort handle for item lookups, so that one
cannot be recalled.
The spinner covering the probe was only barrierDismissible, which does
not stop system back. Back dismissed it and the cleanup pop then closed
the screen underneath, dropping the user out of the detail page
mid-flow. It now traps back, matching the non-dismissible contract its
own doc claims, which also repairs the log uploader and the file-info
sheet.
Coverage splits by what each layer owns. The dialog, its copy and the
DELETE wiring are backend-neutral and stay in the menu widget tests.
Plex — the backend multi-episode files actually come from — gets the
resolver over a real PlexClient and a mocked transport: a row with no
media at all, scope recovered from /library/metadata/{id}, siblings and
paths from /children, a Part that names no file, a sibling whose path
never resolves, the request count a sixty-episode thin season may cost,
and the rating key the DELETE carries. Those are plain async tests
because the Plex metadata cache is a real database whose I/O the widget
tester's fake clock never drives. Deadline behaviour needs the opposite,
so it is pinned separately under fakeAsync against a gated fake client,
with no wall-clock waiting anywhere.
close#1781
A Plex account connection labels itself with the account owner's name.
Under a profile tile that reads as being signed in as the owner: the
Plex Home tile showed the owner beneath the Home user's own name, and a
local profile that borrowed a Home user out of someone else's account
showed only the lender.
Both halves of the relation now go through a single translated string,
so a locale orders them itself instead of inheriting the English
"user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the
account first. When the Home cache cannot resolve the connection's uuid
the chip names the account alone rather than falling back to a bare
name. ProfilesView carries the Home user cache that resolution needs,
and chip labels ellipsize now that an account label can be an email.
fix(profiles): label a Plex Home parent connection as an account
Conflict resolution: regenerated the Slang outputs against main's
translation set, added the empty locale placeholders the translation
gate requires, and gave the new widget test the StorageService provider
the picker now reads for profile recency.
Connects MDBList through its OAuth device-code grant, registered as a
Device Code app so no client secret or redirect URI ships in the binary
and TV, mobile and desktop all use the same flow.
MDBList omits `verification_uri_complete`, but its device page seeds the
code field from a `user_code` query parameter and the sign-in redirect
preserves the query string, so the activation link is built locally and
the dialog's open button lands on a filled-in form instead of an empty
one. A server-supplied complete URL still wins if one ever appears.
Poll state is read from the response body rather than the status code:
`authorization_pending` and `slow_down` both arrive as HTTP 400, and a
missing grant answers 404 `device_not_found`.
Writes go out as real-time `/scrobble/*` reports plus `/sync/watched`
for the marks that never pass through the player, with ratings on
`/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block
has no `tvdb` field, so a TVDB-only item is skipped rather than written
under an empty id block.
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
A Plex Home profile's chip rendered the parent connection's displayLabel,
which for a Plex account is the account owner's username. The owner's name
appeared directly beneath the Home user's own, reading as the wrong user
being signed in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fdzda9t7kQtq7LoQ2v5nVF
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
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
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>