bcd6fe9906c0ab94f5606ce87779457521d5789b
1274
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bcd6fe9906 |
feat(linux): HDR video on a native Wayland plane
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. |
||
|
|
716fe9b51b |
feat(watchlist): add watchlist toggle to library context menus
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 |
||
|
|
9d13584c00 |
feat(settings): add appearance toggle to hide the Explore tab
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 |
||
|
|
8e5279a487 |
feat(subtitles): optionally anchor text subtitles to the screen bottom
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 |
||
|
|
e0a364e26a | chore(tvos): remove the Atmos output diagnostics | ||
|
|
63f2bedf2c |
fix(livetv): navigate guide rows in displayed source-group order
Vertical D-pad/arrow navigation stepped through the flat channel list, which is number-sorted across servers. With overlapping channel numbers from multiple DVRs, focus interleaved source groups and could dead-end before the last displayed row. Derive the up/down order from the same grouped rows the guide renders. close #1843 |
||
|
|
ff461d9f71 |
fix(livetv): explain a guide emptied by the favorites filter
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. |
||
|
|
0b4fd9e8f3 |
fix(tv): host automatic multiline input in the Android IME
Android TV's docked keyboard handles multiline editors natively, so `automatic` no longer diverts them to the Flutter overlay there. Only Apple TV keeps the overlay for multiline input — its modal fullscreen system keyboard cannot edit multiline text. Surfaces that want the overlay for editing ergonomics (mpv config, connection editor, dialog text areas) already pin flutterOverlay explicitly. |
||
|
|
ce9556db22 |
fix(tv): restore the native Android IME for single-line text input
Android TV returns to the platform keyboard for single-line fields; the Flutter overlay stays for multiline and explicit call sites. The bugs that forced the overlay (#1051, #1079) were an engine show/bind ordering race, now repaired at the app level: - MainActivity retries a soft-input show the engine dropped while the FlutterView was not yet served (flutter/flutter#177360), rebinds the IME key session once at first show, and consumes leaked D-pad keys while the keyboard is visible (bounded restartInput budget) so focus cannot wander behind a stuck keyboard. - The platform text-input hint is activation-based, so gamepad pause and the pre-IME D-pad intercept track a live session instead of mere field focus. - While a session is live with the keyboard away, Back closes it and is consumed once, Select re-raises the keyboard, and arrows keep caret-aware edge-escape navigation instead of dead-ending. |
||
|
|
f4ce60611b |
fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since |
||
|
|
8740a19f36 |
feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision request never sent offset=, so any non-zero open - resuming a transcoded title, or switching from Direct Play to a transcoded quality mid-playback - opened a session whose produced window begins at the start of the file and seeked it. mpv immediately requests a segment the transcoder has not produced, PMS answers 404 for it and every subsequent segment, and playback buffers forever. Send offset=<seconds> (6dp) with the decision and start request - the view offset on initial open, the resolved resume position on every in-place reload - so the session begins producing at the position the player consumes first. The playlist timeline is unchanged: an offset session's media playlist still covers the full title from segment zero, so the player keeps opening with start: at the resume position and in-stream seeks work as before. Before a native player opens an offset playlist, waitForTranscodeReady walks the master playlist, the media playlist, and the segment containing the offset, because PMS can publish a manifest before that segment is fetchable and mpv treats the 404 as an HLS error. The probe is best-effort: it never fails an open, hands off immediately on HTTP 500 (on the response and exception paths alike) so the server-limit dialog stays prompt, stops on cancellation, skips itself when the playlist durations never reach the offset, and stays out of the endpoint-failover cascade. In-place reloads resolve the replacement source only after the old stop report has gone out, so Plex cannot use that stop to terminate the replacement transcode. close #1840 |
||
|
|
e6be5f9fef |
fix(player): surface a persistent HTTP 503 at open instead of retrying forever
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 |
||
|
|
f0debe2c32 |
feat(ui): show a system-format clock on TV home and in the player
The clock renders through the existing formatClockTime helper driven by MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour setting instead of introducing an app preference. It re-arms a one-shot timer onto each wall-clock minute boundary rather than polling, and resyncs on resume because a suspended process runs no timers. The player header is shared by the mobile and desktop/TV controls, so one insertion point covers every form factor: the player is fullscreen everywhere, so it never has an OS clock to defer to. Home is the exception and only gets one on TV, where a leanback app hides the system clock; a phone status bar and a desktop menu bar already show the time. |
||
|
|
5f397a99d9 |
fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing Refresh left the first device showing the old "minutes left". Restarting the app showed the right value. Two independent defects produce that, and either alone reproduces the report. The first is the watch-state overlay. Every local watch event lands in WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites viewOffsetMs unconditionally; isNewerThan only ever orders one patch against another, never against the server row underneath. Nothing expires a patch and nothing clears the map except a profile switch, so the Mac's own paused position kept winning over every subsequent fetch until the process died. A patch exists to bridge the gap between a local action and the next server read of that item, so it should stop applying once that read happens. The store now records the watermark at which a successful authoritative response returned each key, and suppresses an acknowledged session patch at or below it. Only a watermark is stored, never the observed state: WatchStateSnapshot cannot hold a container's leaf counts, and keeping max() per key makes the order two concurrent responses complete irrelevant. Suppression is a read-time predicate, so nothing mutates during build. The barrier covers the parentChain too. patchForItem picks the newest of the item's own entry and its ancestors', so retiring only the item's entry would let an older season mark win and render watched/0 -- worse than either the stale value or the fresh one. An authoritative read of a child already reflects any container mark that preceded it, so the child's observation judges its ancestors as well; a newer container action still wins. Provenance decides what may be suppressed at all. WatchStateEvent now carries serverAcknowledged, defaulting to false so an unclassified emit site degrades to today's behaviour rather than silently becoming retireable. An offline write is owed to the server and a read must never retire it, so it stays until a WatchPatchPromotionNotifier promotion says the queue replayed it. That channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts to watched/unwatched by purging queued progress, so replaying one there would delete a newer rewatch. Promotion matches an exact WatchPatchId -- session minted for live crossings, derived from the persisted (profile, row, revision) for queued ones so it still joins after a restart. Report acceptance is not delivery: PlaybackReportSession resolves true for a same-state startup heartbeat it drops, so acknowledgement now keys on onDelivered. A MediaBrowser Started saves play count and last-played date but not the position, so it cannot acknowledge an offset. No report-derived watched crossing is acknowledged on any backend -- Jellyfin hard-codes its threshold and Plex never loads the server pref that would tell it the real one -- so only an awaited explicit markWatched settles one. The second defect is that a failed Refresh reported success. Plex _fetchHubs and the Jellyfin hub legs both degrade a failure to an empty list, and the library prefetch discarded its failures, so a server whose every hub request failed was recorded as succeeded; DiscoverProvider then kept the previous rows, set loaded and surfaced nothing. Worse, the background Continue Watching refresh wiped the row outright on zero success. Hub legs now report what they degraded through a HubFetchDiagnostics sink, which keeps partial rows alongside the failure and leaves every existing caller untouched. Failures ride through the aggregation results, a leg that could not run because discovery failed contributes that failure rather than a successful no-op, and loaded-server ids became succeeded - failed - cancelled so one bad leg no longer caches a server as covered and blocks its retry. The toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad snackbar on failure while the retained rows stay on screen. Rollback after a mid-pass exception is version-guarded, refilters against the current hidden libraries and no longer publishes a system shelf the pass never committed. Observations are staged with the pass and flushed only once the same disposal, generation and exception checks that authorise committing those rows have passed, so a discarded or rolled-back response can never suppress a patch. Also fixes a live data-loss race the promotion work would have built on: upsertProgressAction stamped a millisecond timestamp and updated the row in place, so a rewatch queued during an in-flight replay was deleted by id. Revisions are now strictly monotonic per row, replay deletes and retry updates compare against them, and the upsert resets the retry fields because a new revision is a new logical action. close #1829 |
||
|
|
24a041977b |
fix(sheets): size sheets to their content instead of 75% of the window
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. |
||
|
|
e3703892b3 |
fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and dropped focus onto Play/Pause, even with Video Player Navigation off. Two independent paths did it. InputModeTracker promoted on any key satisfying isNavigationKey, a set that unioned activation, dismissal and the menu key with the arrows and consulted no setting at all; separately the surface's Select handler always asked the chrome for focus. Escape had the same effect, which on desktop reads as the mouse cursor vanishing mid-playback. Both now ask one predicate. eventRequestsFocusNavigation decides whether the app switches to keyboard mode and whether a key may hand focus to the chrome, so the two cannot disagree and focus can never land on a control while focus chrome is still suppressed. Activation and dismissal act on what already has focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an arrow that will really traverse answer yes. The one input the predicate cannot read off the event, whether the focused feature owns arrow keys, rides on the node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet, prompt and OSD button stays an ordinary traversal target with nothing to re-enable. playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace five hand-copied pref-or-isTV expressions and a screen-level cache that disagreed with the live getter after a toggle. Services whose input is synthesized past HardwareKeyboard announce themselves through InputModeTracker.reportNonPointerInput rather than two static callbacks and three copies of a highlight-strategy write. That registration is now identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker after the incoming one initialised and cleared both callbacks, so gamepad and companion remote input had stopped switching to keyboard mode entirely. Falling out of the same rule: a companion heartbeat no longer flips an idle desktop host into keyboard mode, analog-stick drift promotes only past the deadzone that actually navigates, Enter keeps toggling playback once the chrome is up, Tab both reaches and traverses the OSD, and the player surface claims the remote from mount rather than only when the chrome starts hidden, so the first key on a desktop route is a playback shortcut instead of the screen node's chrome-raising self-heal. isNavigationKey becomes isReservedControlKey, since its real meaning is a shell key rather than a text character and the old name is what invited the conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it. |
||
|
|
4816e3928f |
fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819 |
||
|
|
660e375248 |
feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s
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 |
||
|
|
f63a4b039a |
fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was left staring at a launcher error with no way to link the account. The QR code and the linking code are now rendered in the app on a car, so the pairing happens on a phone while the vehicle shows what to scan. |
||
|
|
961e9c0326 |
feat(automotive): scale the car interface and make it adjustable
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. |
||
|
|
4607d165fd |
fix(automotive): keep video from starting while a car is driving
DD-3 gives video no exemption: a restricted vehicle must not play it at all. The gate is read at the single point where media actually opens, so every path that can start a picture - an explicit play, a gapless arm, a track or channel switch, a frame-rate-match resume, a reload, and the queue navigation commands of the OS media session - is covered by one check rather than by a guard at each call site. A seek can also start playback with no play call, because mpv resumes when it seeks off the end of a file, so a restricted seek is followed by a pause. Watch Together needed the pause to be local. A vehicle stopping one peer is not a room-wide intent: a guest's forced pause is swallowed by the attachment's ledger rather than published, while a host's still pauses the room, because a host that kept broadcasting a frozen anchor would stall or rewind every guest it was meant to protect. The layer that owns a pause owns the resume for it, and one acknowledgement is recorded per event, so a surplus cannot eat the user's next real pause. |
||
|
|
f93952ba6f |
fix(android): stop tunneling 24p video on the Fire TV Stick 4K
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. |
||
|
|
9d51a040c3 |
fix(player): keep the remote on the player surface after a window switch (#1797)
Returning to the desktop window with the chrome still up left arrow keys navigating the OSD instead of seeking: the first press seeked and silently moved focus onto Play/Pause, and every press after that walked the buttons. A window blur drops Flutter's primary focus to the root scope, so the player screen's reclaim parks it on its own node. The only handoff back down to the controls was the chrome visible->hidden transition, so with the OSD up nothing reclaimed it -- hence the reported workarounds of letting the controls hide, or moving the pointer off the player and back. Pointer exit normally hides the chrome and masks this, which is why it only shows when the pointer stays over the player while another window takes focus. Hand the surface back on window re-activation, next to the existing hide-path claim, and rename the helper since it is no longer hidden-chrome specific. The claim runs synchronously because a platform callback is not guaranteed to be followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the two no longer compete. Also gate the screen's self-heal so a directional key no longer pulls focus into the OSD when "Video Player Navigation" is off -- Tab and select keep their path in, which the ungated return value would otherwise consume with nowhere to go. |
||
|
|
26dbce0277 |
fix(profiles): name the Plex user and account in one translated chip
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. |
||
|
|
edaff1fbfc |
Merge pull request #1789 from JackDanger/fix/plex-home-account-chip
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. |
||
|
|
eaa1736c4e |
feat(mdblist): sync watched history, scrobbles and ratings with MDBList
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. |
||
|
|
b97a22c213 |
fix(player): report every AudioTrack release so a failed one can recover
An episode that opens but never plays, forever, with no error and no way out except force-quitting the app. The reporter's log has the whole shape: media opens at 85206ms, the first video frame renders, `AudioTrack init failed 0 Config(48000, 252, 5, 40000)` is logged exactly once, and the position never moves again. Force-quitting fixes it for a while, which is the tell — the state that breaks recovery is process-wide and static. `DefaultAudioSink` releases its `AudioOutput` on every flush — every seek, every renderer disable, every reconfigure — and increments a private static `pendingReleaseCount` as it does. It decrements only from `Listener::onReleased`. `RawPositionAudioOutput.release` never called `delegate.release()` for a cacheable output, and it forwarded `addListener` straight through, so the sink's listener sat on the real output while the wrapper was parked and the increment was never balanced. media3's own delivery is lossy too: it posts `onReleased` to the playback looper, which `ExoPlayer.release()` has already quit by the time the 20ms-delayed release runs, so even a real release drops its decrement at teardown. A counter that never returns to zero silently disables media3's escalation of both init and write failures: `PendingExceptionHolder` arms its throw deadline only when nothing is pending, and short-circuits every retry while something is. So the `InitializationException` is never thrown, the audio renderer never becomes ready, and the player is pinned in `STATE_BUFFERING`. No `PlaybackException` means `retryAfterAudioTrackError` never runs, which is why the same failure recovered onto decoded PCM earlier in the same log and hung outright later. The wrapper now owns the listener set and answers every flush exactly once: at once when it parks the track, because a parked track is never going to release; on the delegate's confirmation for a real release; and from the provider at teardown, where nothing else ever will. Bitstream outputs are not parked at all — a direct route is often single-instance and a parked one would block its own successor. An eviction therefore builds its replacement while the old AudioTrack is still going away, as upstream does. Holding the count open across the park to buy media3 patience for that window was tried and is worse: it pins the counter above zero for the whole live track after the first seek, which is the hang above. Refusing to allocate until the release confirms is worse too — the refusal reaches media3 as an init failure with no pending release to excuse it, so the 200ms deadline starts immediately and a slow TV teardown turns an ordinary config change into a playback error. If the overlapping allocation does fail, media3 escalates into the audio recovery ladder and the watchdog below backs it up. Because no amount of accounting hygiene guarantees media3 will raise the next failure, add the watchdog that was missing. Nothing covered "buffering, holding data, not moving": the frame watchdog wants `STATE_READY` and zero frames, the decoder-hang check is cancelled by the first frame, `ResumeStallPolicy` treats a frozen clock as explicitly not its business, `EndOfStreamPolicy` wants the position past the duration, and media3's stuck-buffering detector wants an empty buffer. `BufferingStallPolicy` covers exactly that hole and escalates through the existing audio ladder — now shared with the exception path — then to the mpv backend rather than leaving a spinner up. The watchdog only indicts a player that could have started. `DefaultLoadControl` is configured to hold playback until 5s is buffered after a rebuffer, so the stall threshold is derived from that same constant rather than guessing at one, and a buffer below it reads as starved — the loader's business, not the renderer's. Starvation also restarts the stall clock, so a minute of network rebuffering cannot bank the timeout and have the first poll after recovery report a stall that never happened. Also raise the passthrough buffer to a second. media3 defaults it to 250ms, which the AC3 factor doubles to the 40000 bytes that failed here, and 1.10.1's only retry is to keep halving; upstream adopted the same 1s floor in #3207. Recovery now resumes from the furthest position reached rather than `lastPosition`, which the poller writes down as freely as up — a dead clock reporting 0 is how an audio recovery restarted a resumed episode from the top. On the Dart side the episode loading flags are cleared on every exit of the in-place reload, not just the success and rollback paths; a flag stranded by a superseded reload made the Next button a no-op for the rest of the session. close #1790 |
||
|
|
8879941d29 |
fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on Windows and Linux whenever HTPC-style player navigation was off, so it dropped the window out of fullscreen regardless of who put it there. For anyone running with "start in fullscreen" (or who had toggled fullscreen from the browse UI), backing out of a movie left the app windowed, with "exit fullscreen on player close" switched off. Track fullscreen ownership instead: FullscreenStateManager now exposes a scope that the player opens in initState and closes in dispose, and setFullscreen — the single funnel every desktop platform reports through (window_manager on Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) — records whether the fullscreen currently active was entered inside that scope. Escape only exits fullscreen the player itself entered; otherwise it is plain Back. The scope is depth-counted so the next-episode swap, where the incoming screen's initState runs before the outgoing screen's dispose, carries ownership across rather than resetting it. Nothing changes for a user who fullscreens from inside the player: Escape still exits fullscreen first, then acts as Back. The fullscreen toggle button and its shortcut are untouched, as is exitFullscreenOnPlayerClose. Fixes #1624. |
||
|
|
05fd622968 |
feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
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.
|
||
|
|
f36e20bcad |
fix(profiles): keep the profile picker highlighted while its list sorts
The picker resolved StorageService asynchronously and rebuilt its profiles stream once it landed. Storage is what supplies profile recency, so the second view arrived re-sorted a microtask after first paint. The sliver children carried no keys, so that reorder handed each tile's Element the next profile's focus node; detaching the old node dropped primary focus onto the enclosing scope and took the D-pad highlight with it. The launch picker has no back route on tvOS, so a user who can no longer see or move the selection has nothing useful left to press. Read StorageService from the provider graph, where it is already resolved before any route exists, so the stream is built once and the first painted frame is already recency-sorted. Key the tiles and add findChildIndexCallback so a later re-sort from a refreshed profile source moves a tile instead of destroying it: without the lookup the sliver re-inflates the tile, which keeps primary focus but resets FocusableWrapper's chrome to unfocused. close #1792 |
||
|
|
dcc06768e0 |
fix(profiles): label a Plex Home parent connection as an account
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 |
||
|
|
4872adcde3 |
feat(player): keep the session's explicit track choices across episodes
Episode advance carried live player state, so the viewer's choice only survived while every episode could serve it: one episode without the picked audio or subtitle fell back, and the fallback became the carry for the rest of the session. The screen now keeps the last explicit audio, subtitle, and secondary-subtitle choices for its lifetime; automatic outcomes never overwrite them, so the choice retries on every following episode and reattaches as soon as a catalog can serve it again. Audio catches up with the subtitle carry from #1785. The old matcher required raw language equality (a 'sv' pick never found a 'swe' row) and otherwise took the first same-language track, flipping a commentary or alternate-mix pick back to the main mix on every episode. Audio now uses the same evidence bands as subtitles: bridged language parity is authoritative, a unique title match vouches for untagged tracks, codec and channel-count parity only break ties, and an ambiguous catalog declines to the server's own choice instead of guessing. The synthesized source descriptor also prefers the row's own title over the display title that collapses to the bare language. Episode advance previously sent no audio hint to negotiation at all, so a transcode baked in the server's default audio no matter what was playing. Both backends now resolve the carried semantics against the new episode's streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the transcode decision, an explicit per-part stream id always wins, and a failed match falls back to the server's pick. close #1785 |
||
|
|
fdd4c661fe |
fix(player): carry a picked subtitle language across episodes with sparse tags
The cross-item subtitle intent required declared languages on both sides, and a null on either side counted as a contradiction. Any untagged track - common when a title like "Swedish" is the only signal - declined on every episode advance, fell to the server's per-item priority, and turned the viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard gates). A unique real title match now vouches for a row when language evidence is missing on either side. Declared languages that disagree still decline no matter what the title says, forced-class parity is untouched, codec and external parity only break ties within the title-matched set, and a residual tie declines rather than guesses, so the wrong-track class of #1716 stays closed. A decline is also no longer laundered into a viewer decision: the resolver keeps the unserved preference on the selection, the open flow hands it to the track manager instead of a navigation-priority off (late native tracks may carry the container tags the server rows lack), the next episode boundary re-carries it instead of hardening it into an explicit off, and progress reports withhold the -1 subtitle index that would otherwise come back as the item's server-side default forever. A pick the screen could not map to a source row (no subtitle catalog, or an identity-matcher miss) previously never reached the committed session selection at all, so the next episode carried the stale off while the picked track was visibly on screen. Such picks now commit the raw native track without source ids and demote to a semantic intent at the boundary. close #1785 |
||
|
|
439ae1d733 |
perf(detail): paint a show before its on-deck episode is looked up
Jellyfin has no equivalent of Plex's bundled `?includeOnDeck=1`, so a show detail open chained `/Shows/NextUp` behind the item fetch and the screen sat on a spinner for both round trips. The second one is not needed to paint: everything except the play button's episode label comes from the item. `fetchItemWithOnDeck` now takes an `onItemReady` callback and invokes it as soon as the item is known, when that is strictly before on-deck settles. Plex returns both together and never invokes it. Phone and desktop only. TV keeps its own reveal gate — `_isTvDetailReadyToReveal` holds the foreground at opacity 0 until extras, related hubs, seasons and the first episode page have all loaded, and those still run after the on-deck lookup settles, so TV sees no change. Both halves are pinned by tests. Measured on a remote Jellyfin server, 15 interleaved show-detail opens per version: time to content 1264ms -> 1042ms (-18%), with the rest of the load unchanged. Seasons and extras deliberately still start after the whole lookup settles. Starting them at the early paint measured worse (time to settled +21%) because they contend with the on-deck request instead of overlapping it — the same reason `/Shows/NextUp` is not fired in parallel with the item fetch. That trade-off is also why TV was left alone rather than being unblocked by moving those loads earlier. Two ordering hazards the early paint introduces, both covered by `media_detail_screen_test.dart`: - The early call must not write on-deck. `_loadFullMetadata` runs again after playback, and clearing there would blank the play button for the length of the round trip. `onDeckSettled` marks the authoritative write, so a reload that finds the series finished still clears it. - A settled empty on-deck must not drop the episode-derived fallback that `_ensureFallbackOnDeckEpisode` supplies. close #1784 |
||
|
|
74d3af3ae1 |
perf(home): load the home screen once instead of twice per cold start
The Discover tab fanned out its whole request set twice on every cold start and replayed slow rows on a shrinking timeout ladder, so a healthy remote server produced anywhere from 4s to 15s of loading. Measured against a remote Jellyfin server with four libraries, 24 interleaved cold-start samples per side: requests 19 -> 9 payload 219 KB -> 94 KB settled 5231ms -> 2502ms median, 13222ms -> 5927ms p95 Four independent causes: - Retry policy. `Client.send` resolves on response headers, so the connect budget covers the server's think time and a slow-but-alive query raises `connectionTimeout`. Replaying it made the server re-run the query with a shorter budget than the one it just missed; the `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after 23s. Hub surfaces now get one whole-request deadline, retry only immediate connection errors, and the deadline bounds the whole call including the request still in flight. - Request shape. `/Items/Latest` groups a TV library by series, so its rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost a DB count each, per row. Hub rows now ask for `Overview` only; watch state survives because Jellyfin derives `UserData.Played` from `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp` sends `NextUpDateCutoff` to bound the server's series-key scan, and `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData` and `PremiereDate` leave the browse set: neither is an `ItemFields` member, so the server dropped them anyway. - Fan-out. Per-library hubs ran in batches of three separated by a barrier, so one slow library stalled every library behind it. A sliding window keeps the same peak concurrency without head-of-line blocking. Concurrent `fetchLibraries` calls now share one `/Views` instead of racing two identical round trips, Plex's global and music hub legs start together, and Jellyfin gets Plex's pool tuning. - Duplicate pass. `DiscoverScreen.initState` starts a load and the online-entry hook asked for a full refresh on top of it, which `CoalescedLoadCoordinator` correctly queued as a trailing pass. The hook now calls `primeRefresh`, which rides along with a load already in flight; profile switches still go through `fullRefresh`. Refs #1784 |
||
|
|
860ce1e11a |
feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.
Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.
The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.
close #1667
|
||
|
|
2b4875d389 |
fix(player): keep hidden and cycled subtitles off in the next episode
Episode navigation carries the subtitle choice this screen has committed, so a way of turning subtitles off that the screen never sees is undone by the next episode. ExoPlayer has no renderer-level visibility switch, so the player's hide toggle is emulated by deselecting the track. That emulation lasted until the next selection: the automatic pass after an episode change put subtitles straight back on screen while the toggle still read "hidden", and un-hiding then restored a track id belonging to the episode that had already ended. Hiding is now sticky across media opens the way mpv's global sub-visibility is, selections made while hidden become what un-hiding restores, and the toggle no longer refuses to restore because the hidden track reads as Off. Cycling subtitles over the native track list — downloads, and items whose server exposes no subtitle rows — went straight to the track manager, which owns the player selection and the server write-back but not the committed choice. The screen records the cycled track now. |
||
|
|
a9f0532f5f |
fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is 36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16 disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on API 35+ the window is edge-to-edge from the first frame and MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation. MainScreen's phone layout hides that. It supplies a bottomNavigationBar and never sets extendBody, so Flutter's Scaffold strips padding.bottom from the body MediaQuery and every tab is already safe. Routes pushed on the profile navigator are full-screen siblings of MainScreen with no bottom bar, so they receive the untouched inset and nothing consumes it - the last settings card and the final log lines render under the back, home, and recents buttons. Three shared hosts own most of those routes, so the inset is consumed there: FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing SliverSystemBottomInset, and the four screens that build their own Scaffold around a CustomScrollView append it directly. The new widget codifies the convention this repository had already written down but open-coded - insets baked into the scroll content rather than a SafeArea around the scroll view - so content still paints under the bar while the scroll extent grows enough to bring the last row above it. It reads padding from its own context and collapses to zero height wherever the inset is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside MainScreen's tab bodies. No platform branching, and it stacks additively with the music detail screens' existing mini-player spacers, which is correct because the mini-player itself floats above the navigation bar on a pushed route. Scroll views that are not sliver lists take the inset in their own padding: the companion remote's ListView, the auth screen's scroll container, and the two SliverFillRemaining sign-in forms, whose children size themselves from the extent remaining before them and so cannot be helped by a trailing sliver. The logs empty state is left alone for the same reason inverted - it already fills the viewport, and a trailing inset would only add scroll slack. Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation: Settings, Logs, and Video Playback all end clear of the bar. close #1766 |
||
|
|
fb0613e3db |
feat(player): toggle playback on a two-finger tap without raising the chrome
A touch viewer had to raise the chrome to pause, which dims the picture and covers the subtitle line they were trying to finish reading. A two-finger tap now toggles playback with the chrome left down, so the frame that pauses is the frame that was on screen. It fires the moment the chord resolves, in every player state. The two-finger double tap no longer resets the video zoom. Keeping it would mean holding this toggle back for the double-tap window before acting, and pausing late is pausing on the wrong frame. Zoom reset stays in the video settings sheet, its presets and the keyboard shortcut, and pinching back to 100% now snaps exactly within three percent so touch has a one-gesture path too. Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert under screen lock, in PiP and while the content strip is open; the zoom reset previously fired straight through a locked screen. close #1505 |
||
|
|
d83d0790ba |
fix(exoplayer): match side-loaded subtitles after media3 rewrites track ids
Plex sidecar subtitles are attached as MediaItem.SubtitleConfiguration and tagged `external_<n>`, then recovered from the Format id the track selector reports. Since media3 1.3.0, DefaultMediaSourceFactory always merges side-loaded subtitles with the primary source and MergingMediaPeriod rewrites every child format id to "<periodIndex>:<originalId>", so the tag arrives as "1:external_0" - measured on device - or "0:1:external_0" behind the container-sidecar merge. The prefix test therefore never matched and every sidecar reached Dart as an embedded track with no URI. A Plex sidecar's only identity is its stream key, which the app carries in that URI, so both matchers failed on it: a server-selected sidecar could never resolve and left subtitle selection pending, and a manually chosen one could not be mapped back to a stream id to write to the server. The already-attached branch of addSubtitleTrack compared the raw id too, so re-selecting a loaded sidecar silently did nothing. Route every write and readback of the tag through ExternalSubtitleIds, which matches the final id segment, and cover it with an instrumentation test that side-loads a subtitle through the real media3 media-source factory. Also stop claiming a saved track selection when no server stream was identified - there is no local store, so that path silently dropped the user's choice. close #1713 |
||
|
|
bbed260169 |
fix(player): start the TV player with its chrome down
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 |
||
|
|
1d9ffb7427 |
fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue Watching and the library rail still surfaced their contents in the Search tab. Thread the profile's hidden keys from SearchScreen through to the aggregation, and drop matching items between the fan-out and the ranking pass so hidden hits cannot spend the result limit and shrink what is shown. Items the backend cannot attribute to a library, such as Plex shared and external media, are kept. The screen re-runs the visible query when a library is hidden or unhidden while results are on screen. Its listener is attached only after the provider has hydrated, so the initial load notification cannot race the first query into running twice. Plex search rows now go through the library-aware tagger, so a response that names its section only via librarySectionKey or targetLibrarySectionID is still attributable, and therefore filterable. Jellyfin search results carry no library id at all: the mapper's ParentLibraryId is not a Jellyfin field, and ParentId resolves to a season or physical folder rather than a CollectionFolder. Filtering there needs server-side ParentId scoping and is left for a follow-up. close #1770 |
||
|
|
bac2a0d201 |
fix(player): keep Delete and Home editing text in player sheets
Bare Backspace and Home are player navigation keys, but they are also caret editing keys. The player screen's Focus wraps its OverlaySheetHost, so it saw them before the subtitle-search field could act: the press was consumed on key-down, DefaultTextEditingShortcuts never turned it into a deletion, and the back pipeline hid the chrome and then left the player. A focused text editor now takes both keys back, but only for physical keyboard presses — a synthesized dpad/gamepad press has no caret, and browserHome has no editing role at all. The screen also resolved its overlay-sheet controller from the State's own context, which sits above the host it was querying, so the lookup always returned null and Back skipped the sheet stage entirely. Resolve it from a context below the host instead, matching NowPlayingScreen. close #1741 |
||
|
|
bc0d14a749 |
fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore "In these libraries" chooser could never show more than one copy per server. A movie held by both a 4K library and an HD library on one Plex server therefore resolved to whichever copy came back first, with no way to reach the other. Return every id-verified match instead. `/library/all` is already server-wide and each `Metadata` entry carries its own `librarySectionID`, so both copies come back labelled with no extra request; Plex was simply taking `Metadata[0]` and the title ladder was returning on its first hit. An exact-guid hit no longer short-circuits the title search either — a library still on a legacy agent has a different primary guid and is invisible to the `guid=` filter. Copies are deduped by global key and ordered best-first, and each row now states its resolution, since library names need not mention it. Resolution passes merge rather than replace: the cross-server fan-out logs and skips per-server failures, so a later pass can come back short a server that answered an earlier one, and a failed pass no longer claims the title left the library. Duplicate keys fold field by field, because Jellyfin's library stamp is a best-effort ancestors lookup that returns the item bare when it fails and an unstamped row is indistinguishable from its sibling. Focus nodes are keyed by copy and reclaimed after a merge re-sorts the rows, so a dpad user is not thrown to a different copy. close #1754 |
||
|
|
2cb2c3eb95 |
feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor took `rating` and fell back to `audienceRating` only when it was null, so a Plex movie carrying four attributed scores surfaced one, and which one was whatever the server happened to put in the scalar slot. #1755 asked for a setting to choose the source; showing all of them answers it without one. The data was already on the wire and being thrown away. `/library/metadata/ {id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, but PlexMetadataDto declared no field for it, so json_serializable dropped the key. The identical parse already existed in plex_catalog_source for the Explore tab and had simply never been wired to library items. Model the scores as a list rather than widening the scalar pair. The neutral MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage and audienceRatingImage, which the list subsumes — Plex sends those images on listings too, so the same field covers both response shapes and no caller narrows to a backend type to read a score any more. CatalogRatingSource is promoted to lib/media as MediaRatingSource instead of growing a second near-identical type beside it, and plex_catalog_source's _ratingsFor becomes the shared plexRatingSources so one implementation serves both paths. There is no persistence to migrate: MediaItem.toJson has no production caller, the offline path re-parses raw Plex JSON through the same mapper, and Plex's audienceRating sort is server-supplied data, not a model read. Cards and the dashboard still show fewer scores than detail screens, and that part is a real Plex limit rather than a shortcut. Section listings send only the scalar pair; includeRatings, includeElements=Rating, includeFields=Rating, includeChildren and includeExtras were each probed against a live server and none surfaced the array, while includeGuids=1 demonstrably does add Guid[] — the probe works, the parameter does not exist. Hydrating every card would be one request per row, so listings render whatever their own response carried, which is one or two attributed scores rather than the single one they showed before. Jellyfin has no per-source array at all: the server collapses whatever its fetchers found into CommunityRating and CriticRating. CommunityRating's provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or a local NFO, last writer wins — so it stays the generic `audience` source with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a 0-100 percent and is divided by ten explicitly rather than folded by magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin reuses CommunityRating for the EXIF 0-5 star. The badges share one slot on every surface. On the phone hero the scores go in a single pill because that chip row is a height-clipped Wrap and a chip per source would push year, certification and runtime out of the visible band on short heroes; on the TV detail line and the dashboard spotlight the group occupies the one metadata slot so bullet separators do not multiply. The group announces itself as a single semantics node naming each source, because a bare row of four percentages tells a screen reader nothing about which score is which. rating_utils drops parseRatingImage and isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper — and the source-key resolver and label map, previously private to the Explore detail screen, become the shared pair both screens use. The label strings move from explore.ratingSource to common.ratingSource accordingly, which costs no translations because every non-English value was empty; running clean_translations also scaffolds startup.quitPlezy and startup.restartRequiredBody, which were already drifted. Verified against the live server the probes came from: a detail response now yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the production mapper and badge resolver, and the listing response for the same title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures. Coverage adds mapper ordering, dedupe against the array's repeat of the scalar, out-of-range rejection, the Jellyfin scale and photo guard, the CatalogItem conversion that feeds Explore's dashboard hubs, and the three render surfaces including the semantics announcement. close #1755 |
||
|
|
395798f28e |
fix(player): stop handing ExoPlayer the demuxer's buffer budget on Auto
On Auto, Dart derives a buffer size for mpv's demuxer from the device heap and sets it as `demuxer-max-bytes`. The Android player forwarded that same number to `DefaultLoadControl.setTargetBufferBytes`, so ExoPlayer's sample allocator was sized by a tier table written for a different consumer: 64MB on any device whose large heap is 512MB or less, which every Shield is. `targetBufferBytes` is a byte cap, so the media it represents collapses as bitrate rises — 64MB is 53s of a 10 Mbit/s stream but 5.2s of a 103 Mbit/s UHD remux. With `prioritizeTimeOverSizeThresholds` false the cap is hard: `shouldContinueLoading` returns false the moment the allocator reaches it no matter how little media that is, and `shouldStartPlayback` reports READY off the same byte term. Read-ahead that short starves the audio sink in bursts, and on a passthrough route that is enough to keep the AudioTrack from ever starting — the track initializes, accepts one access unit and never renders a frame. Because an enabled audio renderer owns the MediaClock, the whole player freezes and the black-screen watchdog then blames the video decoder and drops the session to mpv. Size the LoadControl target natively instead, from what actually bounds `DefaultAllocator`: the Java heap. `min(media3's own default for a video+audio selection, largeMemoryClass/4, availMem/4)` with a 32MB floor, the lowest tier that has already shipped. The quarter matches the threshold the Buffer Size setting already warns at, and the media3 default is a ceiling — this is not "buffer more than upstream", it is "stop buffering less". Deliberately not bitrate-aware, because the LoadControl is built during initialize, before any media is opened. `bufferSizeAuto` carries the distinction over the channel; `bufferSizeBytes` still travels with it because the plugin's mpv fallback replays it as a real demuxer property, and an explicit Buffer Size choice is still honoured verbatim. Confirmed against the hardware in the 2.9.1 passthrough report. That reporter's own log is a natural A/B: three runs at 64MB fail with `0 frames rendered after 8002ms`, spanning both DV conversion modes and both tunneling states, while the single run after he manually selected 128MB logs `Position advancing` and renders. Reproduced on the same Shield model with codec and bitrate held fixed and only the cap varied — 6s of audio demand stalls at 64MiB and plays at 128MiB, 4 of 4 predictions, with read-ahead measured off an injected DefaultAllocator at 65 664 and 131 776 KiB. That device reports `dalvik.vm.heapsize` 512m, so the heap term binds first at every free-memory level in his log and Auto now derives exactly the 128MB he had to pick by hand; the shipped path logs `Buffer: 128MB limit (auto, heap=512MB, available=568MB)` where it previously logged 64MB. |
||
|
|
8a46df2850 |
fix(player): keep live TV on its retry ladder when a stream 404s
The 404 branch added in 16668be5 ran ahead of the live-TV fallback chain, so a transient live 404 — an HLS segment rolled off the playlist, or a transcode session restarting under us — showed "file unavailable" and killed a stream the bounded ladder would have recovered. Only on-demand playback can read a 404 as terminal, where it really does mean the file is unreadable. 500 stays terminal for both, since a limit rejection is not something a retry clears. The dispatch lived in a private extension on the screen state, where no test could reach the decision. Extract it as resolvePlaybackFailureAction next to runLiveStreamRetry, which already sets that precedent, and cover both the live and on-demand paths plus the ladder's rungs. |
||
|
|
86c8011b72 |
fix(player): tell the user when the server cannot read the media file
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. |
||
|
|
7f0cad339c |
fix(startup): report and repair a failed launch instead of showing "Error"
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 |
||
|
|
d55b875855 |
fix(tvos): raise the system keyboard on arrival, not on every focus
Apple TV single-line fields moved to the engine's UITextField proxy in
2.10.0 (
|