Hovering the back button in bottom sheet headers drew the circular
highlight 12px right of the arrow: the 48px hit target was positioned
from the stack's padded origin while the glyph sits flush at the
leading content edge. Move the horizontal padding onto the header row
and position the target so the InkResponse box centers on the glyph.
scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
Enabling audio passthrough on macOS 2.14.0 silenced every AC3/EAC3/DTS
item: the macOS player now pins ao=coreaudio, where audio-spdif redirects
to coreaudio_exclusive. That needs an IEC61937-capable device Mac setups
essentially never have, and with a restricted ao list mpv has no PCM
fallback, so the failed AO init stalls playback with no audio. The toggle
never delivered real bitstreaming on macOS anyway (2.13 decoded through
AVPlayer), so stop offering and applying it there.
Gapless playback still gapped between tracks on network streams: mpv
only opened the armed next track's stream at the moment the current one
ended, so any server whose connect+probe outlasts the audio output's
buffered tail (~0.5s) - remote Plex over TLS, a transcode session
starting up - produced an audible dropout at every transition.
Enable mpv's prefetch-playlist when arming a network track so the open
happens while the current track still plays. Measured on a Pixel 7 the
boundary goes from 60-233ms of inline network work to 9-16ms with no
network activity at all; a failed or superseded prefetch falls back to
the old boundary open. Local fdclose:// arms keep prefetch off: an
early open would consume the fd while playlist-pos still reads 0,
breaking _clearArmedNext's "provably never opened" close proof.
close#1869
_startPlayback and _reloadMediaInPlace each inlined the same ~180-line open sequence around the playback_open.dart helpers — frame-rate prep, display priming, startup gate, external-subtitle plan, open, track manager build, track apply, gate release — with comments instructing that the two copies be kept in sync, and they had already drifted (live _isTranscoding vs result.isTranscoding, Watch Together attach vs detach/reattach).
The sequence now lives once in _openResolvedMedia; the genuine divergences are explicit parameters and caller hooks (transcoding source, WT handling, session-commit boundary, automotive start deferral, resume timing), so a future change to open sequencing lands in both flows by construction. Every await boundary and staleness guard keeps its original position in both flows.
BaseLibraryTabState documented an abstract loadData() that every tab "must implement", but three of the four tabs override loadItems() entirely and carried never-invoked empty loadData stubs; only the recommended tab exercised the contract, so the class docs described an extension surface that did not exist.
loadItems() is now the one overridable hook, and the shared load transaction (generation tracking, localized error mapping, post-frame onDataLoaded) lives once in the protected runLoadTransaction helper that the recommended tab and the focus tests route through. No tab's load behavior changes.
The library quick picker and the libraries dropdown each hand-rolled their own BackendBadge + server-name label rendering with divergent show-when policies: the picker only labeled libraries whose titles collided, so a uniquely-titled library on a multi-server list showed its server in the dropdown but not in the picker.
Both surfaces now share library_server_label.dart — one label widget, one grouping loop over groupLibrariesByFirstAppearance, and one policy: group headers whenever the visible list spans more than one server, per-row labels only in ungrouped lists. The picker's duplicate-title heuristic is gone; its test now pins the unified policy.
The job "which Plex token represents the active profile right now" was implemented three times against the same registries — the Discover session supplier, the Seerr token supplier, and UserProfileProvider's settings refresh — and the copies had already diverged on whether a Plex Home profile may fall back to the account token.
resolveActivePlexToken in lib/profiles/active_plex_token.dart now owns the policy: the per-user ProfileConnection token wins when present, else the account-owner token, with an explicit allowAccountTokenForHomeUser flag (true for Discover/Seerr, false for the settings refresh, which must not impersonate the owner). The three call sites keep only their genuine differences.
profiles_view.dart rebuilt the exact merged-profile view ActiveProfileProvider already computes — the same four source streams, the same merge/avatar derivation, plus a hand-rolled combineLatest4 — and the profile switch screen was its only consumer while already reading the provider for activeId.
The screen now renders from the provider (new connectionsByProfile/connectionsById/plexHomeByConnectionId getters) and gates loading on provider initialization; visibleProfileConnections moved to profile_merge.dart for profile_detail_screen; profiles_view.dart is deleted. The switch-screen tests initialize the provider up front like boot does, using a timer-less PlexHomeService subclass so start()'s periodic refresh timer cannot trip the widget-test pending-timer invariant; the deleted pipeline's merge assertions were ported to profile_merge_test and active_profile_provider_test.
lib/media declared two different enums named MediaStreamKind — a 4-value one in media_stream.dart and a 7-value one in media_file_info.dart — so any file importing both libraries silently picked one by import order.
media_stream.dart's enum now carries the full member set (image/data/lyric added before unknown; the original four ordinals are preserved) and media_file_info.dart re-exports it instead of declaring its own.
Media buttons on HID remotes (USB/Bluetooth keyboards, common on Android
TV) are delivered as key events to the focused window instead of the
MediaSession, so they only worked while the app was backgrounded. A
global handler now routes play/pause, next/previous, stop, and
fast-forward/rewind to the live music session anywhere in the app and
consumes the key burst, so a press can neither leak to Android's
fallback MediaSession dispatch nor start a focused library item. Same
lifecycle as the OS media session; video playback never coexists with it
because claiming video disposes the music session first.
close#1948
close#1952
Shows whose Season 0 holds aftershow featurettes (e.g. House of the
Dragon "Inside the Episode") share air dates with the episodes they
accompany, so the air-date interleave from #1416 queued them between
regular episodes: playing S03E04 offered S00E76/S00E84 as up next.
Air date alone cannot separate canon Specials from featurettes, so
Specials placement is now a three-way preference, defaulting to
"follow server order":
- respectServer: Plex keeps its server-built /allLeaves queue (aired
order, Specials interleaved, as before); the Jellyfin queue now
preserves the /Shows/{id}/Episodes response order, which is
Jellyfin's native watch order — Specials placed only via explicit
AirsBefore* metadata per the server-wide DisplaySpecialsWithinSeasons
setting (the endpoint ignores SortBy except Random). Client-side
selections with no server order to respect (offline next/prev,
download "next N", offline OnDeck) fall back to Specials-last.
- airDate: the #1416 aired interleave on every surface.
- specialsLast: Specials strictly after the regular seasons (#1414);
Plex builds its show queue from /children.
The sync delay slider spanned ±60s with 100ms steps, making fine
adjustment between e.g. 100ms and 200ms impractical on touch and mouse.
The slider now covers ±10s at 50ms per step (taps and D-pad included),
while the +/- step buttons still reach the ±60s absolute limit via
long-press, so existing large saved offsets keep working and display
their true value.
close#1907
Adding or removing a Plex Live TV favorite channel never persisted: the PUT
to epg.provider.plex.tv/settings/favoriteChannels answered 400. The dio ->
package:http migration (15b22f75) lost dio's implicit JSON content type, so
the JSON-encoded favorites list went out as text/plain and the Plex cloud
refused to parse the body. Verified live: the identical payload succeeds
with application/json and the mutation persists.
MediaServerHttpClient now defaults content-type to application/json for
structured bodies. Callers and client defaults still win (the headers map
is case-insensitive and already populated), so Jellyfin's pinned default
and the octet-stream artwork upload are unaffected; favorites is the only
Plex request with a JSON body.
close#1878
Music on iOS played through a mixable audio session: mpv's audiounit
output requests mixWithOthers unless audio-exclusive is set, and a
mixable session disqualifies the app from Now Playing. iOS ignored the
published metadata and remote-command targets, so the lock screen showed
no controls, headphone buttons drove the previous media app, and other
apps kept playing alongside plezy.
Set audio-exclusive on the audio-only core at init on iOS — the same
contract the video player already applies at playback start. Its only
effect there is dropping mixWithOthers.
close#1921
Pausing past the server's paused-session limit (or an admin stop) removed
the session on PMS, but Plezy kept sending paused timeline heartbeats,
re-registering it as a zombie session the server could no longer clear.
Plex signals the termination with terminationCode/terminationText on the
MediaContainer of the next timeline reply. Detect it, close the reporting
session with one final stopped report at the current playhead (which
removes the session row), and suppress paused heartbeats plus the
transcode keepalive until playback actually resumes, which opens a fresh
server session.
close#1916
Guests joining an "Anyone" room saw "Host controls playback" and a
locked room until the host actually started something. Control mode
only travelled inside the host's PlaybackState broadcast, and every
broadcast path requires an active media epoch, so an idle lobby had no
carrier at all: guests sat on the joinAsGuest hostOnly default. The v1
protocol's sessionConfig message covered this; the v3 rewrite lost it.
Carry the mode on the host's join messages instead: the directed join
reply every participant already sends to a new peer, and the host's
reconnect re-announce. The field is optional on the wire ('cm'), so
older clients ignore it and rooms with older hosts degrade to the
previous behavior. Guests apply it only from the relay-derived host
peer ID, never from a join's own spoofable isHost flag.
close#1950
4443b761 staged the system-back path (strip/fullscreen/hide/exit), so on
Android a back with the controls visible hid them instead of closing the
player. The PlayerNavigationCoordinator now takes an exitPlayerBeforeChrome
policy; the player enables it on mobile, restoring immediate exit on Back
while TV/desktop keep the staged chrome handling.
The display-agnostic texture renderer restored by 9cdfe759 is deleted
again, this time for good: it is a second, SDR-only rendering stack
(isolated EGL context on Flutter's display plus EGL-image handoff) kept
alive solely to host sessions that cannot bring up the Wayland plane -
X11/XWayland or a failed plane bootstrap - and it was the source of the
native lifecycle and EGL state-churn fixes of the 9f2e0507 era. Linux
video now requires a Wayland compositor; a session that cannot host the
plane fails initialization with VIDEO_PLANE_UNSUPPORTED instead of
silently rendering through the second stack.
Reverts the restore commit's machinery: mpv_texture.cc/.h and
mpv_gpu_bootstrap.cc/.h deleted, the plugin's texture-registrar,
bootstrap, and waitForVideoReady paths removed, MpvPlayer's texture-mode
render-context API dropped, and the Dart-side renderMode setting, its
settings tile, translation keys, and the Player textureId member
withdrawn. The #1874 HDR diagnostic work is unaffected.
2.13.0 deleted the display-agnostic EGL/Flutter-texture renderer and made
the native Wayland plane the only path, hard-rejecting every session that
cannot host one - X11, XWayland (SteamOS Gaming Mode runs native apps
through Gamescope's XWayland), or a plane whose EGL bootstrap failed. This
restores the 2.11.0 texture path from git history as the fallback:
- Re-add mpv_texture.cc/.h and mpv_gpu_bootstrap.cc/.h (2.11.0 verbatim):
an FlTextureGL whose populate renders mpv into an offscreen FBO sampled
by Flutter via an EGL image.
- MpvPlayer gains the texture-mode render-context API (InitRenderContext,
HasRenderContext, GetEglDisplay/Context, Render(w,h,fbo)) beside the
plane's InitRenderContextForSurface/RenderToSurface. The isolated ES 2.0
context on Flutter's display and the X11 display param are exactly the
2.11.0 configuration hardware decode demonstrably worked in.
- initialize now tries the plane first and falls back to the texture path
when it cannot be brought up, returning the texture id (Dart's 2.11.0
'result is int' contract) with waitForVideoReady gating playback until
the GPU bootstrap settles. Both paths share one mpv core, so the
plane/texture decision precedes render-context creation.
- hdr-enabled/hdr-tone-mapping are intercepted in texture mode (no plane,
no HDR); the HDR toggle hides itself via isHDRSupported.
- New Linux setting 'Video rendering mode' (Automatic / Texture) forces
the fallback - the user-visible workaround for plane-only trouble and
for the hwdec interop regression, plus translations in all locales.
SDR only on the fallback, matching 2.11.0; the plane path is unchanged.
mpv 0.40's OPT_COLOR parser rejects anything but #RRGGBB/#AARRGGBB, so a
stored subtitle colour that does not parse made mpv refuse the write with
MPV_ERROR_PROPERTY_FORMAT — and the bare await in _runPlayerInitializationAttempt
turned that into the initialization error screen on every open. Subtitle
styling, volume-max, and the pre-open defaults (start/pause/sid) are now
sanitized (colours canonicalized to hex with fallback to the default) and
non-fatal, matching the existing hdr-enabled tolerance policy: a refused
preference write must never become "this session cannot play video".
The user mpv.conf editor is applied as runtime mpv_set_property writes, and
vo/gpu-context/gpu-api were not withheld, so a vo=gpu-next line re-created
mpv's output as a separate uncontrollable window and orphaned the embedded
render context. Add the VO family to the Linux-owned property set with a
key-aware skip log, a native reject for vo != libmpv on the video core (the
render API is OpenGL-only; gpu-next is windowed by construction), and a hint
in the mpv.conf editor explaining why, with translations across all locales.
Scrubbing an Emby video showed no preview thumbnails on the timeline
while Plex and Jellyfin both worked: Emby's scrubThumbnails capability
was off, so the player never attempted a load. It was off because the
4.9.5 test server answered the preview endpoints with empty payloads —
its extraction task had not run.
Emby's preview transport is a Roku-format BIF at
/Videos/{id}/index.bif?Width=320, the same wire format Plex serves, so
the existing BIF parser handles it unchanged. Enable the capability and
route Emby previews through the shared BifThumbnailService, whose load
now takes a bytes callback instead of a Plex-typed client. MediaBrowser
sources also carry videoAspectRatio from the video stream so the
tooltip sizes itself to the stream. A server without extracted frames
still answers a header-only BIF, which parses to zero frames and keeps
the tooltip suppressed.
close#1930
GoNotoCurrent lacks Hangul syllables, and the Android libmpv build has no
fontconfig/system-font fallback, so libass could not resolve any Korean
glyph and subtitles rendered as boxes. Ship a Hangul subset of
GoNotoKurrent-Regular beside the default font in the extracted subtitle
fonts directory; libass picks it up as fallback by glyph coverage.
close#1932
Switching to a profile whose only server is offline verified the PIN,
failed the bind with zero reachable servers, and rolled back to the
previous profile — whose scope owns none of the downloads. The Downloads
UI then showed nothing while the files and pinned metadata sat intact on
disk. Startup offline mode only covered the cold-start bind, so such a
profile could never be entered while its server was away.
The binder now classifies a settled bind failure as connectivity-only
when the profile expected servers, reached none, and none were
auth-rejected (snapshotting auth markers before the visibility sweep,
which clears them via removeServer). On such a failure,
switchProfileFromUi keeps the profile active when it owns downloads
instead of rolling back; OfflineModeProvider drives the offline UI from
the empty visible-server set. Auth failures, PIN cancels, and
downloads-free profiles keep the existing rollback and error snackbar.
close#1927
Collapsing Libraries in the sidebar only lasted until the app closed. The
next launch always came back expanded, which buries the rest of the nav
menu for anyone running a lot of libraries.
The expansion flag was plain widget state on SideNavigationRailState. It
survived tab navigation only because MainScreen pins the rail with a
GlobalKey, and died on relaunch, profile switch or a layout change. It now
lives in the librariesSectionExpanded preference, so the rail reads and
writes the persisted value and follows a settings reset or import too.
close#1896
ExoPlayer reports RFC 6381 codec IDs (mp4a.40.2, ec-3, dtsc) where mpv
reports ffmpeg names, so the audio track picker and performance overlay
showed raw identifiers instead of friendly names on the ExoPlayer path.
Normalize RFC 6381 audio IDs in CodecUtils.formatAudioCodec, route the
performance overlay's audio codec through it, and cover the video IDs
(hvc1/hev1, av01, vp09) that fell through the overlay's matcher.
close#1899
On Fire OS, standby never freezes a backgrounded app, so the paused
heartbeats that kept a suspended session "alive and resumable" pinned it
in the server dashboard indefinitely. The live session bought nothing:
the restore path already performs a fresh playback decision.
When the TV background grace expires, stop the heartbeat timer (its
paused tick also keeps a Plex transcode session alive) and report the
session stopped before releasing the native pipeline, since stop()
resets the player state the report reads. Standby entry can drop Wi-Fi
into power-save and mutations never fail over, so a failed terminal
report is redelivered on a bounded schedule, gated on actual backend
delivery and on the suspend still standing so a restored session is
never reported stopped.
close#1911
On TV, a movie carrying a full set of attributed scores pushed the resolution and audio labels past the right edge of the detail metadata line, which silently clipped them.
The line now sheds its least useful parts when it overflows -- surplus rating badges first, then the whole ratings slot, then quality labels -- instead of hard-clipping the tail. It also leads with the year instead of the redundant "Movie"/"TV Show" label, matching the desktop hero chip order, and the Discover spotlight line gets the same fitting. Branded badge icons are pinned to their SVG viewBox aspect so the fit is measured exactly.
close#1893
After activating Skip Intro with the remote, the next Select only raised
the player controls and pausing took a second press. The skip button
autofocuses on TV, and every path that hides it dropped focus out of the
controls subtree - the screen node reclaimed the remote and its self-heal
consumed the next key to raise the chrome instead of toggling playback.
Hand the remote back to the player surface whenever the skip button
disappears while focused: after a skip seek, when the playhead leaves the
marker window, and when the auto-dismiss timer hides the button. The
credits-at-end path is exempt because the play-next flow requests its own
focus.
close#1890
Physical-keyboard Escape at root Home now exits window fullscreen on
Windows and Linux the way it already did on macOS, and never arms the
press-back-again quit — so Escape aimed at fullscreen can't close the
app. Remotes, gamepad B, and system back keep the double-press exit.
close#1748
An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.
Three changes:
- A failed in-place reload now records its classified failure reason, and
an advance that ran with the completion latch set re-presents the Play
Next prompt when that reason is serverUnavailable. With auto-play
enabled the countdown re-fires the advance up to two times before the
prompt goes manual-only; Watch Together sessions and mid-episode Next
presses (whose rolled-back stream is still valid) keep the existing
handling. playNextRetryPresentation owns the decision and is unit-tested.
- Committing adjacency now best-effort prefetches the next episode's full
metadata row through fetchItem, which writes the exact row playback
initialization falls back to on both backends (Plex: same cache key and
full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
playback bundle reads). A warm row turns a blip at the transition into a
normal start.
- JellyfinClient.fetchItem's documented "pure transport error -> cached
row" fallback was dead code: the HTTP layer wraps transport errors into
MediaServerHttpException, which the first catch rethrew unconditionally.
Status-less, non-cancelled failures now take the fallback; answered
requests (401/403/5xx) and cancellations surface unchanged.
Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.
close#1867
A quality switch or resumed open at a nonzero position sent offset=T on the
HLS start URL, waited for the readiness probe to touch the segment at T, and
then had mpv seek to T anyway. mpv's stream probing always reads segment zero
first, and a Plex segment request is a seek, so the transcoder was dragged
through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured
against PMS 1.43, a segment response that races such a restart can be left
open with headers sent and no data or error, and ffmpeg's HLS segment reads
have no default timeout, so playback buffered forever after the first frame
(issue #1859). Starting the session plain and letting the player's start=T
request the resume segment performs the one unavoidable transcoder seek.
The offset request parameter, the readiness probe, and the probe-only
getStatus HTTP helper are removed; live TV time-shift keeps its own offset
path. Transcode opens now also set an explicit network-timeout with
demuxer-level reconnect options: mpv's stream-layer reconnect settings never
reach ffmpeg's HLS segment fetches, so a silently hung segment response now
times out after 20s and is re-requested on a fresh connection instead of
buffering indefinitely. Verified against a live PMS (resume plays from the
requested position) and a stall harness (hung segment re-requested at 20s
with no content skip).
2.13.0's ShelfRefreshWorker boots a second headless FlutterEngine in
the app process to refresh the launcher row every six hours. Its
foreground guard is checked only once at worker start, so launching the
app during a run leaves two engines sharing a low-RAM TV for up to 90
seconds, and a failed run retries with backoff. Suspected of
destabilizing the compositor on the 32-bit TCL panel in #1862. The tvOS
Top Shelf live fetch is unaffected and stays.
The foreground sync pipeline keeps the row fresh while the app runs, as
before 2.13.0. Updated devices still carry the persisted periodic job,
which would wake the process once more only to fail instantiating the
deleted class; the package-replaced receiver now cancels it.
Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.
Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.
close#1860
Non-Original presets advertised hevc inside the mpegts HLS target; a Plex
Pass server with HEVC encoding enabled obliges, and its HEVC encode -> TS
segmenter path emits parameter sets mpv rejects ("PPS changed between
slices"). The VOD target now requests fragmented MP4 (verified against
PMS 1.22-1.43), retrying once with an H.264-only TS profile when a
server's decision does not echo the mp4 container back, and falling back
to direct play when neither is honoured. Live TV keeps its own TS target:
live sessions copy broadcast hevc/mpeg2video streams, a path the encoder
bug does not touch.
Presets also now send the videoResolution/videoQuality caps their labels
promise; previously only the bitrate limitation went out, so a "1080p
8 Mbps" preset delivered 2160p at a starved 8 Mbps.
close#1859
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
Nonzero mpv video-zoom flips vo_avfoundation into a per-frame Core
Image re-render that destroys HDR/DV passthrough - DV frames render
near-black on tvOS (verified on Apple TV 4K, DV P7->8.1 content:
panel luma mean 0.0 zoomed vs 87-103 unzoomed at locked exposure).
Zoom now scales the AVSampleBufferDisplayLayer itself (a
sublayerTransform on the container is ignored by the video plane)
via the existing Player.setVideoZoom seam, and VideoFilterManager
pins the mpv property to 0 on backends with native zoom. The layer
tree at 100% stays identical to before: clipping engages only while
zoomed, and updateFrame sizes the layer via bounds/position, which
frame= decomposes to anyway.
macOS keeps the property path (gpu-next zooms losslessly in-shader);
Android is untouched.
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.
When a gapless advance was announced, the new track's tracker sent its
initial report from live player state, which still carried the finished
track's position and duration - telling Plex the new track was already
at ~100%. PMS recorded a play (and a Last.fm scrobble) at track start on
top of the one from the real playthrough, and the tracker latched the
new track watched locally the moment it began.
The music bind now pins the initial report to the track's own start
(position zero, metadata duration); timer ticks keep reading live state.
close#1849
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.
Every authoritative message a guest acts on is gated on the relay-stamped
`senderId` matching `_session.hostPeerId` — room state, `hostExitedPlayer`
— except `pong`, which `_handleMessage` fed to `ClockSync` on nothing more
than "I am a guest and this pingId is one I am waiting for".
That matters because the guest's clock, not just the state it receives, is
part of the trust boundary. A guest estimates the host's clock offset from
the round trip: it sends `ping` at its own local time, the host answers
`pong` stamped with the host clock, and the guest takes the midpoint as the
one-way delay. Every anchor the host publishes — `anchorHostTimeMs` on a
state, a scheduled synchronized start — is translated into local time
through that offset, so a wrong offset silently shifts the target position
`GuestPlaybackReconciler` computes from otherwise authentic state. Past the
2000 ms hard-seek threshold the guest seeks, then keeps mistranslating the
corrections that follow, while the host and every other guest stay fine.
Any peer in the room could therefore reply to another guest's ping. This is
a weak primitive rather than playback takeover: the forged pong has to name
a `pingId` that is currently outstanding and land inside its RTT window,
and `ClockSync` discards samples over a second. But the relay already
stamps the sender on every inbound message, so the check costs one
conjunct.
Verified: full suite green (5788 tests, 5 skipped), 186 of them under
test/watch_together, plus dart format and analyzer parity.
`maxVideoBitrate` budgets the whole stream, so a capped transcode forced
even profile-compatible audio down to low-rate AAC (measured on Plex
1.43: EAC3 5.1 640k became 360k AAC). The client profile already carries
the video cap through `add-limitation(video.bitrate)`, so drop the
redundant `maxVideoBitrate` param and send `directStreamAudio=1`: the
video stays capped per preset while audio in the codecs the profile
declares (aac/ac3/eac3/mp3) is copied through untouched. Audio the HLS
target cannot carry still transcodes as before.
The unused-code gate flags debugSetCrashReporterReady (referenced nowhere)
and the formFactorScaleForTesting/rootShellForTesting wrappers (referenced
only from tests, which the lib-scoped check cannot see). Delete the dead
setter and let the car-scale test build the real rootShell and
FormFactorScale directly.
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