Commit Graph
1376 Commits
Author SHA1 Message Date
edde746 0fbf2e205c fix(profiles): skip the resume profile prompt during a live companion-remote session
With "Ask for profile on app open" enabled, every screen-off or app
switch on a phone re-pushed the profile picker and PIN on resume, burying
a companion-remote session that survives backgrounding since the
reconnect cycle was made lifecycle-aware. Suppress the resume prompt
while a companion session is live, mirroring the active-playback
exemption — the session already belongs to the profile that started it.

close #2087
2026-08-24 10:06:00 +02:00
edde746 84bc4b2618 fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in
sixteen more.

The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.

The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.

Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
2026-08-24 05:23:05 +02:00
edde746 6da1f83891 feat(player): switch the display to the video's native resolution on Android TV
Sub-4K content on a 4K TV was always upscaled by the playback device, so the TV's own (usually better) upscaler never received the native signal and could not apply resolution-specific processing.

Adds a Match Content Resolution setting on Android TV: playback picks the smallest display mode that still contains the video — never downscaling it — and rate-matches within that resolution when frame rate matching is also enabled. A resolution-only switch keeps the refresh rate as close to the current one as possible, and the settle watchdog now verifies the requested mode id so such switches still trigger the Android MPV decoder refresh. Transcodes get their target from decoded dimensions on the post-first-frame path. Mode selection is extracted into a pure, JVM-tested DisplayModeSelector.

close #2073
2026-08-24 02:10:35 +02:00
edde746 a11da3fec8 feat(livetv): record live TV on Jellyfin and Emby via the timer APIs
Jellyfin and Emby users had no way to record live TV from Plezy at all: no
record button in the EPG guide's program menu, and nothing in the recordings
UI, because every recording surface was gated behind a Plex-only DVR adapter.

The recording surfaces are now backed by a MediaBrowser DVR adapter that
synthesizes the neutral (Plex-shaped) payload models from the timer APIs:

- Template comes from `GET /LiveTv/Timers/Defaults?programId=`, offering a
  "Record Episode" entry and, for series airings, "Record Series". The
  defaults DTO travels JSON-encoded in the template's opaque `parameters`
  and setting ids are DTO field names, so the existing template-driven
  record-options sheet renders and round-trips them unchanged.
- Create POSTs the mutated defaults whole to `/LiveTv/Timers` or
  `/LiveTv/SeriesTimers`; a duplicate one-off create answers 400, which the
  adapter rethrows as `RecordingConflictException` so the sheet can show
  "Already scheduled" without a backend check.
- Scheduled recordings read `/LiveTv/Timers` minus `Cancelled`/`Completed`
  tombstones, rules read `/LiveTv/SeriesTimers` with their child timers
  nested. Rule keys carry a `timer:`/`series:` prefix so cancel, edit and
  delete dispatch to the right timer space inside the adapter.

Guide programs now carry their recording state: the program id doubles as the
record seed, and `TimerId`/`SeriesTimerId` become the rule keys that drive the
guide's scheduled dot and the Manage action. The series key is stamped only
when an airing actually records, so an episode a series rule skips does not
show a false indicator.

Three neutrality fixes on the shared UI: the record-options sheet only demands
a target library when the template declares one (MediaBrowser records into its
own configured folder), and "Re-evaluate rules" is hidden unless a connected
DVR supports it, since only Plex has that endpoint.

`fetchDvrs` deliberately stays empty so the synthesized per-server Live TV
identity that channel fetches, favorites and playback key off is preserved.

Verified end-to-end against a disposable jellyfin/jellyfin:10.11.11 container
with an M3U tuner and XMLTV guide, driven through the real client: template,
create, duplicate conflict, cancel, series create with child grabs, edit
round-trip and delete.

close #1645
2026-08-24 00:08:11 +02:00
edde746 6aaea847a7 fix(tv): stop dropping more than half the frames on D-pad browse
On a 32-bit Amlogic TV box, 57% of UI frames while moving focus along a rail
missed the 16.68 ms budget, and the median frame was already over it at 20.7 ms.
The GPU was idle throughout (raster p90 9.1 ms, zero frames over budget) -- all
of it was main-isolate work, split roughly evenly between layout and semantics.

Five separate causes, all measured:

- The horizontal rail used `itemExtentBuilder`, so `RenderSliverVariedExtentList`
  walked every preceding index on each realized child, in every layout pass, and
  re-resolved the trailing slot inside the closure. Pinning the trailing cell to
  the card extent makes the list uniform, which restores O(1) offset, index and
  max-extent math.
- The sidebar-expand tween wrapped the whole shell with a `LayoutBuilder` inside
  its builder, so ~15 ticks per focus flip rebuilt `SideNavigationRail` -- and its
  non-virtualized child list -- during the layout phase. The tween now wraps only
  the content `Positioned`, matching `SideNavigationBleedBuilder`.
- Each nav item crossfaded through two `Opacity` subtrees, i.e. 14-18 offscreen
  save layers per frame for the whole 250 ms morph. It now fades colour alpha on
  the leaf, the same substitution `AnimatedDimScrim` already documents.
- `HorizontalScrollWithArrows` kept a scroll listener on platforms where its arrow
  chrome is compiled out, so crossing either scroll boundary `setState`d a whole
  rail row of cards. Two full-row rebuild storms per traversal, for arrows that
  cannot appear.
- The rail's semantic proxy rebuilt its label and six closures on every
  `_RailFocusModel` notification, including vertical-scroll flips.

Also: one merged listener per `SettingsBuilder` instead of six, and the media card
semantic label is cached rather than rebuilt and then discarded by the rail's
`ExcludeSemantics`.

Measured on the device, median of three runs: frames over budget 120/212 -> 32/246,
total UI-thread work 4067 ms -> 1845 ms, layout p90 18.2 -> 8.1 ms, build p90
7.95 -> 2.42 ms. The sidebar scenario went from 121/148 frames over budget to
32/184.
2026-08-23 18:13:39 +02:00
edde746 c4059d0ead fix(startup): stop Cronet and Plex Home from blocking time-to-interactive
Two cold-start findings from the same pass. They share a call site in
`MainScreen`'s post-frame block, so they land together.

## Cronet was 33% of time-to-interactive

`createPlatformClient()` built the shared `CronetEngine` inline, so whichever
consumer happened to create the first HTTP client paid for it — and that landed
between `database_ready` and `credentials_loaded`, i.e. squarely on the path to
the first usable screen.

Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by
temporarily forcing the existing `_cronetBroken` fallback so no engine is ever
built:

| phase | engine built inline | engine never built |
|---|---|---|
| database_ready | +455 | +456 |
| credentials_loaded | +1171 | +703 |
| binding_settled | +1331 | +827 |
| main_screen | +1394 | +932 |

So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule
loadModule2NoCrashUtils` then `HttpFlagsLoader` reading
`com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider
*enumeration*, not selection — `CronetEngine.Builder(Context)` calls
`isEnabled()` on every registered provider, and `PlayServicesCronetProvider`
answers that by installing the Play services Dynamite module. `play-services-cronet`
arrives transitively through `media3-datasource-cronet`, and `package:cronet_http`
offers no way to choose a provider, so the only lever available in Dart is *when*
the cost is paid.

Android's `createPlatformClient()` now returns a client that resolves its
delegate per request: the tuned IOClient that already backstops a broken Cronet
until the shared engine exists, Cronet afterwards. Per-request matters — a
`MediaServerHttpClient` builds its client in a constructor initializer and lives
for the process, so deciding once at construction would have pinned primary
media-server traffic to HTTP/1.1 forever, which is a silent steady-state
regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine
from `MainScreen`'s post-frame block.

Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines
(`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build
now runs from +1023 to +1419, entirely after the first screen, and produces no
Choreographer or Davey report — the UI is static waiting on hub content there, so
there are no frames to drop.

## Plex Home refresh raced the offline decision

`PlexHomeService.start()` conflated disk hydration with going live: it decoded
the cached `plex_home_users_*` entries *and* subscribed to connection changes,
installed a refresh timer and fired `_refreshAll()`. It was invoked straight from
a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi
these devices typically have — it started requests that would time out during the
exact window the startup gate needs. Its immediate neighbour
`ActiveProfileBinder` is explicitly not auto-started for this reason and says so
in a comment; the same argument applied here and had simply not been followed.

`start()` is now the live/network entry point and `hydrate()` is the disk-only
half, coalesced and lifecycle-guarded like `start()` already was. The provider
`create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow
picker hydrates, because it reads `current` immediately and is reachable while
offline. Only `MainScreen` goes live, gated on `!_isOffline`, with
`_handleOfflineStatusChanged` picking it up if the session later regains network —
otherwise an airplane-mode launch would never refresh Plex Home again.

Hydration still `_emit()`s, so `stream`'s replay contract holds even when the
network side never starts, which is what keeps a late listener behind a
`combineLatest` off a permanent spinner.
2026-08-23 18:13:39 +02:00
edde746 e6a0840fbe fix(player): stop rebuilding the whole controls tree on every tick
Nothing sat between the full-screen `RasterizedGradient` scrim and the
position-driven leaves, so `markNeedsPaint` from the timeline slider walked past
the gradient and re-rasterized the entire chrome -- top bar, button row,
timestamps, timeline -- on every 250 ms position tick. That also partly defeated
`RasterizedGradient`, which exists because Skia gradients cost ~10 ms per
full-screen pass on this GPU class.

Around that, four things drove root `setState` calls that only needed to change a
leaf:

- Held D-pad seek called a root `setState` per key repeat, even when the readout
  was already visible in the same direction and only the accumulated seconds had
  changed. The seek amount now flows through a `ValueNotifier` that
  `DoubleTapFeedback` reads, so a burst updates one `Text`.
- `DoubleTapFeedback`'s drift chevron ran `..repeat()` with no stop condition, and
  kept ticking at 60 Hz for the ~300 ms the readout spends at zero opacity after
  every burst. The controller is now gated on visibility.
- The auto-skip countdown ran a 5 Hz timer that root-`setState`d the controls tree
  for the whole intro/credits window, to animate one progress ring.
- The play-next and still-watching countdowns root-`setState`d the player screen
  once a second, which re-created `PlexVideoControls` with its ~50 props and
  rebuilt the entire chrome to change one digit. Prompt open/close deliberately
  stays on the state path, because `playNextFocusNode` is threaded through those
  props and D-pad focus depends on it.

Two of the four position subscribers existed only to recompute pointer-hover
tooltips that a D-pad viewer never sees, rebuilding a button subtree 4x/s and
rescanning the chapter list each time; the "ends at" readout reformatted a string
4x/s for a value that changes once a minute. All three now dedupe.

Finally, the buffer path did two sequential `copyWith` calls on a 15-field
`PlayerState` and handed `BufferRangePainter` a freshly allocated list every tick.
`List` has no value equality, so `shouldRepaint` was unconditionally true.

Measured on device during playback with the chrome raised: 6 of 344 UI frames over
budget, p50 4.39 ms, layout p90 1.09 ms. During a held D-pad seek -- the worst
frame-time window on the TV path, because the readout and the decoder flush land in
the same frames -- 1 of 354 frames over budget, p50 4.28 ms, zero raster frames over
budget.
2026-08-23 18:13:39 +02:00
edde746 5d83d4833c fix(images): stop decoding artwork nothing ever displays
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.

- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
  `resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
  10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
  the same provider in `ResizeImage`, so it decodes again under a different cache
  key. The comment already said the intent was only to warm the disk cache, so it
  now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
  10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
  own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
  3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
  viewer sits still and evicting the rail posters behind it. The fade is already
  zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
  `getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
  a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
  `backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
  `CachedNetworkImage` directly, keeping the sized request but losing the
  `plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
  re-downloaded on detail open. The season strip also discarded its height bound.

Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
2026-08-23 17:51:27 +02:00
edde746 02a5f8ad2c fix(settings): pass the connection dialect when listing known servers in discovery
PR #1666 merged without compiling: it maps existing connections into the
add-server discovered list, but DiscoveredJellyfinServer gained a required
dialect with the Emby backend. Carry the connection's dialect through and
list only connections matching the screen's dialect, so the Add Emby screen
does not offer existing Jellyfin servers.
2026-08-23 10:30:36 +02:00
edde746 0964ab8ba2 feat(libraries): open grouping, filters, and sort as anchored menus on desktop
The three browse chips opened bottom sheets on every form factor. On
desktop platforms they now anchor dropdown popups to the chip: grouping
is a radio menu, sort shows the direction on the active field
(re-selecting toggles it) plus Clear, and filters use a two-level menu
with per-category values and an All row to clear one category. Phones
and TV keep the existing sheets.
2026-08-23 10:12:27 +02:00
edde746 89b8af62ea fix(settings): use background focus chrome on the add connection cards
The backend cards drew the outline focus border; the wrapper now sits
inside the opaque Material with useBackgroundFocus so the fill paints
above the surface, matching the other settings surfaces.
2026-08-23 10:12:27 +02:00
edde746 5842d9c1ec fix(profile): replace the profile tile chips with inline metadata
The Active badge and connection chips used opaque primaryContainer /
surfaceContainerHighest - both resolve to the card surface color in the
mono scheme, so they punched dark holes in the focused tile's background
highlight, and boxed pills fight that fill however they are tinted. The
Active badge becomes a muted check icon + label next to the name, and
connection chips become plain muted icon + text entries, matching the
app's other meta rows.
2026-08-23 10:12:27 +02:00
edde746 627374d4b5 fix(detail): fit hero chips on one row and even out the mobile sections
The hero metadata strip wrapped to a second run, leaving the Rate chip
alone on its own line (or clipping it away on short heroes). The strip
now measures chips and sheds them by usefulness - scores pill first,
then quality labels, edition, certification, runtime - never the year
or the interactive Rate chip, mirroring the TV FittedMetadataLine.

Episode rows replace the 'E0' primary-container chip with an inline
'3. Title' prefix that mirrors the server-provided number, including a
genuine episode 0. Phone section gaps drop from 24px to 12px so Cast,
Extras, and the related hubs share one rhythm; TV keeps its own values.
2026-08-23 10:12:27 +02:00
edde746 c4b043cba9 fix(ui): present the library switcher and profile menu as bottom sheets on mobile
Both anchored popups were small tap targets on phones. AppMenuButton
gains an opt-in adaptiveSheet flag that routes through
showAdaptiveAppMenu: full-width, untitled sheet rows on touch platforms
(phones plus Android TV / tvOS), the same anchored popup on desktop.
2026-08-23 10:12:26 +02:00
edde746 fe954eef5f fix(profile): use background focus chrome on the profile screens
Profile tiles and buttons drew the outline focus border while every
other settings surface highlights with a background fill. Tiles nest
the FocusableWrapper inside the Card so the fill paints above the
opaque card surface; buttons opt into useBackgroundFocus.
2026-08-23 10:12:26 +02:00
edde746 66d0ef054e fix(auth): lay out the plex QR retry and cancel buttons inline on TV
The QR sign-in screen stacked Retry above Cancel with intrinsic widths,
reading as sparse on a TV canvas and pushing Cancel off the bottom edge.
Both wait states now share one centered row, matching the player's
initialization-error surface.
2026-08-23 10:12:26 +02:00
Thomas LupinandGitHub 8f13a56e1a Add existing Jellyfin servers to discovered servers (#1666) 2026-08-23 09:29:04 +02:00
edde746 1a9d2686c9 fix(tv): declutter the detail page with a slimmer rail and spaced hero
The TV media detail page felt cramped on 1080p TVs (540 logical px): the
bottom rail's title strip, card label bands, and next-hub peek consumed
nearly half the screen and cut the next hub's posters mid-card at the
screen edge, while the hero packed logo, metadata, genres, summary, and
action row into 5-10 px gaps against the back arrow, shedding summary
lines to fit.

Slim the shared rail metrics (hub strip 36->30, next-hub peek 30->20,
label band 42->36, person labels 58->52, height slack 14->10) and the
detail page's rail card scale (0.8->0.72), then spend the freed height
on the hero: larger gaps between metadata/genres/summary/actions, a top
offset that clears the back button, and an info column widened from 57%
to 60% of the screen. Movie and show details now keep three spaced
summary lines, and the wider metadata line fits one more rating badge
before shedding.

The #1893 metadata-line regression test pinned the exact badge-shed
count at its test viewport; it now asserts rendered badges stay fully
on screen while keeping the quality-label guards.
2026-08-23 07:08:27 +02:00
edde746 95800d8a72 fix(tv): open a full details sheet from the detail hero info block
On TV the detail hero caps the description at three lines with no way to
read the rest, and the fitted metadata line sheds rating badges and
quality labels when they do not fit, leaving that information
unreachable by D-pad.

The hero information block is now a focusable target: D-pad up from the
action row lands on it, and select (or tap) opens a sheet with the
complete metadata fields, every rating badge, the genres, and the
untruncated description. Long descriptions page with D-pad up/down.

close #2042
2026-08-23 05:51:36 +02:00
edde746 c201bc2f03 feat(player): default every container to the ffmpeg demuxer
The Auto demuxer mode routed only media3's weak container families
(AVI, ASF/WMV, MPEG-PS, Matroska/WebM) through FFmpeg and kept MP4/TS
on media3's extractors, with a second any-container FFmpeg instance
behind media3's list. With the goal of fully switching to the ffmpeg
demuxer, the split served no purpose and the catch-all could never add
coverage once the primary accepts everything.

Collapse the preference to two modes: FFmpeg (default), which demuxes
every progressive container ahead of media3's list, and media3 only as
the user-facing escape hatch in case a file misbehaves. media3's
extractors stay behind FFmpeg, so anything FFmpeg cannot sniff still
reaches them. The catch-all role and the Auto container list are
removed; unknown persisted wire values (including the retired "auto")
resolve to FFmpeg.

Verified on a Pixel 7: MP4, TS, and MKV all demux through FFmpeg under
the default ("sniff accepted mov,mp4 / mpegts / matroska,webm") with
forward and backward seeks landing; JVM suites and analyzer pass.
2026-08-23 01:42:36 +02:00
edde746 75c095a5b2 feat(player): demux direct-played containers with ffmpeg on Android
Direct play on Android kept accumulating container patches: AVI with
XviD packed-bitstream timestamps and missing VOL csd played broken,
ASF/WMV and MPEG-PS/VOB had no media3 extractor at all, and MKV needed
a custom extractor stack for zlib-compressed subtitles, LOAS/LATM
audio, cueless seeking, and font attachments.

Demux progressive containers with libavformat behind media3's
extractor API. An AVIO bridge serves libavformat from the
ExtractorInput: every position divergence defers into a RESULT_SEEK
round trip, header reads replay from a block cache while
avformat_open_input restarts, and avformat_seek_file executes seeks
with bounded loader round trips. Packets feed media3's TrackOutputs,
so decoders, passthrough carriers, Dolby Vision RPU/EL conversion
(dvh1 codecs string surfaced from DOVI side data), and the subtitle
pipeline are untouched: embedded fonts feed AssHandler over JNI, ASS
reaches libass as per-sample dialogue, SRT/VTT render as cues, VobSub
maps through media3's VobsubParser, and LOAS/LATM AAC unwraps via
LatmTrackOutput.

Under the default Auto preference FFmpeg demuxes AVI, ASF/WMV,
MPEG-PS, and Matroska/WebM and sits behind media3's list as an
any-container fallback; MP4/TS keep media3's extractors. A playback
setting exposes auto / FFmpeg first / media3 only, and the JNI
load-failure path falls back to stock media3. The custom Matroska
extractor stack and its reflection keeps are retired.

Verified on Pixel 7 (API 36), Box R 4K Plus TV (API 34), and SHIELD
TV (API 30): instrumentation playback suites, the minified R8
reachability gate, and an on-device container matrix with forward and
backward seek landings plus visual subtitle checks.

close #2052
2026-08-23 01:22:24 +02:00
edde746 ed1d0f1b10 fix(profiles): require the target profile's PIN before managing or deleting it from the picker
Long-pressing a PIN-protected local profile in the picker offered Manage and Delete with no verification, so any user could open its detail screen, clear its PIN, or delete it outright. Both actions now verify the target's PIN first (active profile and Plex Home profiles keep their existing flows).
2026-08-21 19:23:44 +02:00
edde746 a029b1a7c8 fix(seerr): decode media status per product and model failed/completed request states
Jellyseerr shifts MediaStatus codes 6/7 relative to Overseerr (6=blocklisted, 7=deleted vs 6=deleted), and the shared unconditional mapping misread both. The product is now detected from the presence of mediaServerType in /settings/public, persisted on the session, and used to resolve raw wire codes; blocklisted titles are shown as non-requestable.

Request statuses failed(4) and completed(5) previously decoded as pending, blocking re-requests and mislabeling failures; both are now modeled and active requests are defined positively as pending or approved. Seerr also marks a request Failed on arr-push failure while leaving the media status Processing, and precedence checked processing first, so failed titles rendered as Processing and the request sheet kept blocking re-requests; a failed request with no live pending/approved request now wins over stale pipeline status in both the catalog state and the sheet's blocked labels.

Reauthentication completing from a stale session snapshot wholesale-replaced the session and could downgrade a concurrently detected product discriminator back to unknown permanently (the settings cache never reapplies it); adoption now merges the known product.
2026-08-21 19:23:42 +02:00
edde746 4fcb221643 fix(downloads): repair recovery paths, queue stalls, resume coverage, deletion fan-out, and aggregate status
Post-restart completion recovery joined directory/filename, losing the base-directory component the Task constructor strips on custom roots. An unresolvable queue head (offline server) broke out of the drain loop, stalling every other queued download; the drain now excludes it and continues. The download tree also read all-cancelled or partial containers as completed.

The queued-download resume was a one-shot on the first online client, so a persisted row skipped because its server was offline stayed queued until restart. The resume now re-fires whenever a server comes online that the last resume did not cover (including reconnects), and a resume landing mid-drain re-drives the pass instead of being swallowed by the processing guard.

Deleting an episode fanned out one network playback-extras request per sibling row; the reference scan is now cache-only, retaining thumbnails when a sibling cannot be resolved. Season/show deletion removes rows sequentially, so that conservative retention would have counted siblings queued in the same batch - a sibling, or the container row whose extras cache is always a miss, made every episode retain its thumbnails and orphaned the files once the batch's rows were gone. The fan-out threads the batch's rating keys through so rows scheduled to disappear neither retain thumbnails nor contribute in-use paths; genuinely surviving rows still trigger retention.
2026-08-21 19:23:42 +02:00
edde746 0f5cd11c08 fix(screens): claim the mandatory profile picker before awaiting and skip the full-screen loader after playback
The mandatory profile picker checked its re-entry flag before several awaits, so an initialization notification could stack two requireSelection routes; the flag is now claimed before the first await and the late-profiles edge only fires on a real transition. Returning from playback also reran the full metadata loader, replacing the whole detail screen with a spinner and resetting the selected season; playback returns now use the non-loading watch-state refresh.
2026-08-21 19:23:41 +02:00
edde746 09e0b06e49 fix(tv): unbreak collapsed-rail D-pad traversal, action-bar focus survival, and text-field back handling
With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates.

An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment.

The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
2026-08-21 19:23:41 +02:00
edde746 5c08862194 fix(trackers): purge queued writes on disconnect, evict stale anime snapshots, and cancel dismissed auth dialogs
Queued tracker writes carried no account identity, so rows created under account A replayed against account B after a reconnect; explicit disconnect now purges that service's queue. An in-flight markWatched/reconcile that failed after the purge was re-enqueued anyway (the write scope only tracked profile generation) and replayed into whichever account connected next: Tracker.accountBinding (client identity, rebound synchronously on every disconnect) is captured before each write and re-checked before queueing a retry, and because the check sits in the same synchronous segment as the enqueue, a row that passes it is claimed ahead of the purge in the queue mutex and is still removed by it.

The memoized anime-list snapshot survived writes, so sequential rewatch updates computed from pre-write state; successful writes now evict the entry.

A device-code dialog dismissed by system back never cancelled the poll (blocking new attempts until the deadline), and a user-cancelled sign-in showed the connection-failed snackbar; both now route through one cancel path.
2026-08-21 19:23:41 +02:00
edde746 de91db47c7 fix(player): keep progress reporting and live sessions alive across failed retries, re-arm the sleep timer on prompt dismissal
A Jellyfin live retry that failed before open stop-reported the still-current session (recover returns the receiver), killing /Sessions/Playing for the rest of the session; the discard now skips a recovered session identical to the current one. An in-place reload failing before the open boundary left the progress tracker disposed and nulled, so the resumed stream never reported again and the exit flush was skipped; the re-wire now also runs on the rollback path against the restored metadata. Dismissing the Still Watching prompt via back or next also never re-armed the sleep timer; it now counts as the same acknowledgement as Continue.
2026-08-21 19:23:40 +02:00
edde746 d7500c25c1 fix(plex): stop double-encoding removed tags and surfacing failures as empty lists
Removing a tag containing a space or symbol silently did nothing: the removed values were pre-encoded and the transport encoded them again, so Plex matched nothing while the UI showed the tag gone. Folder listings and the shared list wrapper also converted every transport failure (401, 500, timeout) into a successful empty list, so an unreachable server rendered 'no folders'/'no results' with no retry while Jellyfin surfaced an error. Failures now propagate to the existing error paths, and the match screen gained one.
2026-08-21 19:23:40 +02:00
edde746 5961bf8881 fix(settings): reject blank intro/credits patterns and ignore stored blanks
A cleared skip-marker regex saved as an empty pattern, and RegExp('') matches every chapter title: all synthetic markers became intros or credits, and an end-of-file credits marker could auto-advance the episode. Blank input is now rejected at both validation and save, and the chapter fallback treats already-persisted blank patterns as absent.
2026-08-21 19:23:40 +02:00
294419109b feat(player): dismiss the skip prompt on Back instead of exiting (#2049)
While a Skip Intro / Skip Credits prompt is up and the chrome is down,
Back walked the screen's staged chain and exited playback. On a remote
nothing else means "no thanks": left/right seek, OK takes the skip,
up/down raises the OSD. Declining an intro cost you the episode.

Adds a stage for it alongside the sheet and content-strip stages that
already run locally in the player controls. Gated on the same condition
as Select's skip path, off on phones (#1938), off without canControl,
and off while a screen-level prompt owns the key.

The claim is latched for the whole press: handleBackKeyAction acts on
the key-up, and the button's own 7s auto-dismiss can fire in between.

Co-authored-by: cajunflavoredbob <cajunflavoredbob@users.noreply.github.com>
2026-08-21 08:48:44 +02:00
edde746 61268fab73 fix(livetv): derive recording state from grid subscription attributes
Recording indicators never appeared in the EPG and the program sheet kept
offering "Record" for already-subscribed programmes, so rules were created
repeatedly (nine duplicate subscriptions in the linked report). Two defects,
both confirmed against the official web client bundle:

- The sheet's scheduled-state check called
  /media/providers/<identifier>/media/subscriptions/mapping/<ratingKey>,
  which 404s: PMS mounts that route under the numeric MediaProvider id from
  /media/providers. The check failed instantly and unconditionally on every
  platform, so the button always read "Record".
- The guide derived its dot solely from cross-matching grab metadata, and
  the grab refresh issued right after scheduling raced the server
  materializing the grab, wiping the optimistic key before one frame showed.

The grid response already tags subscribed airings with subscriptionID /
grandparentSubscriptionID - the signal the official client renders from.
LiveTvProgram now carries those attributes; the guide checks them first
(grab matching stays as the secondary signal) and keeps local
schedule/cancel actions authoritative until the next grid load. The sheet
resolves Record vs Manage from the tagged rule key without a network call,
falling back to the fixed numeric-id mapping route for untagged airings.
Grab parsing also accepts the nested airing under "Video", which PMS uses
for every non-scheduled grab status in JSON.

close #2009
2026-08-21 07:44:27 +02:00
edde746 54af977f39 fix(library): refetch content tabs when the app resumes from a long backgrounding
Opening Plezy on a TV box resumes the resident process rather than
cold-starting it, so the Libraries grid kept showing the in-memory
content from the previous session -- hours stale -- until the user
switched libraries and back. Nothing on the resume path refreshed tab
content.

MainScreen now latches genuine backgrounding and, on a resume more than
five minutes later, refreshes LibrariesProvider and sweeps the content
tabs through each screen's in-place Refreshable.refresh() -- skipped
while offline, before startup priming, during playback, or with no
connected servers. LibrariesScreen.refresh() now refetches the selected
library's loaded tabs (the toolbar refresh action) instead of
re-selecting the saved library, which never reloaded them because tab
widgets only reload when the library's globalKey changes.

close #2043
2026-08-20 18:19:03 +02:00
edde746 fcb167e661 fix(library): size hub detail posters like home rows and library grids
Home hub rows showed ~3 large posters per row while the hub's "see all"
page packed 5 small ones on the same 360dp phone. Hub detail was the
only surface on the padding-aware target-count formula, which resolves
ceil(lerp(5, 2, f)) columns regardless of screen width (and 9+ columns
on TV). Drop the flag so hub detail shares the fixed-extent formula
with every other grid, and delete the now-unused
getMaxCrossAxisExtentWithPadding and its usePaddingAware plumbing.

close #2039
2026-08-20 14:34:27 +02:00
edde746 71cbb83c06 fix(player): keep a play/pause tap from crashing on a torn-down player
Transport from the controls layer ran unawaited with no error handling:
a tap racing player teardown (NOT_INITIALIZED on Android) or a wedged
mpv event queue (SET_PROPERTY_FAILED on Windows) crashed the app. The
controls path now tolerates failures the same way the remote transport
path one function above already does.
2026-08-20 13:31:03 +02:00
edde746 04673a8c8a fix(library): render Plex home videos with 16:9 thumbnails instead of cropped posters
Plex "Other Videos" libraries returned items as type=movie with
subtype=clip, which mapped to MediaKind.movie and rendered 2:3 poster
cards that cropped the generated 16:9 video-frame thumbs.

Items keep the movie kind so downloads, add-to, delete-from-server, and
detail navigation stay available, but subtype=clip now renders wide with
the thumb-first artwork clips already use. Sections marked subtype=clip
in /media/providers map to the clip library kind, giving Plex home-video
libraries the same folder-first grouping and wide grid cells as
MediaBrowser homevideos views. Wide-only hubs also keep 16:9 cards in
the poster episode modes, matching the TV rail's gate.

close #2036
2026-08-20 05:44:08 +02:00
edde746 958c27350d feat(navigation): open settings with Cmd+, / Ctrl+, on desktop
Pressing Cmd+, on macOS did nothing: the stock Flutter template left a
disabled Preferences menu item holding the key equivalent, and nothing
in the app handled the chord.

MainScreen now handles Cmd+, (macOS) / Ctrl+, (Windows/Linux) beside
the existing Cmd+F search shortcut, reusing its tab-aware open path. A
SettingsShortcut fallback above the profile navigator covers pushed
content routes, pushing a named settings route; a navigator observer
blocks the chord while settings is already in the stack, and the video
player route keeps ownership of the keyboard. The dead xib menu item
is removed.

close #1909
2026-08-20 05:28:37 +02:00
edde746 dcf2314d9d fix(player): interpolate the media-controls suspend flag in lifecycle log
The controller extraction left a bare $_mediaControls.suspendedForTvBackground
in the lifecycle diagnostic string, so the line logged the controller
instance instead of the boolean it exists to surface during TV
background-suspend debugging.
2026-08-20 03:30:07 +02:00
edde746 4c74d6a1d5 chore(player): latch TV background suspend state in one owned object
The Android TV background suspend (#1911) kept its grace timer, the
suspended latch, and four pre-stop position/track fields loose on the
State, with the latch/rollback/consume invariants enforced only by
convention across three lifecycle methods and the redelivery loop.
Move them into TvBackgroundSuspendState: latch() sets the snapshot and
the suspended flag together, clear() rolls both back when the native
stop fails, and consumeForRestore() drops the suspended latch before
handing the snapshot to the restore reload — the ordering the bounded
stop-report redelivery depends on. Pure eligibility predicates stay in
tv_background_suspend_policy.dart; orchestration stays in the
lifecycle part.
2026-08-20 03:08:43 +02:00
edde746 5842d30ae8 chore(player): route all live-session mutations through the live part
The live fallback ladder, retry latch, timeline suspend/resume, and
exit-on-resume flags were poked directly from the error handler, the
play-intent path, the playing-state listener, and lifecycle handling.
Give the live part named operations (_beginLiveLadderRetry,
_retryLiveStreamForPlayIntent, _resetLiveLadderOnPlaybackRestart,
_stopLiveSessionForTvBackground, _consumeLiveExitOnResume) and move
the timeline suspend/resume helpers over from lifecycle, so
LiveTvSessionState is mutated only by live_tv.dart and the initial
tune in the start composition root.
2026-08-20 03:04:14 +02:00
edde746 5e19d9dad4 chore(player): give the episode vertical an owned session-state object
Adjacency, loading flags, the Play Next prompt/countdown, the
transient-retry budget (#1867), the completion latch, and the per-
screen adjacency loader were thirteen loose State fields written by
the episode parts, the reload engine, and the stream listeners. Move
them into EpisodeSessionState — the episode analog of
LiveTvSessionState — so the vertical's mutable state has one home and
the reload engine's touch points (clear prompt on open, reset
adjacency and retry budget on swap, record failure reason) are visible
as writes to one object. Logic is unchanged; the screen disposes the
state object instead of the raw timer.
2026-08-20 03:00:27 +02:00
edde746 b0b4d5d6db chore(player): extract spurious-EOF recovery into an owned helper
The dead-stream recovery budget, its progress-based refill, and the
parked latch (#1520) were four State fields written from the commit
chokepoint, the position listener, the reload engine, seeking, and the
transport-intent handlers. Move the state machine into
SpuriousEofRecovery with explicit verbs (interceptEof, retry,
clearPark, resetBudget, onPositionAdvanced); the reload call rides one
injected callback that fills the common in-place-reload arguments. The
reload outcome enum becomes the public MediaReloadOutcome so owned
helpers outside the screen library can consume it.
2026-08-20 02:55:08 +02:00
edde746 a55ec642b3 chore(player): move the transition lock and generation into a gate object
The mutually-exclusive playback transition state, its lease identity,
the idle completer, and the playback generation counter were four State
fields manipulated by centralized screen methods. Move them verbatim
into PlaybackTransitionGate (tryAcquire/owns/advance/release/forceIdle/
waitForIdle/beginGeneration) so lease discipline is enforced by one
type and parts can no longer touch the raw fields. The transition enum
and lease type become public in the gate's library; leases are only
mintable through tryAcquire.
2026-08-20 02:50:18 +02:00
edde746 aedca9b416 chore(player): centralize first-frame readiness in FirstFrameGate
_hasRenderedFirstFrame had five writer files with hand-rolled
snapshot/restore pairs in the reload and channel-zap rollbacks, and the
paired _hasFirstFrame ValueNotifier was reset independently along the
open path. Move both flags into FirstFrameGate with explicit verbs —
markReady, resetUiForOpen, resetRenderedForAttempt, reset,
forceUiReadyOnFailure, snapshot/restore — so the UI-vs-reporting
asymmetry and the transactional rollbacks are enforced by one type.
The uiReady notifier keeps its identity across attempts; the video
surface, controls, and buffering overlay hold it by reference.
Unit-tests the gate's verb semantics.
2026-08-20 02:45:53 +02:00
edde746 d9a5fb034e chore(player): extract shader and zoom orchestration into a controller
Shader preset application, ambient lighting enable/restore/toggle, and
video zoom/boxfit lived as an extension reading per-attempt services
off the player State. Move them into VisualEffectsController, a plain
State-owned helper; the shader, ambient-lighting, and video-filter
services are injected as late-bound getters because all three are
re-created per playback attempt and nulled in teardown. Rebuilds keep
flowing through the single requestRebuild callback.
2026-08-20 02:40:30 +02:00
edde746 92cc49a265 chore(player): extract companion remote wiring into an owned binding
The CompanionRemoteReceiver callback install/uninstall, home-fallback
save/restore, provider sync handle, and receipt-time volume/seek
dispatch lived on the player State. Move them into
CompanionRemoteBinding, a plain State-owned helper: the binding
instance replaces the State as the receiver's playerOwner token with
identical identical()-guard semantics, and the provider is captured at
bind time because context.read can fail during dispose. Subtitle/audio
cycling stays on the State — the drain loop is bound to the playback
transition lease — and is passed in as callbacks.
2026-08-20 02:30:41 +02:00
edde746 1e83e4e6c2 chore(player): extract OS media-controls adapter into an owned controller
The Android TV background suspension latch, availability policy, and
resume/rewind restore logic lived as an extension on the 149-field
player State, with the suspension flag read and reset from three other
parts. Move them into MediaControlsScreenController, a plain
State-owned helper following the established player pattern: the
manager, player, and current item are injected as late-bound getters
because they are re-created per playback attempt. The controller now
solely owns the suspension latch; teardown clears it via
resetSuspension() instead of writing the State field directly.
2026-08-20 02:25:16 +02:00
edde746 e71580def9 chore(player): move the in-place reload engine into its own part
episode_navigation.dart fused two responsibilities: the episode
next/previous vertical and the screen's generic in-place media
transition engine (_switchPlaybackSource, _performPlaybackSourceSwitch,
_selectSourceSubtitleLocally, _reloadMediaInPlace,
_reapplyScopedPlayerPrefsForItemChange), which lifecycle restore,
spurious-EOF recovery, Watch Together, and the companion remote also
drive. Move the engine and deferTranscodeSubtitleSelection verbatim
into playback_reload.dart so the episode part contains only the
episode feature. No code changes beyond the file split.
2026-08-20 02:16:12 +02:00
edde746 df0d71e6f8 fix(auth): let the Plex QR and browser waits cancel back to the sign-in actions
Google Play rejected 2.16.0 (versionCode 142) for an Android Automotive
navigation dead end: on a car, "Sign in with Plex" resolves to the
in-app QR wait, which offered only Retry — and the AAOS system bar has
no back button, so a touch user could not leave the screen without
killing the app. Add a Cancel action to the QR and browser-polling
states that aborts the attempt and returns to the initial sign-in
actions on every platform.

Verified on an API 34 Automotive emulator: Cancel returns to the
landing with the Jellyfin/Emby paths reachable.
2026-08-20 01:11:23 +02:00
edde746 20896a3d48 fix(player): keep the wake-time profile picker responsive on Android TV
Sleeping the device mid-video with "Ask for profile on app open" enabled
left the app wedged on an unresponsive Choose Profile screen (Nvidia
Shield). The picker is pushed on the root navigator, but the player's
focus self-heal only consulted its own nested profile-session route, so
it yanked D-pad focus back behind the picker whenever it lost it.

Route currency now walks every enclosing navigator (isRouteChainCurrent)
before the player reclaims focus, primes loading-phase navigation focus,
or claims the surface on window focus. The resume-time prompt is also
skipped entirely while a video player is active: waking mid-stream
resumes the stream instead of stacking the picker over the session.

close #2034
2026-08-19 23:08:32 +02:00