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
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.
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
Simkl only heard about an item once playback crossed the media server's
watched threshold, so stopping partway recorded nothing at all: no resumable
position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the
player lifecycle instead, carrying the measured progress. Seeks report
nothing, as Simkl asks.
The terminal stop owns watched state for in-player playback, so real-time
trackers are excluded from the threshold markWatched fan-out and one watch
never produces two writes. Progress is reported as measured — it doubles as
the user's resume position — so when a server threshold configured below
Simkl's own 80% rule would leave the watch unrecorded, the tracker records it
through /sync/history rather than inflating progress. Manual, container,
offline-replay and external-player marks keep using /sync/history. Only
/scrobble/stop accepts a 409, which is the sole action documented to return
one.
Reports go out one at a time because Simkl serialises scrobble writes per
user and fails queued ones with a 400; overflow sheds the oldest non-terminal
report so an episode swap cannot drop the previous item's stop. A playback
session is pinned to the account bound when it began and every send re-checks
that binding, so a profile switch or a disconnect/reconnect can neither
redirect a queued report nor misfile the watched fallback.
Also close the paths that lost the terminal report entirely: app exit flushes
it instead of dropping it, the desktop window button goes through the app
shutdown rather than exit(0), a detached VOD player reports a stop, and a
finished item reports completion at EOF instead of waiting for teardown. A
session that opened at 0% is still closed on stop, or Simkl keeps showing the
item as playing until its runtime elapses.
close#1719
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.
Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.
Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
close#1514
A query submitted from the companion remote now behaves like a submit
instead of focus-to-type: TvKeyboardController closes an already-open
OSK and lands focus on the input without reopening it, then the shared
handleSearchSubmit path runs the search and focuses the first result.
Also guard DebouncedMediaSearch against selection-only controller
notifications re-arming the debounce into a duplicate fetch.
Co-authored-by: l3gitpanda <12003346+l3gitpanda@users.noreply.github.com>
Backing out to home selected the home tab but let focusActiveTabIfReady
steal real focus into the content while _isSidebarFocused stayed true,
leaving the rail stuck expanded over a shifted content box that clipped
the end of every hub row. Skip the content-focus grab while the sidebar
owns focus, move the sidebar focus ring to the Home item on back-to-home,
and self-heal the flag whenever the content scope actually gains focus.
close#1411
Guest switch dispatch pre-marked its dedup key and fired-and-forgot, so
any failure (fetch error, reload busy with an auto-advance, navigation
race with the host exiting) silently stranded the guest on the old media.
A CurrentPlaybackDispatcher now marks a key handled only after the sink
reports success against the committed identity, with a serialized
in-flight slot, timeout, and generation reset; the reconciler re-offers
unattached media on every host heartbeat, making the heartbeat the retry
channel. Fetches that outlive their dispatch are re-validated against the
current snapshot so a stale switch can't override the live one.
hostExitedPlayer now rides the controller's ordered message queue with
host authentication instead of racing state handling in the provider.
The side-nav host's onSystemBack called _handleMainBack on the system
route-pop path, jumping straight to the home tab and skipping the dpad
back chain (content -> top tabs -> sidebar). Restore the original inert
behavior: canPop:false blocks the route-pop and the key path owns the
chain; the host still closes an open sheet on back. Bottom-nav unchanged.
Both main_screen hosts (side-nav + bottom-nav) drop their PopScopes and
pass canPop:false + onSystemBack to OverlaySheetHost, so a system back
with a sheet open closes the sheet instead of running tab-nav/exit. Key
(dpad) back path unchanged; onSystemBack mirrors it for a pure popRoute.