Compare commits

...
Author SHA1 Message Date
github-actions[bot] ddc3a4d77c chore: bump version to 2.12.1 2026-08-04 14:59:38 +00:00
edde746 58d5d3c4ef fix(plex): read external ids from legacy agent and HAMA AniDB guids
Plex only builds the `Guid` array for the Plex Movie / Plex TV Series
agents. A library still on a legacy agent answers with the scalar `guid`
alone, so `fetchExternalIds` returned nothing for it and every consumer
went quiet: trackers logged "no external IDs" and skipped the write,
manual ratings showed "Not available", the detail screen dropped its
watchlist button, and Continue Watching stopped collapsing duplicate
copies. The reverse lookup already read that scalar; only the forward
path ignored it.

Read both shapes from the one request the method already makes, with the
array winning per field and the scalar filling the rest.

HAMA identifies anime by AniDB id and nothing else, which no id set could
carry. AniDB is the Fribb mapping's own primary key, so it now travels on
`ExternalIds` and indexes those rows directly — 7177 of them expose no
tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain
`anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one
TVDB-numbered show, so the guid names the root entry only.

Two guards keep the new id where it means something. It is trusted for
season 1, because that mode puts the anime there and its specials in
season 0, while a higher season means the library is numbered by TVDB
instead. And it resolves nothing for Trakt and Simkl, which never map
anime and cannot address an AniDB id, so they keep reporting no ids
rather than failing silently further down. `hasCatalogIds` marks the
callers that can only speak IMDb/TMDB/TVDB.

close #1788
2026-08-04 16:56:54 +02:00
edde746 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
2026-08-04 16:10:02 +02:00
edde746 61ae314c94 fix(player): tell same-language subtitle rows apart across episodes
The committed track kept the server display title, which collapses to the
bare language ("English") and is identical for every same-language row: a
carried signs/songs choice tied with the full dialogue track on the next
episode and latched onto whichever row sorted first. The row's own title
is preferred now, so the carried intent names the exact row again and the
native pass can match the right container track by title instead of
language order.

Reproduced against a live library where both English ASS rows differ only
by Title ("Styled Subtitles" vs "Signs/OP/ED").

close #1785
2026-08-04 15:22:46 +02:00
github-actions[bot] d82a5c5cd7 chore: bump version to 2.12.0 2026-08-04 13:03:59 +00:00
edde746 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
2026-08-04 13:51:20 +02:00
edde746 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
2026-08-04 08:38:19 +02:00
edde746 a759e8b3c6 perf(jellyfin): fetch a detail item once when several callers want it at once
Opening a detail screen issued two identical full-detail GETs for the same
id, concurrently: `_loadFullMetadata` calls `fetchItemWithOnDeck`, and
`_initWatchlistState` calls `fetchExternalIds`, which fetches the same item
purely to read `ProviderIds`. Playback start adds three more for its own id.

Each of those makes the server rebuild the entire dto — `People`, `Chapters`
and `MediaSources` cost a database query apiece and `Trickplay` costs several
plus a filesystem stat — so the duplicate is expensive on both ends.

`fetchItem` now shares an in-flight request per item id. Single-flight only:
once a request settles the next caller re-fetches, so nothing can serve a
stale item.

Measured on a remote Jellyfin server, 12 interleaved show-detail opens per
version: requests 4 -> 3, payload 28.4 KB -> 18.6 KB. Median wall time is
unchanged (1394ms -> 1386ms) because the duplicate ran alongside the first
rather than behind it; this removes duplicated work, not latency.

Two things were tried and rejected because measurement did not support them:
starting `/Shows/NextUp` in parallel with the detail fetch (the requests
contend rather than overlap — NextUp went from 380ms alone to 1395ms beside
it — and it costs a wasted request per movie), and dropping `Trickplay` /
`Chapters` from the detail field set (no measurable effect; both are real
data the playback path reads).

Refs #1784
2026-08-04 06:39:23 +02:00
edde746 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
2026-08-04 04:35:06 +02:00
edde746 8624c37041 fix(profiles): notice a corrected connection creation time
ActiveProfileProvider diffed connections on toConfigJson alone, but
createdAt is a real column and now decides which connection lends a
profile its picture. A creation-time correction was therefore invisible
to the guard and left a stale avatar until the next launch.

Compare createdAt alongside the config. ConnectionRegistry pins
creation order across re-authentication, so this adds no notifications
in normal operation — it only stops an out-of-band correction, such as
a restore or a backfill, from being swallowed.
2026-08-04 02:22:44 +02:00
edde746 7a19f4149e fix(connections): keep a connection's creation time across re-authentication
ConnectionRegistry.upsert already preserved isDefault on conflict but
rewrote created_at from the in-memory model. Re-signing in rebuilds the
connection with DateTime.now() under the same stable id, so the row's
creation time jumped forward on every reauth.

That was cosmetic while created_at only drove list ordering. It is now
behaviour: it picks which connection lends a profile its picture, so
re-adding the originally-first connection could hand the avatar to a
later one. remove() also promotes the oldest remaining row to default
and was reading the same restamped value.

Preserve the existing row's created_at on conflict, reusing the lookup
upsert already performs for isDefault.
2026-08-04 02:22:44 +02:00
edde746 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
2026-08-04 02:22:44 +02:00
edde746 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.
2026-08-03 17:07:14 +02:00
edde746 e7aa1e4782 fix(jellyfin): stop overriding a server subtitle mode of None
Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the
user's SubtitleMode is None: the index is the server's whole answer, and null
means it picked no subtitle. The mapper read null as "the server did not say"
and promoted the container's default/forced flags to a server selection
instead, which outranks the profile subtitle mode in the selection ladder. A
viewer who had turned subtitles off for their Jellyfin user got them switched
back on by every item that carried a default or forced row.

Only the row the server names is selected now. A stream the viewer picks, and
an explicit off, still survive per item because Plezy reports the index
through playback progress and the server hands it back as that index or -1.

close #1779
2026-08-03 17:07:05 +02:00
edde746 bbaf5f0f9e fix(music): replace the Instant Mix faders with a wand icon
The three vertical faders read as an equalizer in a music context and
are the vertical twin of the video player's settings icon. Use
wand_stars, which names what the action produces and collides with no
neighbouring affordance in the action bar or the music context menu.

close #1629
2026-08-03 13:29:27 +02:00
edde746 0f5e5c8b6e feat(music): offer File Info on tracks and other file-backed items
The context menu only offered File Info for movies and episodes, so a
track's path, container, and audio stream detail were unreachable even
though both backends already answer getFileInfo for them.

Gate the entry on the new MediaKind.hasFileInfo instead of a literal
kind list: movies, episodes, tracks, and clips are leaf items with real
files, while shows, seasons, artists, albums, collections, playlists,
and folders carry no Media/MediaSources and would only ever produce the
"not available" snackbar.

Also fix the Plex stream classifier, which mapped streamType 4 to an
embedded image although PlexStreamType.lyrics is 4. Only music tracks
carry that type, so a track's lyric stream rendered under "Embedded
Images" with the video field block. Type 5 was invented outright and is
now unknown.

close #1747
2026-08-03 02:34:37 +02:00
edde746 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
2026-08-03 00:05:21 +02:00
edde746 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
2026-08-03 00:04:03 +02:00
edde746 9c08c78f6d fix(plex): stop recording a second play when the server already logged one
Plezy reported a completed playback twice: the /:/timeline heartbeats let
the server mark the item played on its own, and the in-player auto-scrobble
then sent an explicit /:/scrobble for the same watch. On PMS 1.30 that adds
a second Play History row; on 1.43 the row is suppressed but viewCount still
lands on 2 for one playback.

Measured against PMS 1.43 to find what the server acts on: a watched-threshold
crossing observed inside one session. Consecutive above-threshold reports mark
nothing, a resume point left by an earlier session does not arm a new one, and
a report at position zero is inert while one at a single second is enough. So
the explicit mark now goes out only for sessions that gave the server no
crossing to observe.

That decision cannot be made while the session is live. A session beginning
past the threshold has no crossing yet, but rewinding and playing forward
creates one, and the server records it — marking eagerly and then hitting that
path leaves viewCount at 2 again. The mark is therefore deferred to the
terminal stop, and rides its future so callers that await the stop before
tearing the player down do not drop it. Deferring also covers a crossing
coalesced away during startup and a seek back below the threshold before
stopping.

Crossing state is tracked from reports the backend actually received rather
than from PlaybackReportSession.report()'s bool, which resolves true for a
same-state snapshot dropped during startup.

The same-file sibling hook (#1500) still runs exactly once, on the transition
to a settled mark rather than at the local crossing, so sibling episodes are
never marked watched while the episode actually played is not.

Local watched state and Continue Watching removal still happen on the observed
crossing, so the only behaviour that moves is the redundant server call.

close #1740
2026-08-02 16:16:19 +02:00
edde746 95b013e155 fix(seerr): show worldwide popular titles in Explore again
Overseerr and Jellyseerr bind the `language` query parameter of
`/discover/movies` and `/discover/tv` to `originalLanguage`, which becomes
TMDB's `with_original_language`. Sending the app locale there collapsed both
shelves to titles originally made in that language, so a Portuguese UI saw
only Portuguese films. Those two routes take their display language from the
instance/user locale, which already wins over the query value, so the
parameter was pure filtering with no localization to show for it.

Drop it from the two paged discover routes. Trending, both upcoming rows,
search, details and recommendations keep it: Seerr treats it as the display
language everywhere else.

close #1763
2026-08-02 15:35:42 +02:00
edde746 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
2026-08-02 12:19:28 +02:00
edde746 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
2026-08-02 11:45:27 +02:00
edde746 35061f9f68 fix(jellyfin): scope global search to visible libraries
Jellyfin search rows do not expose their owning collection, so hidden libraries cannot be filtered after the response. Scope searches to visible libraries, stamp the returned rows, reuse the latest loaded views, propagate cancellation, and fail closed when views cannot be loaded.

Keep full candidate budgets and split music libraries into parallel album, audio, and artist requests. Album requests disable UserData and use the existing album field set to avoid recursive per-album work from #1552; audio requests retain cheap leaf play state.

close #1770
2026-08-02 09:29:59 +02:00
edde746 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
2026-08-02 09:29:59 +02:00
edde746 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
2026-08-02 07:37:12 +02:00
edde746 2a7e5f4f9c fix(jellyfin): ask the server who may delete before offering it
Jellyfin never consults IsAdministrator when authorizing a library
delete: BaseItem.IsAuthorizedToDelete looks at EnableContentDeletion and
the per-library grant, and only the first user a server creates gets the
former for free. Gating the "Delete from server" entry on the admin bit
therefore offered a destructive action that answers 401 to later
administrators, and hid it from plain users who do hold the grant.

Ask the server per item instead, through the new
MediaDeletionPermissionClient capability: BaseItemDto.CanDelete already
folds the global grant, the per-library grant, and item state such as
missing files or an in-progress recording. The probe runs when a menu
opens on a deletable kind, costs ~0.5 KB, carries a whole-request
deadline because the client's own budget covers connect and receive
separately, and fails closed on anything unknown. Plex keeps its
account-level owner/admin gate; it has no per-item permission on the
wire.

close #1749
2026-08-02 07:08:14 +02:00
edde746 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
2026-08-02 06:40:04 +02:00
edde746 f78f65faf5 chore(player): report marker counts when loading playback extras
The extras loader logged only the chapter count, so a user report of
"auto skip never fires" could not be told apart from "the server has no
intro marker for this item" — the two need opposite fixes. Log the
marker count and types on all three load paths, including the cache-only
one that previously logged nothing at all.

Drop PlexVideoPlaybackData.markers while here: the playback-start parse
filled it on every item and no caller ever read it, because the player
controls fetch their own PlaybackExtras.

Document why getPlaybackExtras may serve the shared metadata cache row
without a freshness check: getPlaybackInitialization refreshes that row
network-first before the controls mount. That ordering is what makes
cache-first correct, and nothing said so.
2026-08-02 04:55:54 +02:00
edde746 957711a650 fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of
10336 bytes, every one of them zero, and reported each as "still failing". The
gate classified it correctly every time and the consented repair would have
cleared it in-process, but nothing on the failure screen said so: Retry was
first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside
it as a tonal afterthought. Retry re-reads the same document, so for a
corrupt-store failure it is an action that cannot succeed however many times it
is pressed — and it was the one the screen recommended.

Repair now takes the primary styling, the focus node and first position whenever
it is offered, and the body text says plainly that retrying will not help.
Retry keeps its place for every other failure, where a locked database or a
denied directory really can change between attempts.

The consent dialog was also promising an outcome it could not always deliver.
Servers and profiles survive a repair only because their tokens are ciphertext
in the database and the key that decrypts them lives in the store, so a store
the key cannot be read out of signs the user out of everything — exactly the
all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without
touching it, and the dialog now names the real cost from that. The retained copy is
labelled as holding credentials unless the bytes prove otherwise: what the
salvage recovered says nothing about what the file still contains, because a
store truncated mid-value keeps most of a vault key in plaintext while the
salvage pattern — which needs the value's closing quote — matches nothing at
all. Only an all-zero file drops the warning, so the one case that cries wolf
is the one that provably holds no secret.

`describe()` finally carries whether a repair was on offer. That line is the
difference between a report a maintainer can act on and two days of guessing
whether the button was even on screen.

close #1732
2026-08-02 04:37:29 +02:00
edde746 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
2026-08-02 03:59:56 +02:00
209 changed files with 12690 additions and 2215 deletions
@@ -16,13 +16,20 @@ tags:
visible: "(?s)^(Play|Resume).*$"
commands:
- pressKey: "Remote Dpad Center"
# The TV player opens with its chrome down so the OSD and timebar never sit
# over the opening seconds of the picture (#1765), so the Pause button can no
# longer stand in for "the player has finished loading". Wait for the detail
# screen to go first: the labelled spinner is necessarily up the moment the
# player route owns the frame, and it only clears once playback reports its
# first frame or gives up, so this can never advance before the media opens.
# A give-up lands on the transport assertions below, which is where it belongs.
- extendedWaitUntil:
visible: "Pause"
notVisible: "Overview"
timeout: 30000
- pressKey: "back"
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
notVisible: "Loading video"
timeout: 30000
- assertNotVisible: "(?s)^(Play|Pause)$"
# Hardware transport keys drive playback without raising the chrome (#1676):
# the player answers with a centred disc (pause) or a side readout (seek), never the
# full chrome, so subtitles stay readable.
@@ -7,9 +7,23 @@ tags:
- playback
---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
- tapOn:
point: "6%,22%"
- tapOn: "Search"
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A
# tap here flips InputModeTracker into pointer mode, which collapses the rail
# to icons and leaves no "Search" label to hit — and a percentage coordinate
# does not survive the jump from a phone in forced TV mode to a 4K television.
- pressKey: "Remote Dpad Left"
- extendedWaitUntil:
visible: "Search"
timeout: 15000
- repeat:
times: 12
while:
notVisible:
text: "Search"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
@@ -24,13 +38,24 @@ tags:
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "(?s)^Play S1E1$"
# The TV player opens with its chrome down (#1765), so the Pause button can no
# longer stand in for "the player has finished loading". Wait for the detail
# screen to go first: the labelled spinner is necessarily up the moment the
# player route owns the frame, and it only clears once playback reports its
# first frame or gives up, so this can never advance before the media opens.
- extendedWaitUntil:
visible: "Pause"
notVisible: "(?s)^Play S1E1$"
timeout: 30000
- extendedWaitUntil:
notVisible: "Loading video"
timeout: 30000
- pressKey: "Remote Media Fast Forward"
- pressKey: "Remote Media Fast Forward"
# Gate on the prompt's own Cancel action, the label the iOS branch below taps.
# "Next Episode" is the credits skip button at this point in the episode, so
# waiting on it would let Back fire before the prompt ever opened.
- extendedWaitUntil:
visible: "Next Episode"
visible: "(?s)^Cancel$"
timeout: 20000
- runFlow:
when:
@@ -42,7 +67,10 @@ tags:
platform: Android
commands:
- pressKey: "back"
- assertNotVisible: "Next Episode"
# Dismissal is animated, so give it a bounded wait rather than one instant look.
- extendedWaitUntil:
notVisible: "(?s)^Cancel$"
timeout: 10000
- assertNotVisible: "Overview"
- pressKey: "Remote Dpad Up"
- extendedWaitUntil:
@@ -0,0 +1,152 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Pins the side-loaded subtitle identity contract against the real media3 the
* app links, on a real device.
*
* `ExoPlayerCore` tags each `MediaItem.SubtitleConfiguration` with
* [ExternalSubtitleIds.idFor] and recovers it from the `Format` the track
* selector reports. media3 does not hand that id back verbatim:
* `DefaultMediaSourceFactory` merges side-loaded subtitles with the primary
* source, and `MergingMediaPeriod` prefixes every child format id with its
* period index. Matching the raw id therefore classifies every sidecar as an
* embedded track and drops its URI, which is what broke Plex sidecar
* subtitles (#1713).
*
* A JVM test can only assert the parsing rule. This asserts that the rule
* still matches what media3 actually emits.
*/
@RunWith(AndroidJUnit4::class)
class ExternalSubtitleIdentityTest {
private companion object {
const val TAG = "ExternalSubtitleIdentityTest"
const val SRT = "1\n00:00:00,500 --> 00:00:05,000\nplezy sidecar identity\n\n"
}
@Test
fun sideLoadedSubtitleIdSurvivesMedia3Merging() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val media = copyAsset(instrumentation.context, context, "ffmpeg/planar_5_1.m4a")
val subtitle = File.createTempFile("sidecar-", ".srt", context.cacheDir).apply {
writeText(SRT)
}
val thread = HandlerThread("plezy-sidecar-identity-test").apply { start() }
val handler = Handler(thread.looper)
val settled = CountDownLatch(1)
val playerRef = AtomicReference<ExoPlayer>()
val errorRef = AtomicReference<Throwable>()
val textFormatId = AtomicReference<String>()
val textGroupCount = AtomicReference(0)
handler.post {
try {
val player = ExoPlayer.Builder(context).setLooper(thread.looper).build()
playerRef.set(player)
player.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
errorRef.set(error)
settled.countDown()
}
override fun onTracksChanged(tracks: Tracks) {
val text = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
if (text.isEmpty()) return
textGroupCount.set(text.size)
textFormatId.set(text.first().mediaTrackGroup.getFormat(0).id)
settled.countDown()
}
})
// setMediaItem (not setMediaSource) so DefaultMediaSourceFactory owns
// the subtitle configuration exactly as ExoPlayerCore.open does.
player.setMediaItem(
MediaItem.Builder()
.setUri(Uri.fromFile(media))
.setSubtitleConfigurations(
listOf(
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subtitle))
.setId(ExternalSubtitleIds.idFor(0))
.setLabel("Plezy sidecar")
.setLanguage("en")
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
)
)
.build()
)
player.prepare()
} catch (error: Throwable) {
errorRef.set(error)
settled.countDown()
}
}
val finished = settled.await(30, TimeUnit.SECONDS)
val released = CountDownLatch(1)
handler.post {
playerRef.get()?.release()
thread.quitSafely()
released.countDown()
}
val teardownFinished = released.await(5, TimeUnit.SECONDS)
thread.join(5_000)
media.delete()
subtitle.delete()
assertTrue("Player teardown timed out", teardownFinished)
assertNull("Playback failed", errorRef.get())
assertTrue("Timed out before any text track group was reported", finished)
assertEquals("Expected exactly one side-loaded text group", 1, textGroupCount.get())
val reportedId = textFormatId.get()
Log.i(TAG, "media3 reported side-loaded subtitle Format.id=$reportedId")
assertNotNull("Side-loaded subtitle reported a null Format.id", reportedId)
// The contract ExoPlayerCore depends on.
assertTrue(
"Side-loaded subtitle was not recognised as external (Format.id=$reportedId)",
ExternalSubtitleIds.isExternal(reportedId)
)
assertEquals(
"Side-loaded subtitle did not resolve to its configuration index (Format.id=$reportedId)",
0,
ExternalSubtitleIds.indexOf(reportedId)
)
}
private fun copyAsset(instrumentationContext: Context, targetContext: Context, asset: String): File {
val output = File.createTempFile("sidecar-primary-", null, targetContext.cacheDir)
instrumentationContext.assets.open(asset).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
@@ -1877,14 +1877,15 @@ class ExoPlayerCore(private val activity: Activity) :
subtitleTrackGroupMap[trackId] = trackGroup
val isSelected = group.isSelected
// Detect external (side-loaded) subtitle by the ID prefix set in open()
val isExternal = format.id?.startsWith("external_") == true
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
// Detect external (side-loaded) subtitle by the ID set in open(). media3
// rewrites merged child ids, so the tag is not the whole id.
val isExternal = ExternalSubtitleIds.isExternal(format.id)
val externalIndex = ExternalSubtitleIds.indexOf(format.id)
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null
Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
Log.d(TAG, "Subtitle track $groupIndex: formatId=${format.id}, codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
val track = mutableMapOf<String, Any?>(
"type" to "sub",
@@ -3222,7 +3223,7 @@ class ExoPlayerCore(private val activity: Activity) :
(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
(if (isForced) C.SELECTION_FLAG_FORCED else 0)
val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
.setId("external_$index")
.setId(ExternalSubtitleIds.idFor(index))
.setLabel(title ?: "External")
.setLanguage(language)
.setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
@@ -3585,7 +3586,7 @@ class ExoPlayerCore(private val activity: Activity) :
val existingIndex = externalSubtitleUris.indexOf(uri)
val isNew = existingIndex < 0
val index = if (isNew) externalSubtitles.size else existingIndex
val formatId = "external_$index"
val formatId = ExternalSubtitleIds.idFor(index)
if (isNew) {
// SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
@@ -3632,9 +3633,10 @@ class ExoPlayerCore(private val activity: Activity) :
player.prepare()
player.playWhenReady = savedPlayWhenReady
} else {
// Already attached — select the existing track via override.
// Already attached — select the existing track via override. The
// reported id carries media3's merge prefixes, so compare the tag.
val trackId = subtitleTrackGroupMap.entries
.firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
.firstOrNull { (_, group) -> ExternalSubtitleIds.indexOf(group.getFormat(0).id) == index }
?.key
if (trackId != null) {
selectSubtitleTrack(trackId)
@@ -0,0 +1,40 @@
package com.edde746.plezy.exoplayer
/**
* Identity of a side-loaded subtitle across media3's media-source merging.
*
* Each `MediaItem.SubtitleConfiguration` is tagged with [idFor], and the tag is
* read back off the `Format` the track selector reports. The tag does not
* survive verbatim: `DefaultMediaSourceFactory.createMediaSource` always wraps
* the primary source plus one source per subtitle configuration in a
* `MergingMediaSource`, and since media3 1.3.0 `MergingMediaPeriod.onPrepared`
* rewrites every child format id to `"<periodIndex>:<originalId>"`. A second
* merge — the one this player builds for container sidecars — prefixes it
* again. So the same configuration can surface as `external_0`,
* `1:external_0`, or `0:1:external_0`.
*
* Only the final `:`-separated segment carries the tag, which is why matching
* on the whole id silently classifies every sidecar as an embedded track.
*/
internal object ExternalSubtitleIds {
private const val PREFIX = "external_"
/** Tag written onto the [index]th side-loaded subtitle configuration. */
fun idFor(index: Int): String = "$PREFIX$index"
/** Whether [formatId] identifies a side-loaded subtitle. */
fun isExternal(formatId: String?): Boolean = tagOf(formatId) != null
/**
* Index passed to [idFor], or null when [formatId] is not a side-loaded
* subtitle or carries a tag this build did not write.
*/
fun indexOf(formatId: String?): Int? = tagOf(formatId)?.toIntOrNull()
private fun tagOf(formatId: String?): String? {
// substringAfterLast returns the whole string when no ':' is present, which
// is the unmerged case.
val segment = formatId?.substringAfterLast(':') ?: return null
return if (segment.startsWith(PREFIX)) segment.removePrefix(PREFIX) else null
}
}
@@ -0,0 +1,61 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalSubtitleIdsTest {
@Test
fun unmergedTagResolves() {
assertTrue(ExternalSubtitleIds.isExternal("external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("external_0"))
assertEquals(7, ExternalSubtitleIds.indexOf("external_7"))
}
@Test
fun mediaSourceFactoryMergePrefixResolves() {
// DefaultMediaSourceFactory always merges side-loaded subtitles with the
// primary source, so MergingMediaPeriod prefixes the period index.
assertTrue(ExternalSubtitleIds.isExternal("1:external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("1:external_0"))
assertEquals(2, ExternalSubtitleIds.indexOf("3:external_2"))
}
@Test
fun containerSidecarOuterMergePrefixResolves() {
// The container-sidecar path wraps that merge in a second MergingMediaSource.
assertTrue(ExternalSubtitleIds.isExternal("0:1:external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("0:1:external_0"))
assertEquals(4, ExternalSubtitleIds.indexOf("0:2:external_4"))
}
@Test
fun embeddedAndUnknownIdsAreNotExternal() {
assertFalse(ExternalSubtitleIds.isExternal(null))
assertFalse(ExternalSubtitleIds.isExternal("1:2"))
assertFalse(ExternalSubtitleIds.isExternal("0:"))
// A container child whose own id merely ends in the tag's text.
assertFalse(ExternalSubtitleIds.isExternal("1:not_external_0"))
assertNull(ExternalSubtitleIds.indexOf("1:2"))
}
@Test
fun malformedTagIsExternalWithoutAnIndex() {
// Still a side-loaded track, but no usable URI lookup.
assertTrue(ExternalSubtitleIds.isExternal("1:external_x"))
assertNull(ExternalSubtitleIds.indexOf("1:external_x"))
}
@Test
fun writtenIdRoundTrips() {
for (index in 0..3) {
val id = ExternalSubtitleIds.idFor(index)
assertEquals(index, ExternalSubtitleIds.indexOf(id))
assertEquals(index, ExternalSubtitleIds.indexOf("1:$id"))
assertEquals(index, ExternalSubtitleIds.indexOf("0:1:$id"))
}
}
}
+31
View File
@@ -1,6 +1,7 @@
import '../media/media_backend.dart';
import '../models/plex/plex_home_user.dart';
import '../services/plex_auth_service.dart';
import '../utils/json_utils.dart';
import '../utils/url_utils.dart';
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
@@ -232,6 +233,13 @@ class JellyfinConnection extends Connection {
/// match/unmatch, edit metadata) without an extra round-trip.
final bool isAdministrator;
/// The authenticated user's `PrimaryImageTag`, or `null` when they have no
/// profile picture. Jellyfin omits the key entirely in that case, and the
/// tag is `MD5(imagePath + lastModified)` so it changes on every upload —
/// which makes the derived avatar URL self-invalidating. Captured at auth
/// time and refreshed by [JellyfinClient.checkHealth].
final String? primaryImageTag;
JellyfinConnection({
required this.id,
required String baseUrl,
@@ -243,6 +251,7 @@ class JellyfinConnection extends Connection {
required this.accessToken,
required this.deviceId,
this.isAdministrator = false,
this.primaryImageTag,
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
@@ -298,6 +307,12 @@ class JellyfinConnection extends Connection {
String? accessToken,
String? deviceId,
bool? isAdministrator,
String? primaryImageTag,
/// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the
/// user DTO, so a refresh must be able to null the cached value — a bare
/// `primaryImageTag: null` is indistinguishable from "unchanged".
bool clearPrimaryImageTag = false,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
@@ -314,6 +329,7 @@ class JellyfinConnection extends Connection {
accessToken: accessToken ?? this.accessToken,
deviceId: deviceId ?? this.deviceId,
isAdministrator: isAdministrator ?? this.isAdministrator,
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
@@ -332,6 +348,7 @@ class JellyfinConnection extends Connection {
'accessToken': accessToken,
'deviceId': deviceId,
'isAdministrator': isAdministrator,
'primaryImageTag': primaryImageTag,
};
}
@@ -359,9 +376,23 @@ class JellyfinConnection extends Connection {
accessToken: json['accessToken'] as String? ?? '',
deviceId: json['deviceId'] as String? ?? '',
isAdministrator: json['isAdministrator'] as bool? ?? false,
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
}
/// Tolerant read of a Jellyfin user DTO's `PrimaryImageTag`.
///
/// The tag is decorative — a fork or a drifted scalar type must never brick
/// sign-in — so this coerces through [readStringField] rather than casting,
/// and collapses absent/blank to `null` ("no picture").
static String? readPrimaryImageTag(Map<String, Object?> userDto) =>
normalizePrimaryImageTag(readStringField(userDto, 'PrimaryImageTag'));
static String? normalizePrimaryImageTag(Object? raw) {
final tag = raw?.toString().trim();
return tag == null || tag.isEmpty ? null : tag;
}
}
+12 -3
View File
@@ -42,14 +42,22 @@ class ConnectionRegistry {
/// Insert or replace [connection]. If this is the first stored connection
/// it is automatically marked default; re-upserting an existing row keeps
/// the row's current `isDefault` (so token/metadata refreshes don't clear
/// the default flag).
/// the row's current `isDefault` and `createdAt` (so token/metadata
/// refreshes don't clear the default flag or restamp creation order).
///
/// Creation order is behaviour, not bookkeeping: it decides which
/// connection lends a profile its picture, and `remove` promotes the oldest
/// remaining row to default. Re-authenticating rebuilds the model with
/// `DateTime.now()` and reuses the same stable id, so without this the
/// originally-first connection would jump to last on every re-sign-in.
Future<void> upsert(Connection connection) async {
await _db.runIdentityMutation(() async {
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
final bool isDefault;
final int createdAt;
if (existing != null) {
isDefault = existing.isDefault;
createdAt = existing.createdAt;
} else {
final any =
await (_db.selectOnly(_db.connections)
@@ -57,6 +65,7 @@ class ConnectionRegistry {
..limit(1))
.getSingleOrNull();
isDefault = any == null;
createdAt = connection.createdAt.millisecondsSinceEpoch;
}
final protectedConfig = await CredentialVault.protectConnectionConfig(
connection.kind.id,
@@ -68,7 +77,7 @@ class ConnectionRegistry {
displayName: Value(connection.displayName),
configJson: Value(jsonEncode(protectedConfig)),
isDefault: Value(isDefault),
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
createdAt: Value(createdAt),
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.connections).insertOnConflictUpdate(row);
+17
View File
@@ -123,6 +123,23 @@ KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
return KeyEventResult.handled;
}
/// Whether the primary focus currently belongs to an active text editor.
///
/// Ancestor key handlers use this to stay off keys a focused field owns.
/// Flutter dispatches a key event from the focused node upwards, and the
/// editing shortcuts that turn Backspace into a deletion live in
/// [DefaultTextEditingShortcuts] at the very top of the app — *above* any
/// screen. An ancestor that claims Backspace as "back" therefore both steals
/// the navigation and stops the character from ever being deleted (#1741).
///
/// [EditableText] builds its [Focus] internally, so the focused node's context
/// resolves to the owning [EditableTextState].
bool isTextEditingFocused() {
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return false;
return context.findAncestorStateOfType<EditableTextState>() != null;
}
/// Expands a UTF-16 [range] to whole extended grapheme clusters in [text].
///
/// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Yüklənir...",
"fullscreen": "Tam ekran",
"exitFullscreen": "Tam ekrandan çıx",
"pressBackAgainToExit": "Çıxmaq üçün geri düyməsinə bir daha basın"
"pressBackAgainToExit": "Çıxmaq üçün geri düyməsinə bir daha basın",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Lisenziyalar",
@@ -585,6 +598,7 @@
"pauseButton": "Fasilə",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Oynatma idarəetmələrini göstər",
"hidePlaybackControls": "Oynatma idarəetmələrini gizlət",
"seekBackwardButton": "${seconds} saniyə geri sar",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Зареждане...",
"fullscreen": "На цял екран",
"exitFullscreen": "Изход от цял екран",
"pressBackAgainToExit": "Натиснете Назад отново, за да излезете"
"pressBackAgainToExit": "Натиснете Назад отново, за да излезете",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Лицензи",
@@ -585,6 +598,7 @@
"pauseButton": "Пауза",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Покажи контролите за възпроизвеждане",
"hidePlaybackControls": "Скрий контролите за възпроизвеждане",
"seekBackwardButton": "Превърти назад ${seconds} секунди",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Indlæser...",
"fullscreen": "Fuldskærm",
"exitFullscreen": "Forlad fuldskærm",
"pressBackAgainToExit": "Tryk på tilbage igen for at afslutte"
"pressBackAgainToExit": "Tryk på tilbage igen for at afslutte",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licenser",
@@ -585,6 +598,7 @@
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Vis afspilningsknapper",
"hidePlaybackControls": "Skjul afspilningsknapper",
"seekBackwardButton": "Spol ${seconds} sekunder tilbage",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Wird geladen …",
"fullscreen": "Vollbild",
"exitFullscreen": "Vollbild verlassen",
"pressBackAgainToExit": "Zum Beenden erneut Zurück drücken"
"pressBackAgainToExit": "Zum Beenden erneut Zurück drücken",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Lizenzen",
@@ -585,6 +598,7 @@
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Wiedergabesteuerung anzeigen",
"hidePlaybackControls": "Wiedergabesteuerung ausblenden",
"seekBackwardButton": "${seconds} Sekunden zurück",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+29 -25
View File
@@ -64,7 +64,20 @@
"loading": "Loading...",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit fullscreen",
"pressBackAgainToExit": "Press back again to exit"
"pressBackAgainToExit": "Press back again to exit",
"ratingSource": {
"critic": "Critics",
"audience": "Audience",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes critics",
"rottenTomatoesAudience": "Rotten Tomatoes audience"
}
},
"screens": {
"licenses": "Licenses",
@@ -585,6 +598,7 @@
"pauseButton": "Pause",
"playbackPaused": "Paused",
"playbackResumed": "Playing",
"loadingVideo": "Loading video",
"showPlaybackControls": "Show playback controls",
"hidePlaybackControls": "Hide playback controls",
"seekBackwardButton": "Seek backward ${seconds} seconds",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "Plezy could not start",
"failedBody": "Something went wrong during startup. The details below identify what failed.",
"failedBodyRepairable": "Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.",
"phaseLabel": "Step",
"showDetails": "Show details",
"hideDetails": "Hide details",
@@ -984,7 +999,9 @@
"repairTitle": "Repair stored data?",
"repairBodyCommon": "Plezy's settings file is damaged and cannot be read. Repairing resets every setting to its default.",
"repairBodyOneCredential": "One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.",
"repairBodySessionsAtRisk": "Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.",
"repairBodySignInsKept": "Your servers and profiles should stay signed in.",
"repairBodySignInsLost": "The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.",
"repairBodySessionsUncertain": "Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.",
"repairConfirm": "Repair",
"repairSucceeded": "Storage repaired",
"repairNeedsRestart": "Storage repaired — restart required",
@@ -1134,18 +1151,16 @@
"creator": "Creator",
"composer": "Composer"
},
"ratingSource": {
"critic": "Critics",
"audience": "Audience",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes critics",
"rottenTomatoesAudience": "Rotten Tomatoes audience"
"relation": {
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Side story",
"spinOff": "Spin-off",
"alternativeVersion": "Alternative version",
"summary": "Summary",
"parentStory": "Parent story",
"adaptation": "Adaptation",
"other": "Related"
},
"broadcast": "Airs ${day} at ${time}",
"broadcastWithZone": "Airs ${day} at ${time} ${timezone}",
@@ -1186,17 +1201,6 @@
"totalResults": {
"one": "${n} result",
"other": "${n} results"
},
"relation": {
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Side story",
"spinOff": "Spin-off",
"alternativeVersion": "Alternative version",
"summary": "Summary",
"parentStory": "Parent story",
"adaptation": "Adaptation",
"other": "Related"
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Cargando...",
"fullscreen": "Pantalla completa",
"exitFullscreen": "Salir de pantalla completa",
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir"
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licencias",
@@ -585,6 +598,7 @@
"pauseButton": "Pausar",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Mostrar controles de reproducción",
"hidePlaybackControls": "Ocultar controles de reproducción",
"seekBackwardButton": "Retroceder ${seconds} segundos",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Chargement...",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter le plein écran",
"pressBackAgainToExit": "Appuyez à nouveau sur retour pour quitter"
"pressBackAgainToExit": "Appuyez à nouveau sur retour pour quitter",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licences",
@@ -585,6 +598,7 @@
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Afficher les commandes de lecture",
"hidePlaybackControls": "Masquer les commandes de lecture",
"seekBackwardButton": "Reculer de ${seconds} secondes",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Betöltés...",
"fullscreen": "Teljes képernyő",
"exitFullscreen": "Kilépés a teljes képernyőből",
"pressBackAgainToExit": "A kilépéshez nyomd meg újra a Vissza gombot"
"pressBackAgainToExit": "A kilépéshez nyomd meg újra a Vissza gombot",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licencek",
@@ -585,6 +598,7 @@
"pauseButton": "Szünet",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Lejátszásvezérlők megjelenítése",
"hidePlaybackControls": "Lejátszásvezérlők elrejtése",
"seekBackwardButton": "Tekerés hátra ${seconds} másodperccel",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Caricamento...",
"fullscreen": "Schermo intero",
"exitFullscreen": "Esci dalla modalità a schermo intero",
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire"
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licenze",
@@ -585,6 +598,7 @@
"pauseButton": "Pausa",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Mostra i controlli di riproduzione",
"hidePlaybackControls": "Nascondi i controlli di riproduzione",
"seekBackwardButton": "Riavvolgi di ${seconds} secondi",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "読み込み中…",
"fullscreen": "フルスクリーン",
"exitFullscreen": "フルスクリーンを終了",
"pressBackAgainToExit": "もう一度押すと終了します"
"pressBackAgainToExit": "もう一度押すと終了します",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "ライセンス",
@@ -585,6 +598,7 @@
"pauseButton": "一時停止",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "再生コントロールを表示",
"hidePlaybackControls": "再生コントロールを非表示",
"seekBackwardButton": "${seconds}秒戻る",
@@ -973,6 +987,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -983,10 +998,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1129,18 +1148,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1179,17 +1196,6 @@
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Жүктелуде...",
"fullscreen": "Толық экран",
"exitFullscreen": "Толық экраннан шығу",
"pressBackAgainToExit": "Шығу үшін артқа түймесін қайтадан басыңыз"
"pressBackAgainToExit": "Шығу үшін артқа түймесін қайтадан басыңыз",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Лицензиялар",
@@ -585,6 +598,7 @@
"pauseButton": "Кідірту",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Басқару элементтерін көрсету",
"hidePlaybackControls": "Басқару элементтерін жасыру",
"seekBackwardButton": "${seconds} секунд артқа айналдыру",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "로딩 중...",
"fullscreen": "전체 화면",
"exitFullscreen": "전체 화면 종료",
"pressBackAgainToExit": "한 번 더 누르면 종료됩니다"
"pressBackAgainToExit": "한 번 더 누르면 종료됩니다",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "라이선스",
@@ -585,6 +598,7 @@
"pauseButton": "일시정지",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "재생 컨트롤 표시",
"hidePlaybackControls": "재생 컨트롤 숨기기",
"seekBackwardButton": "${seconds}초 뒤로 이동",
@@ -973,6 +987,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -983,10 +998,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1129,18 +1148,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1179,17 +1196,6 @@
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Laster...",
"fullscreen": "Fullskjerm",
"exitFullscreen": "Avslutt fullskjerm",
"pressBackAgainToExit": "Trykk på Tilbake en gang til for å avslutte"
"pressBackAgainToExit": "Trykk på Tilbake en gang til for å avslutte",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Lisenser",
@@ -585,6 +598,7 @@
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Vis avspillingskontroller",
"hidePlaybackControls": "Skjul avspillingskontroller",
"seekBackwardButton": "Spol tilbake ${seconds} sekunder",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Laden...",
"fullscreen": "Volledig scherm",
"exitFullscreen": "Volledig scherm verlaten",
"pressBackAgainToExit": "Druk nogmaals op terug om af te sluiten"
"pressBackAgainToExit": "Druk nogmaals op terug om af te sluiten",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licenties",
@@ -585,6 +598,7 @@
"pauseButton": "Pauzeren",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Afspeelbediening tonen",
"hidePlaybackControls": "Afspeelbediening verbergen",
"seekBackwardButton": "${seconds} seconden terugspoelen",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Ładowanie...",
"fullscreen": "Pełny ekran",
"exitFullscreen": "Wyjdź z pełnego ekranu",
"pressBackAgainToExit": "Naciśnij ponownie przycisk Wstecz, aby wyjść"
"pressBackAgainToExit": "Naciśnij ponownie przycisk Wstecz, aby wyjść",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licencje",
@@ -585,6 +598,7 @@
"pauseButton": "Pauza",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Pokaż elementy sterujące odtwarzaniem",
"hidePlaybackControls": "Ukryj elementy sterujące odtwarzaniem",
"seekBackwardButton": "Przewiń do tyłu o ${seconds} sekund",
@@ -976,6 +990,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -986,10 +1001,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1138,18 +1157,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1194,17 +1211,6 @@
"few": "",
"many": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Carregando...",
"fullscreen": "Tela cheia",
"exitFullscreen": "Sair da tela cheia",
"pressBackAgainToExit": "Pressione voltar novamente para sair"
"pressBackAgainToExit": "Pressione voltar novamente para sair",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licenças",
@@ -585,6 +598,7 @@
"pauseButton": "Pausar",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Mostrar controles de reprodução",
"hidePlaybackControls": "Ocultar controles de reprodução",
"seekBackwardButton": "Retroceder ${seconds} segundos",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Загрузка...",
"fullscreen": "Полноэкранный режим",
"exitFullscreen": "Выйти из полноэкранного режима",
"pressBackAgainToExit": "Нажмите ещё раз для выхода"
"pressBackAgainToExit": "Нажмите ещё раз для выхода",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Лицензии",
@@ -585,6 +598,7 @@
"pauseButton": "Пауза",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Показать элементы управления воспроизведением",
"hidePlaybackControls": "Скрыть элементы управления воспроизведением",
"seekBackwardButton": "Перемотать назад на ${seconds} с",
@@ -976,6 +990,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -986,10 +1001,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1138,18 +1157,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1194,17 +1211,6 @@
"few": "",
"many": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 32942 (1497 per locale)
/// Strings: 32946 (1497 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+111 -95
View File
@@ -300,6 +300,8 @@ class Translations$common$en {
/// en: 'Press back again to exit'
String get pressBackAgainToExit => 'Press back again to exit';
late final Translations$common$ratingSource$en ratingSource = Translations$common$ratingSource$en.internal(_root);
}
// Path: screens
@@ -1818,6 +1820,9 @@ class Translations$videoControls$en {
/// en: 'Playing'
String get playbackResumed => 'Playing';
/// en: 'Loading video'
String get loadingVideo => 'Loading video';
/// en: 'Show playback controls'
String get showPlaybackControls => 'Show playback controls';
@@ -2851,6 +2856,9 @@ class Translations$startup$en {
/// en: 'Something went wrong during startup. The details below identify what failed.'
String get failedBody => 'Something went wrong during startup. The details below identify what failed.';
/// en: 'Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.'
String get failedBodyRepairable => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.';
/// en: 'Step'
String get phaseLabel => 'Step';
@@ -2881,8 +2889,14 @@ class Translations$startup$en {
/// en: 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.'
String get repairBodyOneCredential => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.';
/// en: 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.'
String get repairBodySessionsAtRisk => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.';
/// en: 'Your servers and profiles should stay signed in.'
String get repairBodySignInsKept => 'Your servers and profiles should stay signed in.';
/// en: 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.'
String get repairBodySignInsLost => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.';
/// en: 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.'
String get repairBodySessionsUncertain => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.';
/// en: 'Repair'
String get repairConfirm => 'Repair';
@@ -3040,7 +3054,7 @@ class Translations$explore$en {
late final Translations$explore$format$en format = Translations$explore$format$en.internal(_root);
late final Translations$explore$sourceMaterial$en sourceMaterial = Translations$explore$sourceMaterial$en.internal(_root);
late final Translations$explore$creditRole$en creditRole = Translations$explore$creditRole$en.internal(_root);
late final Translations$explore$ratingSource$en ratingSource = Translations$explore$ratingSource$en.internal(_root);
late final Translations$explore$relation$en relation = Translations$explore$relation$en.internal(_root);
/// en: 'Airs ${day} at ${time}'
String broadcast({required Object day, required Object time}) => 'Airs ${day} at ${time}';
@@ -3055,8 +3069,6 @@ class Translations$explore$en {
one: '${n} result',
other: '${n} results',
);
late final Translations$explore$relation$en relation = Translations$explore$relation$en.internal(_root);
}
// Path: liveTv
@@ -4820,6 +4832,48 @@ class Translations$addServer$en {
String get borrowFromAnotherProfileSubtitle => 'Reuse another profile\'s connection. PIN-protected profiles require a PIN.';
}
// Path: common.ratingSource
class Translations$common$ratingSource$en {
Translations$common$ratingSource$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Critics'
String get critic => 'Critics';
/// en: 'Audience'
String get audience => 'Audience';
/// en: 'IMDb'
String get imdb => 'IMDb';
/// en: 'TMDB'
String get tmdb => 'TMDB';
/// en: 'Rotten Tomatoes'
String get rottenTomatoes => 'Rotten Tomatoes';
/// en: 'Simkl'
String get simkl => 'Simkl';
/// en: 'MyAnimeList'
String get mal => 'MyAnimeList';
/// en: 'AniList'
String get anilist => 'AniList';
/// en: 'Trakt'
String get trakt => 'Trakt';
/// en: 'Rotten Tomatoes critics'
String get rottenTomatoesCritic => 'Rotten Tomatoes critics';
/// en: 'Rotten Tomatoes audience'
String get rottenTomatoesAudience => 'Rotten Tomatoes audience';
}
// Path: hotkeys.actions
class Translations$hotkeys$actions$en {
Translations$hotkeys$actions$en.internal(this._root);
@@ -5414,46 +5468,40 @@ class Translations$explore$creditRole$en {
String get composer => 'Composer';
}
// Path: explore.ratingSource
class Translations$explore$ratingSource$en {
Translations$explore$ratingSource$en.internal(this._root);
// Path: explore.relation
class Translations$explore$relation$en {
Translations$explore$relation$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Critics'
String get critic => 'Critics';
/// en: 'Prequel'
String get prequel => 'Prequel';
/// en: 'Audience'
String get audience => 'Audience';
/// en: 'Sequel'
String get sequel => 'Sequel';
/// en: 'IMDb'
String get imdb => 'IMDb';
/// en: 'Side story'
String get sideStory => 'Side story';
/// en: 'TMDB'
String get tmdb => 'TMDB';
/// en: 'Spin-off'
String get spinOff => 'Spin-off';
/// en: 'Rotten Tomatoes'
String get rottenTomatoes => 'Rotten Tomatoes';
/// en: 'Alternative version'
String get alternativeVersion => 'Alternative version';
/// en: 'Simkl'
String get simkl => 'Simkl';
/// en: 'Summary'
String get summary => 'Summary';
/// en: 'MyAnimeList'
String get mal => 'MyAnimeList';
/// en: 'Parent story'
String get parentStory => 'Parent story';
/// en: 'AniList'
String get anilist => 'AniList';
/// en: 'Adaptation'
String get adaptation => 'Adaptation';
/// en: 'Trakt'
String get trakt => 'Trakt';
/// en: 'Rotten Tomatoes critics'
String get rottenTomatoesCritic => 'Rotten Tomatoes critics';
/// en: 'Rotten Tomatoes audience'
String get rottenTomatoesAudience => 'Rotten Tomatoes audience';
/// en: 'Related'
String get other => 'Related';
}
// Path: explore.detail
@@ -5555,42 +5603,6 @@ class Translations$explore$detail$en {
String get background => 'Background';
}
// Path: explore.relation
class Translations$explore$relation$en {
Translations$explore$relation$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Prequel'
String get prequel => 'Prequel';
/// en: 'Sequel'
String get sequel => 'Sequel';
/// en: 'Side story'
String get sideStory => 'Side story';
/// en: 'Spin-off'
String get spinOff => 'Spin-off';
/// en: 'Alternative version'
String get alternativeVersion => 'Alternative version';
/// en: 'Summary'
String get summary => 'Summary';
/// en: 'Parent story'
String get parentStory => 'Parent story';
/// en: 'Adaptation'
String get adaptation => 'Adaptation';
/// en: 'Related'
String get other => 'Related';
}
// Path: downloads.backgroundWarning
class Translations$downloads$backgroundWarning$en {
Translations$downloads$backgroundWarning$en.internal(this._root);
@@ -6083,6 +6095,17 @@ extension on Translations {
'common.fullscreen' => 'Fullscreen',
'common.exitFullscreen' => 'Exit fullscreen',
'common.pressBackAgainToExit' => 'Press back again to exit',
'common.ratingSource.critic' => 'Critics',
'common.ratingSource.audience' => 'Audience',
'common.ratingSource.imdb' => 'IMDb',
'common.ratingSource.tmdb' => 'TMDB',
'common.ratingSource.rottenTomatoes' => 'Rotten Tomatoes',
'common.ratingSource.simkl' => 'Simkl',
'common.ratingSource.mal' => 'MyAnimeList',
'common.ratingSource.anilist' => 'AniList',
'common.ratingSource.trakt' => 'Trakt',
'common.ratingSource.rottenTomatoesCritic' => 'Rotten Tomatoes critics',
'common.ratingSource.rottenTomatoesAudience' => 'Rotten Tomatoes audience',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Switch Profile',
'screens.subtitleStyling' => 'Subtitle Styling',
@@ -6523,6 +6546,8 @@ extension on Translations {
'rateSheet.notAvailable' => 'No match found',
'rateSheet.noConnectedServices' => 'Connect a service in Settings to rate there.',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, movie',
_ => null,
} ?? switch (path) {
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV show',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
'accessibility.mediaCardSeason' => ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}',
@@ -6534,8 +6559,6 @@ extension on Translations {
'accessibility.increase' => 'Increase',
'accessibility.decreaseValue' => ({required Object label}) => 'Decrease ${label}',
'accessibility.increaseValue' => ({required Object label}) => 'Increase ${label}',
_ => null,
} ?? switch (path) {
'accessibility.hue' => 'Hue',
'accessibility.saturation' => 'Saturation',
'accessibility.brightness' => 'Brightness',
@@ -6579,6 +6602,7 @@ extension on Translations {
'videoControls.pauseButton' => 'Pause',
'videoControls.playbackPaused' => 'Paused',
'videoControls.playbackResumed' => 'Playing',
'videoControls.loadingVideo' => 'Loading video',
'videoControls.showPlaybackControls' => 'Show playback controls',
'videoControls.hidePlaybackControls' => 'Hide playback controls',
'videoControls.seekBackwardButton' => ({required Object seconds}) => 'Seek backward ${seconds} seconds',
@@ -6926,6 +6950,7 @@ extension on Translations {
'logs.uploadLogs' => 'Upload Logs',
'startup.failedTitle' => 'Plezy could not start',
'startup.failedBody' => 'Something went wrong during startup. The details below identify what failed.',
'startup.failedBodyRepairable' => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.',
'startup.phaseLabel' => 'Step',
'startup.showDetails' => 'Show details',
'startup.hideDetails' => 'Hide details',
@@ -6936,7 +6961,9 @@ extension on Translations {
'startup.repairTitle' => 'Repair stored data?',
'startup.repairBodyCommon' => 'Plezy\'s settings file is damaged and cannot be read. Repairing resets every setting to its default.',
'startup.repairBodyOneCredential' => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.',
'startup.repairBodySessionsAtRisk' => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.',
'startup.repairBodySignInsKept' => 'Your servers and profiles should stay signed in.',
'startup.repairBodySignInsLost' => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.',
'startup.repairBodySessionsUncertain' => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.',
'startup.repairConfirm' => 'Repair',
'startup.repairSucceeded' => 'Storage repaired',
'startup.repairNeedsRestart' => 'Storage repaired — restart required',
@@ -7033,6 +7060,8 @@ extension on Translations {
'explore.stats.dropped' => ({required Object n}) => '${n} dropped',
'explore.season.winter' => 'Winter',
'explore.season.spring' => 'Spring',
_ => null,
} ?? switch (path) {
'explore.season.summer' => 'Summer',
'explore.season.fall' => 'Fall',
'explore.season.withYear' => ({required Object season, required Object year}) => '${season} ${year}',
@@ -7048,8 +7077,6 @@ extension on Translations {
'explore.sourceMaterial.manga' => 'Manga',
'explore.sourceMaterial.lightNovel' => 'Light novel',
'explore.sourceMaterial.novel' => 'Novel',
_ => null,
} ?? switch (path) {
'explore.sourceMaterial.visualNovel' => 'Visual novel',
'explore.sourceMaterial.game' => 'Game',
'explore.sourceMaterial.webComic' => 'Web comic',
@@ -7060,17 +7087,15 @@ extension on Translations {
'explore.creditRole.producer' => 'Producer',
'explore.creditRole.creator' => 'Creator',
'explore.creditRole.composer' => 'Composer',
'explore.ratingSource.critic' => 'Critics',
'explore.ratingSource.audience' => 'Audience',
'explore.ratingSource.imdb' => 'IMDb',
'explore.ratingSource.tmdb' => 'TMDB',
'explore.ratingSource.rottenTomatoes' => 'Rotten Tomatoes',
'explore.ratingSource.simkl' => 'Simkl',
'explore.ratingSource.mal' => 'MyAnimeList',
'explore.ratingSource.anilist' => 'AniList',
'explore.ratingSource.trakt' => 'Trakt',
'explore.ratingSource.rottenTomatoesCritic' => 'Rotten Tomatoes critics',
'explore.ratingSource.rottenTomatoesAudience' => 'Rotten Tomatoes audience',
'explore.relation.prequel' => 'Prequel',
'explore.relation.sequel' => 'Sequel',
'explore.relation.sideStory' => 'Side story',
'explore.relation.spinOff' => 'Spin-off',
'explore.relation.alternativeVersion' => 'Alternative version',
'explore.relation.summary' => 'Summary',
'explore.relation.parentStory' => 'Parent story',
'explore.relation.adaptation' => 'Adaptation',
'explore.relation.other' => 'Related',
'explore.broadcast' => ({required Object day, required Object time}) => 'Airs ${day} at ${time}',
'explore.broadcastWithZone' => ({required Object day, required Object time, required Object timezone}) => 'Airs ${day} at ${time} ${timezone}',
'explore.detail.originalTitle' => 'Original title',
@@ -7103,15 +7128,6 @@ extension on Translations {
'explore.detail.relatedTitles' => 'Related titles',
'explore.detail.background' => 'Background',
'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ),
'explore.relation.prequel' => 'Prequel',
'explore.relation.sequel' => 'Sequel',
'explore.relation.sideStory' => 'Side story',
'explore.relation.spinOff' => 'Spin-off',
'explore.relation.alternativeVersion' => 'Alternative version',
'explore.relation.summary' => 'Summary',
'explore.relation.parentStory' => 'Parent story',
'explore.relation.adaptation' => 'Adaptation',
'explore.relation.other' => 'Related',
'liveTv.title' => 'Live TV',
'liveTv.guide' => 'Guide',
'liveTv.noChannels' => 'No channels available',
@@ -7558,12 +7574,12 @@ extension on Translations {
'externalPlayer.playerUrlScheme' => 'URL Scheme',
'externalPlayer.off' => 'Off',
'externalPlayer.launchFailed' => 'Failed to open external player',
_ => null,
} ?? switch (path) {
'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed',
'externalPlayer.playInExternalPlayer' => 'Play in External Player',
'metadataEdit.editMetadata' => 'Edit...',
'metadataEdit.screenTitle' => 'Edit Metadata',
_ => null,
} ?? switch (path) {
'metadataEdit.basicInfo' => 'Basic Info',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Advanced Settings',
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Laddar...",
"fullscreen": "Helskärm",
"exitFullscreen": "Avsluta helskärm",
"pressBackAgainToExit": "Tryck bakåt igen för att avsluta"
"pressBackAgainToExit": "Tryck bakåt igen för att avsluta",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Licenser",
@@ -585,6 +598,7 @@
"pauseButton": "Pausa",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Visa uppspelningskontroller",
"hidePlaybackControls": "Dölj uppspelningskontroller",
"seekBackwardButton": "Spola bakåt ${seconds} sekunder",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Yükleniyor...",
"fullscreen": "Tam Ekran",
"exitFullscreen": "Tam Ekrandan Çık",
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın"
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Lisanslar",
@@ -585,6 +598,7 @@
"pauseButton": "Duraklat",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Oynatma kontrollerini göster",
"hidePlaybackControls": "Oynatma kontrollerini gizle",
"seekBackwardButton": "${seconds} saniye geri sar",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "Yuklanmoqda...",
"fullscreen": "Toʻliq ekran",
"exitFullscreen": "Toʻliq ekrandan chiqish",
"pressBackAgainToExit": "Chiqish uchun orqaga tugmasini yana bir bor bosing"
"pressBackAgainToExit": "Chiqish uchun orqaga tugmasini yana bir bor bosing",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "Litsenziyalar",
@@ -585,6 +598,7 @@
"pauseButton": "Pauza",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "Boshqaruv tugmalarini koʻrsatish",
"hidePlaybackControls": "Boshqaruv tugmalarini yashirish",
"seekBackwardButton": "${seconds} soniya orqaga oʻtkazish",
@@ -974,6 +988,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -984,10 +999,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1132,18 +1151,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1184,17 +1201,6 @@
"totalResults": {
"one": "",
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "載入中…",
"fullscreen": "全螢幕",
"exitFullscreen": "退出全螢幕",
"pressBackAgainToExit": "再按一次返回以退出"
"pressBackAgainToExit": "再按一次返回以退出",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "授權條款",
@@ -585,6 +598,7 @@
"pauseButton": "暫停",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "顯示播放控制項",
"hidePlaybackControls": "隱藏播放控制項",
"seekBackwardButton": "後退 ${seconds} 秒",
@@ -973,6 +987,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -983,10 +998,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1129,18 +1148,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1179,17 +1196,6 @@
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+31 -25
View File
@@ -64,7 +64,20 @@
"loading": "加载中…",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"pressBackAgainToExit": "再按一次返回键退出"
"pressBackAgainToExit": "再按一次返回键退出",
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
}
},
"screens": {
"licenses": "许可证",
@@ -585,6 +598,7 @@
"pauseButton": "暂停",
"playbackPaused": "",
"playbackResumed": "",
"loadingVideo": "",
"showPlaybackControls": "显示播放控制项",
"hidePlaybackControls": "隐藏播放控制项",
"seekBackwardButton": "快退 ${seconds} 秒",
@@ -973,6 +987,7 @@
"startup": {
"failedTitle": "",
"failedBody": "",
"failedBodyRepairable": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
@@ -983,10 +998,14 @@
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairBodySignInsKept": "",
"repairBodySignInsLost": "",
"repairBodySessionsUncertain": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"restartRequiredBody": "",
"quitPlezy": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
@@ -1129,18 +1148,16 @@
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
},
"broadcast": "",
"broadcastWithZone": "",
@@ -1179,17 +1196,6 @@
},
"totalResults": {
"other": ""
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
}
},
"liveTv": {
+51 -12
View File
@@ -85,6 +85,7 @@ import 'utils/android_exit_diagnostics.dart';
import 'utils/storage_failure.dart';
import 'services/base_shared_preferences_service.dart';
import 'services/prefs_recovery.dart';
import 'services/sensitive_prefs.dart';
import 'services/startup_diagnostics.dart';
import 'utils/dialogs.dart';
import 'widgets/dialog_action_button.dart';
@@ -432,16 +433,21 @@ Future<StartupRepairResult> repairStartupStorage(
final unreadableKey = cause is UnreadableSensitivePreferenceException ? cause.key : null;
final reopenSafe = cause is! CorruptPreferenceStoreException || cause.reopenSafe;
// Name the cost before touching anything. A salvaged vault key keeps servers
// and profiles signed in because those tokens are ciphertext in the
// database; tracker and Seerr sessions are plaintext preference entries and
// can still be lost, so neither branch promises more than it can deliver.
// Name the real cost before touching anything, not the usual one. Servers
// and profiles survive a repair only because their tokens are ciphertext in
// the database and the key that decrypts them lives in the store — so a
// store the key cannot be read out of signs the user out of everything. An
// all-zero file is exactly that case (#1732), and telling them their
// sign-ins are safe would be a promise the outcome dialog then contradicts.
final signInsSurvive = unreadableKey != null
? unreadableKey != credentialVaultKeyPref
: (await PrefsRecovery.previewSalvage()).vaultKey != null;
if (!context.mounted) return StartupRepairResult.none;
final confirmed = await showConfirmDialog(
context,
title: t.startup.repairTitle,
message: unreadableKey != null
? '${t.startup.repairBodyOneCredential}\n\n${t.startup.repairBodySessionsAtRisk}'
: '${t.startup.repairBodyCommon}\n\n${t.startup.repairBodySessionsAtRisk}',
message: repairConsentMessage(oneCredential: unreadableKey != null, signInsSurvive: signInsSurvive),
confirmText: t.startup.repairConfirm,
isDestructive: true,
);
@@ -457,11 +463,41 @@ Future<StartupRepairResult> repairStartupStorage(
return result;
}
/// Reports a completed repair, including the credential-bearing backup.
/// The consent dialog's body: what is being repaired, then what it costs.
///
/// Separated from [repairStartupStorage] because the two halves fail
/// differently. Deciding whether the sign-ins survive is I/O against a
/// damaged file; deciding what to *tell* the user about it is a promise, and
/// #1732 is what happens when the promise is written once, optimistically,
/// for a case that does not always hold.
///
/// [signInsSurvive] covers servers and profiles only. Their tokens are
/// ciphertext in the database, so they live or die with the vault key —
/// which is exactly why that one value decides this sentence.
///
/// Tracker and Seerr sessions are plaintext preference entries, salvaged and
/// reseeded one by one and untouched entirely by the single-credential
/// repair. They therefore do not follow the vault key in either direction,
/// and get one cautious sentence in both branches rather than a promise
/// borrowed from a value that does not govern them.
@visibleForTesting
String repairConsentMessage({required bool oneCredential, required bool signInsSurvive}) => [
oneCredential ? t.startup.repairBodyOneCredential : t.startup.repairBodyCommon,
signInsSurvive ? t.startup.repairBodySignInsKept : t.startup.repairBodySignInsLost,
t.startup.repairBodySessionsUncertain,
].join('\n\n');
/// Reports a completed repair, including the retained copy of the damaged
/// store.
///
/// The backup path is shown so the user can find and delete it, never so it
/// can be attached to a report: it holds the credential-vault key, tracker
/// refresh tokens and Seerr cookies in plaintext.
/// can be attached to a report: it can hold the credential-vault key, tracker
/// refresh tokens and Seerr cookies in plaintext. The warning is suppressed
/// only when the quarantined *bytes* prove there is nothing in them — an
/// all-zero file (#1732) — never on the strength of what the salvage managed
/// to recover, which says nothing about what is still in the file. A warning
/// on a file of zeros is the one that teaches users to ignore the warning
/// that matters.
@visibleForTesting
Future<void> showRepairOutcomeDialog(
BuildContext context,
@@ -492,8 +528,10 @@ Future<void> showRepairOutcomeDialog(
Text(t.startup.backupTitle, style: Theme.of(dialogContext).textTheme.titleSmall),
const SizedBox(height: 4),
SelectableText(backupPath, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
const SizedBox(height: 4),
Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)),
if (outcome.backupHoldsCredentials) ...[
const SizedBox(height: 4),
Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)),
],
const SizedBox(height: 8),
DialogActionButton(
label: t.startup.deleteBackup,
@@ -1434,6 +1472,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
registry: context.read<ProfileRegistry>(),
plexHome: context.read<PlexHomeService>(),
connections: context.read<ConnectionRegistry>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
storage: context.read<StorageService>(),
);
unawaited(provider.initialize());
+23 -3
View File
@@ -10,6 +10,7 @@ import 'media_backend.dart';
import 'media_kind.dart';
import 'media_role.dart';
import 'media_version.dart';
import 'media_rating.dart';
part 'media_item.freezed.dart';
part 'media_item.g.dart';
@@ -66,6 +67,7 @@ sealed class MediaItem with _$MediaItem {
int? addedAt,
int? updatedAt,
double? rating,
List<MediaRatingSource>? ratings,
double? userRating,
bool? isFavorite,
List<String>? genres,
@@ -128,6 +130,7 @@ sealed class MediaItem with _$MediaItem {
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
ratings: ratings,
userRating: userRating,
isFavorite: isFavorite,
genres: genres,
@@ -189,6 +192,7 @@ sealed class MediaItem with _$MediaItem {
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
ratings: ratings,
userRating: userRating,
isFavorite: isFavorite,
genres: genres,
@@ -257,11 +261,14 @@ sealed class MediaItem with _$MediaItem {
@JsonKey(fromJson: flexibleInt) int? addedAt,
@JsonKey(fromJson: flexibleInt) int? updatedAt,
@JsonKey(fromJson: flexibleDouble) double? rating,
@JsonKey(fromJson: flexibleDouble) double? audienceRating,
@JsonKey(fromJson: flexibleDouble) double? userRating,
/// Every attributed score the response carried, headline first. Plex
/// listings yield one or two (the `rating`/`audienceRating` pair with
/// their source images); `/library/metadata/{id}` adds the `Rating[]`
/// array, so IMDb and TMDB join Rotten Tomatoes on detail screens.
@JsonKey(fromJson: _mediaItemRatingsFromJson) List<MediaRatingSource>? ratings,
bool? isFavorite,
String? ratingImage,
String? audienceRatingImage,
@JsonKey(fromJson: _mediaItemStringList) List<String>? genres,
@JsonKey(fromJson: _mediaItemStringList) List<String>? directors,
@JsonKey(fromJson: _mediaItemStringList) List<String>? writers,
@@ -335,6 +342,10 @@ sealed class MediaItem with _$MediaItem {
@JsonKey(fromJson: flexibleInt) int? updatedAt,
@JsonKey(fromJson: flexibleDouble) double? rating,
@JsonKey(fromJson: flexibleDouble) double? userRating,
/// `CommunityRating` and the `CriticRating` Tomatometer, in that order.
/// Jellyfin exposes no per-source array, so this is at most two entries.
@JsonKey(fromJson: _mediaItemRatingsFromJson) List<MediaRatingSource>? ratings,
bool? isFavorite,
@JsonKey(fromJson: _mediaItemStringList) List<String>? genres,
@JsonKey(fromJson: _mediaItemStringList) List<String>? directors,
@@ -749,4 +760,13 @@ List<MediaVersion>? _mediaItemVersionsFromJson(Object? raw) {
: null;
}
List<MediaRatingSource>? _mediaItemRatingsFromJson(Object? raw) {
return raw is List
? [
for (final rating in raw)
if (rating is Map<String, Object?>) ?MediaRatingSource.fromJson(rating),
]
: null;
}
Map<String, Object?>? _mediaItemRawFromJson(Object? raw) => raw is Map ? Map<String, Object?>.from(raw) : null;
File diff suppressed because one or more lines are too long
+4 -6
View File
@@ -51,11 +51,9 @@ PlexMediaItem _$PlexMediaItemFromJson(Map<String, dynamic> json) =>
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
rating: flexibleDouble(json['rating']),
audienceRating: flexibleDouble(json['audienceRating']),
userRating: flexibleDouble(json['userRating']),
ratings: _mediaItemRatingsFromJson(json['ratings']),
isFavorite: json['isFavorite'] as bool?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
genres: _mediaItemStringList(json['genres']),
directors: _mediaItemStringList(json['directors']),
writers: _mediaItemStringList(json['writers']),
@@ -123,11 +121,9 @@ Map<String, dynamic> _$PlexMediaItemToJson(PlexMediaItem instance) =>
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'rating': ?instance.rating,
'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating,
'ratings': ?instance.ratings?.map((e) => e.toJson()).toList(),
'isFavorite': ?instance.isFavorite,
'ratingImage': ?instance.ratingImage,
'audienceRatingImage': ?instance.audienceRatingImage,
'genres': ?instance.genres,
'directors': ?instance.directors,
'writers': ?instance.writers,
@@ -200,6 +196,7 @@ JellyfinMediaItem _$JellyfinMediaItemFromJson(Map<String, dynamic> json) =>
updatedAt: flexibleInt(json['updatedAt']),
rating: flexibleDouble(json['rating']),
userRating: flexibleDouble(json['userRating']),
ratings: _mediaItemRatingsFromJson(json['ratings']),
isFavorite: json['isFavorite'] as bool?,
genres: _mediaItemStringList(json['genres']),
directors: _mediaItemStringList(json['directors']),
@@ -262,6 +259,7 @@ Map<String, dynamic> _$JellyfinMediaItemToJson(JellyfinMediaItem instance) =>
'updatedAt': ?instance.updatedAt,
'rating': ?instance.rating,
'userRating': ?instance.userRating,
'ratings': ?instance.ratings?.map((e) => e.toJson()).toList(),
'isFavorite': ?instance.isFavorite,
'genres': ?instance.genres,
'directors': ?instance.directors,
+61
View File
@@ -1,5 +1,6 @@
import 'ids.dart';
import 'media_item.dart';
import 'media_version.dart';
/// Merge freshly fetched metadata with identity and library context already
/// known by the caller. The fetched item owns descriptive fields, while
@@ -12,3 +13,63 @@ MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId f
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
);
}
/// Tallest version height an item exposes, or 0 when the backend reported
/// none.
int _bestResolutionHeight(MediaItem item) {
var best = 0;
for (final version in item.mediaVersions ?? const <MediaVersion>[]) {
final height = version.resolutionHeight;
if (height != null && height > best) best = height;
}
return best;
}
/// Order the library copies of one title best-first: highest resolution, then
/// library title, then server name, then global key.
///
/// Total and derived purely from the items, so a chooser can re-sort after
/// merging a later resolution pass without its rows jumping around.
int compareLibraryCopies(MediaItem a, MediaItem b) {
final byResolution = _bestResolutionHeight(b).compareTo(_bestResolutionHeight(a));
if (byResolution != 0) return byResolution;
final byLibrary = (a.libraryTitle ?? '').compareTo(b.libraryTitle ?? '');
if (byLibrary != 0) return byLibrary;
final byServer = (a.serverName ?? '').compareTo(b.serverName ?? '');
if (byServer != 0) return byServer;
return a.globalKey.compareTo(b.globalKey);
}
/// Fold a re-resolved copy into the one already on screen.
///
/// The addition is fresher, but a degraded pass must not erase context. The
/// Jellyfin library stamp is a best-effort `/Items/{id}/Ancestors` call that
/// hands back an unstamped item when it fails, and a copy that lost its
/// library title is indistinguishable from its sibling in the same server's
/// other library — exactly the ambiguity the chooser exists to resolve. The
/// version list behind the resolution hint is treated the same way.
MediaItem _mergeCopy(MediaItem existing, MediaItem addition) {
final versions = addition.mediaVersions;
return addition.copyWith(
libraryId: addition.libraryId ?? existing.libraryId,
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
serverName: addition.serverName ?? existing.serverName,
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
);
}
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
///
/// Never removes a copy, and never downgrades one. The cross-server fan-out
/// behind these lists logs and skips per-server failures, so a later pass can
/// legitimately come back short a server, or short the best-effort library
/// stamp of a copy it did return.
List<MediaItem> mergeLibraryCopies(Iterable<MediaItem> current, Iterable<MediaItem> additions) {
final byKey = <String, MediaItem>{for (final item in current) item.globalKey: item};
for (final item in additions) {
final existing = byKey[item.globalKey];
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
}
return byKey.values.toList()..sort(compareLibraryCopies);
}
+9
View File
@@ -30,6 +30,15 @@ enum MediaKind {
bool get isPlayable => isVideo || this == track;
/// Whether this kind maps to one or more files on disk, i.e. the server can
/// answer `MediaServerClient.getFileInfo` for it.
///
/// Deliberately not folded into [isPlayable] even though the member sets
/// coincide today: container kinds (show, season, artist, album, collection,
/// playlist, folder) aggregate leaves and carry no `Media`/`MediaSources` of
/// their own, while a non-playable kind can still be file-backed.
bool get hasFileInfo => this == movie || this == episode || this == track || this == clip;
/// Whether this container kind derives watched state from aggregate leaves.
bool get usesLeafWatchCounts => switch (this) {
show || season || artist || album || collection || playlist || folder => true,
+37
View File
@@ -0,0 +1,37 @@
/// One attributed score for a media item.
///
/// Backends routinely return several: Plex's `/library/metadata/{id}` carries
/// an IMDb score next to both Rotten Tomatoes panels and TMDB, Simkl carries
/// Simkl/IMDb/MAL side by side, Jellyfin carries a community score next to a
/// Tomatometer. `MediaItem.rating` keeps the backend's headline number; this
/// list keeps every score with attribution so the UI can badge and label them.
///
/// Serialization is hand-written rather than `json_serializable` because
/// catalog objects round-trip through `MediaItem.raw` maps rather than a
/// generated codec.
library;
class MediaRatingSource {
/// Stable, lowercase source key: `imdb`, `tmdb`, `rottenTomatoesCritic`,
/// `rottenTomatoesAudience`, `simkl`, `mal`, `anilist`, `trakt`, `critic`,
/// `audience`. Rendered through a label map, never raw.
final String source;
/// Normalized to 0-10 by the mapper, matching `MediaItem.rating`. Percentage
/// sources (Rotten Tomatoes, TMDB) are rendered back as `value * 10`%.
final double value;
/// How many users the score is based on, when the backend says.
final int? votes;
const MediaRatingSource({required this.source, required this.value, this.votes});
Map<String, Object?> toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes};
static MediaRatingSource? fromJson(Map<String, Object?> json) {
final source = json['source'] as String?;
final value = (json['value'] as num?)?.toDouble();
if (source == null || value == null) return null;
return MediaRatingSource(source: source, value: value, votes: json['votes'] as int?);
}
}
+94 -22
View File
@@ -167,13 +167,24 @@ abstract class MediaServerClient {
Future<MediaItem?> fetchItem(String id);
/// Fetch a single item *and* its on-deck episode (the next unwatched /
/// in-progress episode) in one round-trip when the backend supports it.
/// The item follows [fetchItem]'s error contract: an online HTTP 404 returns
/// both nullable fields as `null`, while every other HTTP status throws.
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
/// leaving callers to fetch on-deck separately if they need it.
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id);
/// in-progress episode). The item follows [fetchItem]'s error contract: an
/// online HTTP 404 returns both nullable fields as `null`, while every other
/// HTTP status throws.
///
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`. Jellyfin
/// has no equivalent endpoint and needs a second request for on-deck, so it
/// would otherwise hold the item behind a round trip the detail screen does
/// not need in order to paint.
///
/// [onItemReady] exists for exactly that case: implementations invoke it as
/// soon as the item is known, *if* that is strictly before the on-deck
/// lookup finishes. Backends that return both together never invoke it, and
/// neither does a null item. Callers must therefore treat it as an optional
/// early paint and still handle the returned record.
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
String id, {
void Function(MediaItem item)? onItemReady,
});
/// Direct children of [parentId] — episodes of a season, seasons of a
/// show, tracks of an album, items of a collection.
@@ -272,7 +283,18 @@ abstract class MediaServerClient {
/// candidate budget; a backend may supplement omitted media categories and
/// return more candidates for cross-server ranking. [abort] cancels every
/// backend request owned by this search pass.
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});
///
/// [excludedLibraryIds] names server-local libraries the user has hidden. A
/// backend whose search rows carry a library id may ignore it, because the
/// caller drops those rows by id. A backend whose rows cannot be attributed
/// to a library MUST scope the request server-side instead — the caller has
/// nothing to filter on.
Future<List<MediaItem>> searchItems(
String query, {
int limit = 100,
AbortController? abort,
Set<String> excludedLibraryIds = const {},
});
/// Items the user has started but not finished. Plex calls this "On Deck"
/// internally; the neutral name matches the Continue Watching UI surface.
@@ -504,7 +526,7 @@ abstract class MediaServerClient {
/// has no external mapping for the item.
Future<ExternalIds> fetchExternalIds(String itemId);
/// Reverse lookup: find a library movie/show matching any of [ids].
/// Reverse lookup: find every library movie/show matching any of [ids].
///
/// Neither backend can filter by external id — Plex's `guid=` matches only
/// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped
@@ -512,7 +534,16 @@ abstract class MediaServerClient {
/// title and verify candidates against their exact external ids. Title
/// alone never produces a match.
///
/// [titles] are tried in order until one yields an id-verified candidate;
/// One title can own several library items: a server with a 4K section and
/// an HD section holds two rating keys for the same movie, and a library
/// still on a legacy agent carries a different primary guid than its modern
/// sibling. Implementations MUST return every id-verified copy rather than
/// the first, and MUST NOT truncate — external-id verification is the only
/// bound, so a long list means the user genuinely owns that many copies and
/// the caller (the Explore "In these libraries" chooser) exists to show
/// them. Ordering is the implementation's, and callers re-sort.
///
/// [titles] are tried in order until one yields id-verified candidates;
/// pass the entry's own title first and broader forms after (see
/// `titleMatchCandidates`). A sequel entry's own title never matches its
/// parent show, which is why more than one is needed. [year] applies a ±1
@@ -520,22 +551,21 @@ abstract class MediaServerClient {
/// season's, not the show's.
///
/// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller
/// already holds, which the local server *can* filter on exactly, skipping
/// the title search. It is never resolved over the network — only Plex
/// Discover catalog items carry one, in their own rating key. Other backends
/// ignore it.
/// already holds, which the local server *can* filter on exactly. It is
/// never resolved over the network — only Plex Discover catalog items carry
/// one, in their own rating key. Other backends ignore it.
///
/// [season] gates the result: when the entry maps to season 2+ of a longer
/// series, a match is only returned if the server actually has that season.
/// [season] gates the results: when the entry maps to season 2+ of a longer
/// series, a candidate is only kept if the server actually has that season.
/// Implementations MUST gate only on [ExternalSeasonRef.agreedSeason] — the
/// provider a library numbers its seasons by is a server-side setting no
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
/// on a guess.
///
/// Returns null when this server has no match or [kind] is not movie/show.
/// Used to match external catalog items (Explore tab) back to the user's
/// libraries.
Future<MediaItem?> findByExternalIds(
/// Returns an empty list when this server has no match or [kind] is not
/// movie/show. Used to match external catalog items (Explore tab) back to
/// the user's libraries.
Future<List<MediaItem>> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -716,20 +746,41 @@ extension MediaServerClientScope on MediaServerClient {
_ => serverId,
};
/// Mark [item] watched because it crossed [watchedThreshold] during playback,
/// when a playback-stopped report is/was also sent for the same playback.
/// Mark [item] watched because playback crossed [watchedThreshold], for paths
/// where the backend cannot mark it from the playback reports themselves:
/// queued offline replay, external players, Plex same-file siblings, and
/// in-player sessions whose crossing the backend never observed.
///
/// Backends that mark played from the stop report
/// ([marksWatchedOnPlaybackStopped]) skip the server call — issuing
/// [markWatched] too would double-scrobble via the Jellyfin Trakt plugin
/// (#1287). The single local event emitted here keeps the UI and Plezy's
/// own Trakt sync (which key on `watched` events, not progress) in sync;
/// the stop report syncs the server.
///
/// In-player sessions that *did* give the backend an observable crossing use
/// [notifyWatchedFromPlaybackSession] instead.
Future<void> markWatchedFromPlaybackStop(MediaItem item) async {
if (!marksWatchedOnPlaybackStopped) {
await markWatched(item);
}
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
}
/// Emit the local watched event for [item] without touching the server.
///
/// Used when a live playback-reporting session has already given the backend
/// everything it needs to mark the item itself — a report below
/// [watchedThreshold] followed by one at or above it. Both backends act on
/// that crossing: Jellyfin through `/Sessions/Playing/Stopped`
/// (`MaxResumePct`), Plex through `/:/timeline` past
/// `LibraryVideoPlayedThreshold`. Adding an explicit [markWatched] on top
/// records the same watch twice — a second Trakt-plugin scrobble on Jellyfin
/// (#1287), a second Play History row and an inflated `viewCount` on Plex
/// (#1740).
void notifyWatchedFromPlaybackSession(MediaItem item) {
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
}
}
extension MediaServerClientLiveTv on MediaServerClient {
@@ -752,6 +803,27 @@ abstract interface class SeasonEpisodePagingClient {
});
}
/// Optional capability for clients whose server can answer "may the signed-in
/// user delete *this* item?" per item.
///
/// Jellyfin-only by nature: `BaseItemDto.CanDelete` folds the global
/// `EnableContentDeletion` grant, the per-library
/// `EnableContentDeletionFromFolders` grant, and item state (virtual/missing
/// files, in-progress recordings) into one server-computed boolean — none of
/// which a client can reproduce. Plex exposes no per-item delete permission,
/// so it deliberately does not implement this and callers keep using their
/// account-level owner/admin gate for it.
abstract interface class MediaDeletionPermissionClient {
/// `true`/`false` as reported by the server for [item], or `null` when the
/// server did not answer (item not visible to this user, unexpected shape).
///
/// Never served from cache: the answer changes server-side with no
/// client-visible event, and a stale `true` puts a destructive action back in
/// front of a user who lost the grant. Callers must fail closed on `null`
/// and on throw.
Future<bool?> fetchDeletePermission(MediaItem item);
}
/// Cache-aware fetch helpers shared by both backends so the offline-first /
/// network-then-cache pattern lives in one place.
///
+19
View File
@@ -68,6 +68,25 @@ class MediaVersion {
/// them when metadata is fetched with `checkFiles=1`.
bool get isPlayable => parts.isEmpty || parts.any((part) => part.isPlayable);
/// Approximate vertical resolution, for ordering versions best-first.
///
/// Plex reports either a numeric height (`"1080"`) or a named tier
/// (`"sd"`, `"4k"`) and usually both; Jellyfin reports [height] directly.
/// Null when the backend gave neither.
int? get resolutionHeight {
final reported = height;
if (reported != null && reported > 0) return reported;
final named = (videoResolution ?? '').trim().toLowerCase();
return switch (named) {
'' => null,
'sd' => 480,
'hd' => 720,
'4k' => 2160,
'8k' => 4320,
_ => int.tryParse(named),
};
}
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)".
/// When [name] is set, it prefixes the technical label so a user can tell
/// "Director's Cut · 1080p H.264 MKV" apart from "Theatrical Cut · 1080p
+10
View File
@@ -4,6 +4,16 @@ mixin Refreshable {
mixin FullRefreshable {
void fullRefresh();
/// Online-entry variant, used by `main_screen._primeOnlineServices` on cold
/// start and on reconnect-from-offline. Its job is to guarantee the tab
/// loads once servers are up — not to force a refetch.
///
/// Screens that already kick off their own load in `initState` override this
/// to skip while that pass is still in flight; otherwise the prime queues an
/// identical trailing pass and the tab fetches everything twice (#1784).
/// Profile switches go through [fullRefresh] instead, which always refetches.
void primeRefresh() => fullRefresh();
}
mixin FocusableTab {
+4 -2
View File
@@ -1,6 +1,7 @@
import '../../media/media_backend.dart';
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../media/media_rating.dart';
import '../../utils/external_ids.dart';
import 'catalog_cast_member.dart';
import 'catalog_metadata.dart';
@@ -213,7 +214,7 @@ class CatalogItem {
final String? accentColor;
/// Scores beyond [rating], each with its own source label.
final List<CatalogRatingSource>? ratings;
final List<MediaRatingSource>? ratings;
/// Leaderboard positions. A list because providers return several windows
/// at once (all-time and seasonal).
@@ -493,6 +494,7 @@ class CatalogItem {
contentRating: certification,
durationMs: runtimeMinutes == null ? null : Duration(minutes: runtimeMinutes!).inMilliseconds,
rating: rating,
ratings: ratings,
genres: genres,
thumbPath: posterUrl,
artPath: backdropUrl,
@@ -594,7 +596,7 @@ class CatalogItem {
logoUrl: json['logoUrl'] as String?,
bannerUrl: json['bannerUrl'] as String?,
accentColor: json['accentColor'] as String?,
ratings: decodeCatalogList(json['ratings'], CatalogRatingSource.fromJson),
ratings: decodeCatalogList(json['ratings'], MediaRatingSource.fromJson),
ranks: decodeCatalogList(json['ranks'], CatalogRank.fromJson),
audience: _decodeObject(json['audience'], CatalogAudience.fromJson),
broadcast: _decodeObject(json['broadcast'], CatalogBroadcast.fromJson),
-29
View File
@@ -8,35 +8,6 @@
/// round-trip through `MediaItem.raw` inside a single session.
library;
/// One provider score with its own source label.
///
/// Providers routinely return several: Simkl carries Simkl/IMDb/MAL side by
/// side, Plex carries a critic score next to an audience score. The neutral
/// [CatalogItem.rating] keeps the provider's headline number; this list keeps
/// the rest with attribution so the UI can label them.
class CatalogRatingSource {
/// Stable, lowercase source key: `simkl`, `imdb`, `mal`, `anilist`, `tmdb`,
/// `trakt`, `critic`, `audience`. Rendered through a label map, never raw.
final String source;
/// Normalized to 0-10 by the mapper, matching [CatalogItem.rating].
final double value;
/// How many users the score is based on, when the provider says.
final int? votes;
const CatalogRatingSource({required this.source, required this.value, this.votes});
Map<String, Object?> toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes};
static CatalogRatingSource? fromJson(Map<String, Object?> json) {
final source = json['source'] as String?;
final value = (json['value'] as num?)?.toDouble();
if (source == null || value == null) return null;
return CatalogRatingSource(source: source, value: value, votes: json['votes'] as int?);
}
}
/// What a leaderboard position is a position *in*.
///
/// MAL returns one rank per ranking endpoint, AniList returns a `rankings`
@@ -10,8 +10,6 @@ class PlexVideoPlaybackData {
final List<MediaVersion> availableVersions;
final List<MediaMarker> markers;
final int selectedMediaIndex;
final int selectedPartIndex;
@@ -20,7 +18,6 @@ class PlexVideoPlaybackData {
required this.videoUrl,
required this.mediaInfo,
required this.availableVersions,
this.markers = const [],
this.selectedMediaIndex = 0,
this.selectedPartIndex = 0,
});
+41 -10
View File
@@ -28,6 +28,13 @@ class PlayerAndroid extends PlayerBase {
bool get usingMpvFallback => _usingMpvFallback;
/// Subtitles are hidden through the player's visibility toggle. Sticky
/// across media opens, like mpv's global `sub-visibility`, so an episode
/// change cannot put them back on screen.
bool _subtitlesHidden = false;
/// Track that un-hiding restores: whatever was selected when subtitles were
/// hidden, then whatever was selected for the current media while hidden.
String? _hiddenSubtitleTrackId;
@override
@@ -248,14 +255,36 @@ class PlayerAndroid extends PlayerBase {
await invoke('selectAudioTrack', {'trackId': track.id});
}
/// ExoPlayer has no renderer-level subtitle visibility switch, so hiding is
/// implemented as deselection (see [setProperty]'s `sub-visibility` case).
/// A selection arriving while subtitles are hidden — the automatic pass
/// after an episode change, or a manual pick — becomes what un-hiding
/// restores instead of putting subtitles back on screen, matching how mpv's
/// global `sub-visibility` keeps hiding across files (#1779).
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
if (_subtitlesHidden) {
_hiddenSubtitleTrackId = track.id == SubtitleTrack.off.id ? null : track.id;
return _selectSubtitleTrackNatively(SubtitleTrack.off);
}
return _selectSubtitleTrackNatively(track);
}
Future<void> _selectSubtitleTrackNatively(SubtitleTrack track) async {
await invoke('selectSubtitleTrack', {'trackId': track.id});
}
/// A sidecar flagged default must not draw itself onto a hidden renderer
/// either; the selection pass that follows the add records it the same way
/// [selectSubtitleTrack] does.
@override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select});
await invoke('addSubtitleTrack', {
'uri': uri,
'title': title,
'language': language,
'select': select && !_subtitlesHidden,
});
}
@override
@@ -307,19 +336,21 @@ class PlayerAndroid extends PlayerBase {
break;
case 'sub-visibility':
if (value == 'no') {
if (_subtitlesHidden) break;
_subtitlesHidden = true;
final current = state.track.subtitle;
if (current != null && current.id != 'no') {
_hiddenSubtitleTrackId = current.id;
await selectSubtitleTrack(SubtitleTrack.off);
_hiddenSubtitleTrackId = current != null && current.id != SubtitleTrack.off.id ? current.id : null;
if (_hiddenSubtitleTrackId != null) {
await _selectSubtitleTrackNatively(SubtitleTrack.off);
}
} else {
if (!_subtitlesHidden) break;
_subtitlesHidden = false;
final storedId = _hiddenSubtitleTrackId;
if (storedId != null) {
_hiddenSubtitleTrackId = null;
final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
if (track != null) {
await selectSubtitleTrack(track);
}
_hiddenSubtitleTrackId = null;
final track = storedId == null ? null : state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
if (track != null) {
await _selectSubtitleTrackNatively(track);
}
}
break;
+65 -2
View File
@@ -11,6 +11,9 @@ import '../services/storage_service.dart';
import '../utils/app_logger.dart';
import 'plex_home_service.dart';
import 'profile.dart';
import 'profile_avatar_source.dart';
import 'profile_connection.dart';
import 'profile_connection_registry.dart';
import 'profile_merge.dart';
import 'profile_registry.dart';
@@ -27,6 +30,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
required this._registry,
required this._plexHome,
required this._connections,
required this._profileConnections,
this._storage,
this._activeProfileIdWriter,
});
@@ -34,6 +38,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
final ProfileRegistry _registry;
final PlexHomeService _plexHome;
final ConnectionRegistry _connections;
final ProfileConnectionRegistry _profileConnections;
StorageService? _storage;
final Future<void> Function(String profileId)? _activeProfileIdWriter;
@@ -42,9 +47,12 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
List<Profile> _localProfiles = const [];
Map<String, List<PlexHomeUser>> _plexHomeUsers = const {};
Map<String, Connection> _connectionsById = const {};
Map<String, List<ProfileConnection>> _connectionsByProfile = const {};
Map<String, String?> _avatarUrls = const {};
StreamSubscription<List<Profile>>? _localSub;
StreamSubscription<List<Connection>>? _connSub;
StreamSubscription<List<ProfileConnection>>? _pcSub;
StreamSubscription<Map<String, List<PlexHomeUser>>>? _plexHomeSub;
Future<void>? _initializeFuture;
bool _initialized = false;
@@ -61,6 +69,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
Profile? get active => _active;
String? get activeId => _active?.id;
List<Profile> get profiles => _profiles;
/// Derived picture URL for [profileId], or null when initials should render.
String? avatarUrlFor(String profileId) => _avatarUrls[profileId];
bool get hasMultipleProfiles => _profiles.length > 1;
bool get isInitialized => _initialized;
@@ -170,6 +182,14 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
_resolveActive();
safeNotifyListeners();
});
_pcSub = _profileConnections.watchAll().listen((list) {
final byProfile = groupConnectionsByProfile(list);
if (_sameProfileConnections(byProfile, _connectionsByProfile)) return;
_connectionsByProfile = byProfile;
_recomputeProfiles();
_resolveActive();
safeNotifyListeners();
});
_plexHomeSub = _plexHome.stream.listen((cache) {
if (_samePlexHomeUsers(cache, _plexHomeUsers)) return;
_plexHomeUsers = cache;
@@ -191,24 +211,56 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
_localProfiles = await _registry.list();
final initialConns = await _connections.list();
_connectionsById = {for (final c in initialConns) c.id: c};
_connectionsByProfile = groupConnectionsByProfile(await _profileConnections.listAll());
_plexHomeUsers = _plexHome.current;
_recomputeProfiles();
_resolveActive();
}
/// [Connection] has no value equality; its persisted config is the
/// cheapest faithful comparison key for the handful of rows involved.
/// [Connection] has no value equality; its persisted config is the cheapest
/// faithful comparison key for the handful of rows involved.
///
/// `createdAt` is compared separately because it is a real column rather
/// than part of `toConfigJson`, and avatar selection reads it: a profile
/// shows the picture of its oldest linked connection. `ConnectionRegistry`
/// pins creation order across re-authentication, so this costs no extra
/// notifications — it only stops a genuine correction (a restore, a
/// backfill) from being swallowed until the next launch.
static bool _sameConnections(Map<String, Connection> a, Map<String, Connection> b) {
if (a.length != b.length) return false;
for (final entry in a.entries) {
final other = b[entry.key];
if (other == null) return false;
if (identical(entry.value, other)) continue;
if (entry.value.createdAt != other.createdAt) return false;
if (jsonEncode(entry.value.toConfigJson()) != jsonEncode(other.toConfigJson())) return false;
}
return true;
}
static bool _sameProfileConnections(Map<String, List<ProfileConnection>> a, Map<String, List<ProfileConnection>> b) {
if (a.length != b.length) return false;
for (final entry in a.entries) {
final other = b[entry.key];
if (other == null || entry.value.length != other.length) return false;
for (final row in entry.value) {
var hasSameFingerprint = false;
for (final candidate in other) {
if (row.profileId == candidate.profileId &&
row.connectionId == candidate.connectionId &&
row.userIdentifier == candidate.userIdentifier) {
hasSameFingerprint = true;
break;
}
}
// Only these identity fields affect avatar selection. Deliberately
// ignore tokens, default status, and binder-maintained timestamps.
if (!hasSameFingerprint) return false;
}
}
return true;
}
static bool _samePlexHomeUsers(Map<String, List<PlexHomeUser>> a, Map<String, List<PlexHomeUser>> b) {
if (a.length != b.length) return false;
for (final entry in a.entries) {
@@ -225,6 +277,12 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
connectionsById: _connectionsById,
storage: _storage,
);
_avatarUrls = resolveProfileAvatarUrls(
profiles: _profiles,
connectionsByProfile: _connectionsByProfile,
connectionsById: _connectionsById,
plexHomeByConnectionId: _plexHomeUsers,
);
}
void _resolveActive() {
@@ -487,13 +545,17 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
await _localSub?.cancel();
await _connSub?.cancel();
await _plexHomeSub?.cancel();
await _pcSub?.cancel();
_localSub = null;
_connSub = null;
_plexHomeSub = null;
_pcSub = null;
_profiles = const [];
_localProfiles = const [];
_plexHomeUsers = const {};
_connectionsById = const {};
_connectionsByProfile = const {};
_avatarUrls = const {};
_active = null;
_initializeFuture = null;
_initialized = false;
@@ -525,6 +587,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
_localSub?.cancel();
_connSub?.cancel();
_plexHomeSub?.cancel();
_pcSub?.cancel();
super.dispose();
}
}
+15 -4
View File
@@ -12,13 +12,19 @@ class ProfileAvatar extends StatelessWidget {
final double size;
final bool showLockBadge;
const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true});
/// The derived picture for this profile (see [resolveProfileAvatarUrls]).
///
/// Falls back to [Profile.avatarThumbUrl] when null.
final String? avatarUrl;
const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true, this.avatarUrl});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final p = profile;
final lockBadgeSize = size * 0.34;
final memCacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round();
return SizedBox(
width: size,
@@ -27,7 +33,7 @@ class ProfileAvatar extends StatelessWidget {
clipBehavior: Clip.none,
children: [
ClipOval(
child: SizedBox(width: size, height: size, child: _buildContent(theme, p)),
child: SizedBox(width: size, height: size, child: _buildContent(theme, p, memCacheSize)),
),
if (showLockBadge && p != null && p.isPinProtected)
Positioned(
@@ -55,16 +61,21 @@ class ProfileAvatar extends StatelessWidget {
);
}
Widget _buildContent(ThemeData theme, Profile? p) {
Widget _buildContent(ThemeData theme, Profile? p, int memCacheSize) {
if (p == null) {
return Container(color: theme.colorScheme.surfaceContainerHighest);
}
final thumb = p.avatarThumbUrl;
// An empty override means "nothing derived", not "suppress the profile's
// own picture" — same absent-or-blank rule the thumb itself uses below.
final override = avatarUrl;
final thumb = override != null && override.isNotEmpty ? override : p.avatarThumbUrl;
if (thumb != null && thumb.isNotEmpty) {
return CachedNetworkImage(
imageUrl: thumb,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.cover,
memCacheWidth: memCacheSize,
memCacheHeight: memCacheSize,
placeholder: (_, _) => _initialFallback(theme, p),
errorBuilder: (_, _, _) => _initialFallback(theme, p),
);
+105
View File
@@ -0,0 +1,105 @@
import '../connection/connection.dart';
import '../models/plex/plex_home_user.dart';
import '../services/jellyfin_mappers.dart';
import 'profile.dart';
import 'profile_connection.dart';
/// Picture URL per profile id, `null` meaning "render initials".
///
/// A [PlexHomeProfile] owns its picture: Plex serves a per-home-user avatar
/// and [Profile.virtualPlexHome] already carries it, so those pass through
/// untouched.
///
/// A [LocalProfile] has no picture of its own. It shows the user picture of
/// the connection it was linked to **first** — the oldest by
/// [Connection.createdAt], ties broken by connection id so the choice is
/// stable. If that connection has no picture the profile falls back to
/// initials; we deliberately do *not* skip ahead to a later connection, so the
/// avatar stays a property of "the first connection" rather than of whichever
/// backend happens to have an image today.
///
/// The join row is not a usable ordering key: `profile_connections` has no
/// creation timestamp, and `tokenAcquiredAt` is rewritten every time the
/// binder re-mints a Plex Home token.
Map<String, String?> resolveProfileAvatarUrls({
required List<Profile> profiles,
required Map<String, List<ProfileConnection>> connectionsByProfile,
required Map<String, Connection> connectionsById,
required Map<String, List<PlexHomeUser>> plexHomeByConnectionId,
}) {
return {
for (final profile in profiles)
profile.id: _avatarForProfile(
profile: profile,
links: connectionsByProfile[profile.id] ?? const [],
connectionsById: connectionsById,
plexHomeByConnectionId: plexHomeByConnectionId,
),
};
}
String? _avatarForProfile({
required Profile profile,
required List<ProfileConnection> links,
required Map<String, Connection> connectionsById,
required Map<String, List<PlexHomeUser>> plexHomeByConnectionId,
}) {
final own = profile.avatarThumbUrl;
if (own != null && own.isNotEmpty) return own;
// A Plex Home user with no avatar set keeps its initials. Plex owns that
// profile's identity end to end, so it must never borrow the picture of a
// connection it happens to have been lent.
if (profile.isPlexHome) return null;
ProfileConnection? firstLink;
Connection? first;
for (final link in links) {
// A link whose connection was removed can't contribute a picture, and it
// must not win the ordering either.
final connection = connectionsById[link.connectionId];
if (connection == null) continue;
if (first == null || _isEarlier(connection, first)) {
first = connection;
firstLink = link;
}
}
if (first == null || firstLink == null) return null;
return connectionAvatarUrl(connection: first, link: firstLink, plexHomeByConnectionId: plexHomeByConnectionId);
}
bool _isEarlier(Connection candidate, Connection incumbent) {
final byCreatedAt = candidate.createdAt.compareTo(incumbent.createdAt);
if (byCreatedAt != 0) return byCreatedAt < 0;
return candidate.id.compareTo(incumbent.id) < 0;
}
/// Picture a single connection contributes to [link]'s profile, or `null`.
///
/// Plex resolves through the join row rather than the account owner: a profile
/// borrows a *specific* Home user (`userIdentifier`), and that user's live
/// [PlexHomeUser.thumb] is the picture Plex shows for it. Reading the live
/// cache also means the avatar tracks Plex's hourly refresh for free.
String? connectionAvatarUrl({
required Connection connection,
required ProfileConnection link,
required Map<String, List<PlexHomeUser>> plexHomeByConnectionId,
}) {
return switch (connection) {
JellyfinConnection(:final baseUrl, :final userId, :final primaryImageTag) => jellyfinUserImageUrl(
baseUrl: baseUrl,
userId: userId,
tag: primaryImageTag,
),
PlexAccountConnection() => _plexHomeUserThumb(plexHomeByConnectionId[connection.id], link.userIdentifier),
};
}
String? _plexHomeUserThumb(List<PlexHomeUser>? homeUsers, String homeUserUuid) {
if (homeUsers == null || homeUserUuid.isEmpty) return null;
for (final user in homeUsers) {
if (user.uuid != homeUserUuid) continue;
return user.thumb.isEmpty ? null : user.thumb;
}
return null;
}
+10
View File
@@ -2,6 +2,16 @@ import '../connection/connection.dart';
import '../models/plex/plex_home_user.dart';
import '../services/storage_service.dart';
import 'profile.dart';
import 'profile_connection.dart';
/// Groups profile-connection rows by profile id, preserving input order.
Map<String, List<ProfileConnection>> groupConnectionsByProfile(List<ProfileConnection> pcs) {
final out = <String, List<ProfileConnection>>{};
for (final pc in pcs) {
out.putIfAbsent(pc.profileId, () => []).add(pc);
}
return out;
}
/// Merge local profiles with virtual Plex Home profiles. Each Plex Home
/// user becomes a virtual profile attached to its `connectionId`. Home
+28 -11
View File
@@ -6,6 +6,7 @@ import '../models/plex/plex_home_user.dart';
import '../services/storage_service.dart';
import 'plex_home_service.dart';
import 'profile.dart';
import 'profile_avatar_source.dart';
import 'profile_connection.dart';
import 'profile_connection_registry.dart';
import 'profile_merge.dart';
@@ -25,9 +26,22 @@ class ProfilesView {
final Map<String, Connection> connectionsById;
const ProfilesView({required this.profiles, required this.connectionsByProfile, required this.connectionsById});
/// Picture URL per profile id; null means render initials. See [resolveProfileAvatarUrls].
final Map<String, String?> avatarUrlByProfile;
static const empty = ProfilesView(profiles: [], connectionsByProfile: {}, connectionsById: {});
const ProfilesView({
required this.profiles,
required this.connectionsByProfile,
required this.connectionsById,
required this.avatarUrlByProfile,
});
static const empty = ProfilesView(
profiles: [],
connectionsByProfile: {},
connectionsById: {},
avatarUrlByProfile: {},
);
}
/// Join-table rows that should be shown as explicit, user-manageable
@@ -77,21 +91,24 @@ ProfilesView _build({
required StorageService? storage,
}) {
final connectionsById = {for (final c in conns) c.id: c};
final connectionsByProfile = groupConnectionsByProfile(pcs);
final all = mergeLocalWithPlexHome(
locals: locals,
plexHomeByConnectionId: homes,
connectionsById: connectionsById,
storage: storage,
);
return ProfilesView(profiles: all, connectionsByProfile: _groupByProfile(pcs), connectionsById: connectionsById);
}
Map<String, List<ProfileConnection>> _groupByProfile(List<ProfileConnection> pcs) {
final out = <String, List<ProfileConnection>>{};
for (final pc in pcs) {
out.putIfAbsent(pc.profileId, () => []).add(pc);
}
return out;
return ProfilesView(
profiles: all,
connectionsByProfile: connectionsByProfile,
connectionsById: connectionsById,
avatarUrlByProfile: resolveProfileAvatarUrls(
profiles: all,
connectionsByProfile: connectionsByProfile,
connectionsById: connectionsById,
plexHomeByConnectionId: homes,
),
);
}
/// Lightweight `combineLatest4` — emits the combined value once each input
+5
View File
@@ -167,6 +167,11 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return _loadCoordinator.requestFull();
}
/// Whether a [load] pass is already running. The startup online-entry hook
/// uses this to skip a prime that would only duplicate the load the screen
/// started in `initState`.
bool get isLoadInFlight => _loadCoordinator.isBusy;
Future<void> _loadOnce() async {
// Yield to the microtask queue before the first notify so a load()
// kicked off during build (the screen's initState) doesn't mark
+1 -1
View File
@@ -263,7 +263,7 @@ class _AuthScreenState extends State<AuthScreen> {
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: const EdgeInsets.all(24),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop
? Row(
crossAxisAlignment: .center,
+79 -45
View File
@@ -19,6 +19,9 @@ import '../i18n/app_locale_utils.dart';
import '../i18n/strings.g.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_item_merge.dart';
import '../media/media_rating.dart';
import '../media/media_version.dart';
import '../models/catalog/catalog_cast_member.dart';
import '../models/catalog/catalog_item.dart';
import '../models/catalog/catalog_metadata.dart';
@@ -76,6 +79,12 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
List<CatalogLink> _streamingLinks = const [];
List<CatalogLink> _otherLinks = const [];
bool _showSpoilerTags = false;
/// Focus node per library copy, keyed by [MediaItem.globalKey] so a later
/// resolution pass that adds a copy keeps the nodes — and therefore the
/// focus — of the rows already on screen. [_libraryMatchFocusNodes] is the
/// same nodes in display order, for index-based dpad traversal.
final Map<String, FocusNode> _libraryMatchNodesByKey = {};
List<FocusNode> _libraryMatchFocusNodes = const [];
CatalogSource? _watchlistSource;
SeerrCatalogSource? _requestSource;
@@ -134,7 +143,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
node.dispose();
}
_watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged);
for (final node in _libraryMatchFocusNodes) {
for (final node in _libraryMatchNodesByKey.values) {
node.dispose();
}
for (final node in _relationFocusNodes) {
@@ -149,37 +158,60 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
setState(() {});
}
/// Monotonic guard for match resolution: the bare row item and its
/// detail-enriched form resolve concurrently, and only the latest-issued
/// resolution may publish (a slow bare lookup must not overwrite the
/// enriched verdict with its own).
int _matchGeneration = 0;
Future<void> _resolveMatches(CatalogItem item) async {
final generation = ++_matchGeneration;
List<MediaItem> matches;
try {
matches = await context.read<CatalogLibraryMatcher>().match(item);
} catch (e) {
appLogger.w('Catalog library match failed for ${item.identityKey}', error: e);
matches = const [];
// A failed pass is no evidence about copies an earlier pass already
// found; only claim "not in your library" when nothing has resolved.
if (_matches == null) _mergeMatches(const []);
return;
}
if (generation == _matchGeneration) _setMatches(matches);
_mergeMatches(matches);
}
void _setMatches(List<MediaItem> matches) {
/// Fold a resolution pass into the visible copies.
///
/// Union, never replace. The bare row item and its detail-enriched form
/// resolve concurrently, and `findByExternalIdsAcrossServers` logs and skips
/// per-server failures — so a later pass can legitimately come back short a
/// server that answered the first one. Replacing would drop rows for copies
/// that are still there.
void _mergeMatches(List<MediaItem> matches) {
if (!mounted) return;
for (final node in _libraryMatchFocusNodes) {
node.dispose();
final focused = _libraryMatchNodesByKey.values.firstWhereOrNull((node) => node.hasPrimaryFocus);
final merged = mergeLibraryCopies(_matches ?? const [], matches);
final keys = {for (final match in merged) match.globalKey};
for (final key in _libraryMatchNodesByKey.keys.toList()) {
if (!keys.contains(key)) _libraryMatchNodesByKey.remove(key)!.dispose();
}
_libraryMatchFocusNodes = [
for (var index = 0; index < matches.length; index++)
FocusNode(
debugLabel: 'catalog_library_match_$index',
onKeyEvent: (node, event) => _handleLibraryMatchKey(index, event),
for (final match in merged)
_libraryMatchNodesByKey.putIfAbsent(
match.globalKey,
() => FocusNode(
debugLabel: 'catalog_library_match_${match.globalKey}',
// Resolved live: merging a pass can reorder the rows, so a
// captured index would steer the wrong copy.
onKeyEvent: (node, event) {
final index = _libraryMatchFocusNodes.indexOf(node);
return index < 0 ? KeyEventResult.ignored : _handleLibraryMatchKey(index, event);
},
),
),
];
setState(() => _matches = matches);
setState(() => _matches = merged);
// A merge re-sorts, and SettingsGroup shapes its cards by list index, so
// the tile holding the focused node can be rebuilt from scratch and drop
// it. Reclaim it rather than dumping a dpad user on another copy.
if (focused != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || focused.hasPrimaryFocus || focused.context == null) return;
focused.requestFocus();
});
}
}
CatalogSource? get _ownSource =>
@@ -231,11 +263,14 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
_relationEntries = relationEntries;
});
// The row form of a Plex Discover item carries only its rating key;
// the detail body brings the external ids (#1715). When enrichment
// added id forms and the bare lookup found nothing, ask again with
// the full set.
// the detail body brings the external ids (#1715). Ask again with the
// full set whenever enrichment added id forms — not just when the bare
// lookup came back empty: the exact `plex://` guid finds only copies in
// libraries on the modern agent, while a legacy-agent sibling is
// reachable solely through the imdb/tmdb forms (#1754). The result
// merges, so a re-ask can only add copies.
final gainedIds = !widget.item.ids.allKeys.toSet().containsAll(detail.item.ids.allKeys);
if (gainedIds && (_matches?.isEmpty ?? true)) {
if (gainedIds) {
unawaited(_resolveMatches(detail.item));
}
} catch (e) {
@@ -499,14 +534,30 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
}
/// The technical label of a copy's best version, e.g. "4K HEVC MKV
/// (45.0 Mbps)". Library names are user-chosen and need not mention
/// resolution, which is exactly the question two copies of one movie pose
/// (#1754), so the row states it outright. Omitted when the backend
/// reported no resolution at all, rather than showing "Unknown".
static String? _libraryMatchQuality(MediaItem match) {
MediaVersion? best;
for (final version in match.mediaVersions ?? const <MediaVersion>[]) {
if (version.resolutionHeight == null) continue;
if (best == null || version.resolutionHeight! > best.resolutionHeight!) best = version;
}
return best?.displayLabel;
}
Widget _buildLibraryMatchTile(MediaItem match, int index) {
// Plex matches carry their library title; Jellyfin's search-based lookup
// only does when the ancestors call succeeded, so fall back to the server
// name alone. The subtitle carries whatever else tells two copies apart.
final details = [?_libraryMatchQuality(match), ?(match.libraryTitle == null ? null : match.serverName)];
return FocusableListTile(
focusNode: _libraryMatchFocusNodes[index],
leading: BackendBadge(backend: match.backend, size: 24),
// Plex matches carry their library title; Jellyfin's search-based
// lookup doesn't, so fall back to the server name alone.
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name),
subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null,
subtitle: details.isEmpty ? null : Text(details.join(' • ')),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => unawaited(navigateToMediaItemDetails(context, match)),
);
@@ -617,23 +668,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
CatalogCreditRole.composer => t.explore.creditRole.composer,
};
static String? _ratingSourceLabel(String source) => switch (source) {
'critic' => t.explore.ratingSource.critic,
'audience' => t.explore.ratingSource.audience,
'imdb' => t.explore.ratingSource.imdb,
'tmdb' => t.explore.ratingSource.tmdb,
'rottenTomatoes' => t.explore.ratingSource.rottenTomatoes,
// Plex splits Rotten Tomatoes into its two panels, so both the provenance
// and the critic/audience distinction survive.
'rottenTomatoesCritic' => t.explore.ratingSource.rottenTomatoesCritic,
'rottenTomatoesAudience' => t.explore.ratingSource.rottenTomatoesAudience,
'simkl' => t.explore.ratingSource.simkl,
'mal' => t.explore.ratingSource.mal,
'anilist' => t.explore.ratingSource.anilist,
'trakt' => t.explore.ratingSource.trakt,
_ => null,
};
static String _relationLabel(CatalogRelationType type) => switch (type) {
CatalogRelationType.prequel => t.explore.relation.prequel,
CatalogRelationType.sequel => t.explore.relation.sequel,
@@ -795,9 +829,9 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
Widget? _buildRatingsSection(ThemeData theme) {
final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
final chips = <Widget>[];
for (final rating in _item.ratings ?? const <CatalogRatingSource>[]) {
final source = _ratingSourceLabel(rating.source);
final badge = catalogRatingInfo(rating.source, rating.value);
for (final rating in _item.ratings ?? const <MediaRatingSource>[]) {
final source = ratingSourceLabel(rating.source);
final badge = ratingInfoForSource(rating.source, rating.value);
if (source == null && badge == null) continue;
var label = badge == null ? '$source ${rating.value.toStringAsFixed(1)}' : badge.formattedValue;
if (rating.votes case final votes?) {
@@ -203,7 +203,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
padding: .fromLTRB(16, 16, 16, 16 + MediaQuery.paddingOf(context).bottom),
children: [
SegmentedButton<int>(
showSelectedIcon: false,
+16 -3
View File
@@ -600,6 +600,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
unawaited(_discover.load());
}
@override
void primeRefresh() {
// `initState` already fired `load()`. On cold start that pass is still
// running when the online-entry hook primes the tab, and asking again only
// queues an identical trailing pass — the whole home fan-out twice (#1784).
// When nothing is in flight (reconnect-from-offline, or a first pass that
// gave up because no server was online yet) a real refresh is still owed.
if (_discover.isLoadInFlight) return;
fullRefresh();
}
/// Whether the loaded hubs span more than one connected server.
bool _hubsSpanMultipleServers() {
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
@@ -652,12 +663,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
key: _userMenuKey,
enabled: !_switchingProfile,
icon: active != null
? ProfileAvatar(profile: active, size: 32)
? ProfileAvatar(profile: active, size: 32, avatarUrl: activeProvider.avatarUrlFor(active.id))
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
tooltip: t.profiles.sectionTitle,
anchorAlignment: AppMenuAnchorAlignment.end,
onSelected: (value) => unawaited(_handleUserMenuAction(context, value)),
entriesBuilder: (context) => _userMenuItems(context, activeProfile: active, profiles: profiles),
entriesBuilder: (context) =>
_userMenuItems(context, activeProfile: active, profiles: profiles, activeProvider: activeProvider),
),
);
}
@@ -666,6 +678,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
BuildContext context, {
required Profile? activeProfile,
required List<Profile> profiles,
required ActiveProfileProvider activeProvider,
}) {
final theme = Theme.of(context);
final switchable = profiles.where((p) => p.id != activeProfile?.id).toList();
@@ -674,7 +687,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
for (final p in switchable)
AppMenuItem<String>(
value: 'profile:${p.id}',
leading: ProfileAvatar(profile: p, size: 24),
leading: ProfileAvatar(profile: p, size: 24, avatarUrl: activeProvider.avatarUrlFor(p.id)),
label: p.displayName,
trailing: p.isPinProtected
? AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant)
@@ -13,6 +13,7 @@ import '../widgets/focusable_media_card.dart';
import '../widgets/media_card_sliver_layout.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/skeleton_media_card.dart';
import '../widgets/system_bottom_inset.dart';
/// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
@@ -86,7 +87,10 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
/// host that defers route back to [handleBackNavigation], plus a Scaffold
/// with a CustomScrollView bound as the primary scroll view. Callers build
/// the slivers themselves (typically
/// `[appBar, ...header, ...buildStateSlivers(), grid]`).
/// `[appBar, ...header, ...buildStateSlivers(), grid]`); a trailing
/// [SliverSystemBottomInset] is appended so the last row clears the system
/// navigation bar. Screens that add their own trailing spacer (the music
/// detail screens reserve the floating mini-player) stack on top of it.
Widget buildDetailScaffold({required List<Widget> slivers}) {
return PrimaryScrollController(
controller: scrollController,
@@ -100,7 +104,9 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
Navigator.pop(context);
}
},
child: Scaffold(body: CustomScrollView(primary: true, slivers: slivers)),
child: Scaffold(
body: CustomScrollView(primary: true, slivers: [...slivers, const SliverSystemBottomInset()]),
),
),
),
);
+2
View File
@@ -12,6 +12,7 @@ import '../media/media_server_client.dart';
import '../media/media_sort.dart';
import '../services/settings_service.dart';
import '../widgets/settings_builder.dart';
import '../widgets/system_bottom_inset.dart';
import '../utils/app_logger.dart';
import '../utils/continuation_pagination_coordinator.dart';
import '../utils/error_message_utils.dart';
@@ -611,6 +612,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
),
const SliverSystemBottomInset(),
],
),
),
+16 -5
View File
@@ -520,7 +520,7 @@ class _MainScreenState extends State<MainScreen>
}
if (!mounted) return;
_fullRefreshContentTabs();
_primeContentTabs();
}
/// Single-shot "resume queued downloads once any client is online" rule,
@@ -1634,15 +1634,26 @@ class _MainScreenState extends State<MainScreen>
if (_screenKeys[tab]?.currentState case final T state) fn(state);
}
/// Full-refresh the primary content tabs. Shared by the online-entry hook
/// ([_primeOnlineServices]) and the profile-switch invalidation
/// ([_invalidateAllScreens]), which refresh the same set.
/// Full-refresh the primary content tabs. Used by the profile-switch
/// invalidation ([_invalidateAllScreens]), which must refetch everything for
/// the new identity.
void _fullRefreshContentTabs() {
for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) {
for (final tab in _contentTabs) {
_onScreen<FullRefreshable>(tab, (screen) => screen.fullRefresh());
}
}
/// Online-entry variant used by [_primeOnlineServices] on cold start and on
/// reconnect-from-offline. Screens that already started their own load skip
/// it; see [FullRefreshable.primeRefresh].
void _primeContentTabs() {
for (final tab in _contentTabs) {
_onScreen<FullRefreshable>(tab, (screen) => screen.primeRefresh());
}
}
static const _contentTabs = [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search];
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
final tabs = _getBottomNavigationTabs(context);
final selectedIndex = tabs.indexWhere((tab) => tab.id == _currentTab);
+84 -123
View File
@@ -50,7 +50,6 @@ import '../services/plex_client.dart';
import '../media/media_server_client.dart';
import '../services/media_list_playback_launcher.dart';
import '../utils/content_utils.dart';
import '../utils/rating_utils.dart';
import '../models/download_models.dart';
import '../services/download_storage_service.dart';
import '../utils/download_version_utils.dart';
@@ -208,6 +207,12 @@ class _SeasonEpisodePager {
}
}
/// Identifies the TV reveal gate's opacity wrapper. The detail tree builds
/// other [AnimatedOpacity] widgets (the scroll-linked app-bar scrim is 0 at
/// rest), so tests must target this one specifically.
@visibleForTesting
const tvDetailRevealGateKey = ValueKey<String>('tvDetailRevealGate');
class MediaDetailScreen extends StatefulWidget {
final MediaItem metadata;
final bool isOffline;
@@ -838,6 +843,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: IgnorePointer(
ignoring: !revealed,
child: AnimatedOpacity(
// Keyed so tests can assert this specific gate rather than
// whichever AnimatedOpacity happens to be lowest on screen.
key: tvDetailRevealGateKey,
opacity: revealed ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
@@ -961,59 +969,28 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
/// Build a rating chip that shows a source icon when available,
/// falling back to a generic Material icon.
Widget _buildRatingChip(String? imageUri, double value, IconData fallbackIcon) {
/// Every attributed score in one pill, then the tappable user-rating chip.
///
/// The scores share a single pill rather than taking a chip each: this row
/// is a height-clipped [Wrap], so a per-source chip would push the year,
/// certification, and runtime out of the visible band on short heroes.
List<Widget> _buildRatingChips(MediaItem metadata) {
final colorScheme = Theme.of(context).colorScheme;
final isTv = PlatformDetector.isTV();
return MediaRatingBadge.chip(
imageUri: imageUri,
value: value,
fallbackIcon: fallbackIcon,
foregroundColor: colorScheme.onSecondaryContainer,
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.8),
iconSize: isTv ? 20 : 16,
spacing: isTv ? 6 : 4,
padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
textStyle: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
);
}
/// Build all rating chips for the metadata.
/// When both critic and audience ratings are from Rotten Tomatoes,
/// they are combined into a single badge.
List<Widget> _buildRatingChips(MediaItem metadata) {
final chips = <Widget>[];
// Plex-only fields (audienceRating / ratingImage / audienceRatingImage)
// — Jellyfin lacks rating-source attribution. Pull them via a typed
// narrow so the rest of the chip layout stays backend-neutral.
final plex = metadata is PlexMediaItem ? metadata : null;
final audienceRating = plex?.audienceRating;
final ratingImage = plex?.ratingImage;
final audienceRatingImage = plex?.audienceRatingImage;
final bothRT =
metadata.rating != null &&
audienceRating != null &&
isRottenTomatoes(ratingImage) &&
isRottenTomatoes(audienceRatingImage);
if (bothRT) {
chips.add(_buildCombinedRtChip(ratingImage, metadata.rating!, audienceRatingImage, audienceRating));
} else {
if (metadata.rating != null) {
chips.add(_buildRatingChip(ratingImage, metadata.rating!, Symbols.star_rounded));
}
if (audienceRating != null) {
chips.add(_buildRatingChip(audienceRatingImage, audienceRating, Symbols.people_rounded));
}
}
// User rating chip (tappable)
if (!widget.isOffline) {
chips.add(_buildUserRatingChip(metadata));
}
return chips;
return [
if (mediaRatingsFor(metadata).isNotEmpty)
MediaRatingBadgeGroup.chip(
item: metadata,
foregroundColor: colorScheme.onSecondaryContainer,
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.8),
iconSize: isTv ? 20 : 16,
spacing: isTv ? 6 : 4,
entrySpacing: isTv ? 12 : 10,
padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
textStyle: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
),
if (!widget.isOffline) _buildUserRatingChip(metadata),
];
}
Widget _buildUserRatingChip(MediaItem metadata) {
@@ -1111,49 +1088,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
/// Build a combined RT chip showing critic + audience side by side.
Widget _buildCombinedRtChip(
String? criticImageUri,
double criticValue,
String? audienceImageUri,
double audienceValue,
) {
final colorScheme = Theme.of(context).colorScheme;
final textStyle = TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: .w500);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer.withValues(alpha: 0.8),
borderRadius: const BorderRadius.all(Radius.circular(100)),
),
child: Row(
mainAxisSize: .min,
children: [
MediaRatingBadge.inline(
imageUri: criticImageUri,
value: criticValue,
fallbackIcon: Symbols.star_rounded,
foregroundColor: colorScheme.onSecondaryContainer,
iconSize: 16,
spacing: 4,
textStyle: textStyle,
),
const SizedBox(width: 10),
MediaRatingBadge.inline(
imageUri: audienceImageUri,
value: audienceValue,
fallbackIcon: Symbols.people_rounded,
foregroundColor: colorScheme.onSecondaryContainer,
iconSize: 16,
spacing: 4,
textStyle: textStyle,
),
],
),
);
}
/// Backend-neutral counterpart of [getServerBoundPlexClient]. Returns a
/// [MediaServerClient] for Jellyfin items too, so image URLs use the
/// right server's transcoder.
@@ -1355,35 +1289,59 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return;
}
final result = await client.fetchItemWithOnDeck(_metadata.id);
// Normalises a freshly fetched item against the row we navigated from
// (which owns serverId/library) and paints it. Called once from
// [onItemReady] on backends that learn the item before on-deck, and once
// from the settled result.
//
// [onDeckSettled] separates "on-deck not looked up yet" from "on-deck
// looked up and there is none". Only the settled call may write it, so
// the early paint leaves whatever is on screen alone — this method runs
// again after playback, and clearing there would blank the play button
// for the length of the on-deck round trip — while a reload that finds
// the series finished still clears it.
MediaItem publish(MediaItem source, {MediaItem? onDeckEpisode, bool onDeckSettled = false}) {
final serverId = _metadata.serverId;
final serverName = _metadata.serverName;
final base = _withFallbackLibrary(
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
_metadata,
);
final onDeckWithServerId = onDeckEpisode == null
? null
: _withFallbackLibrary(
onDeckEpisode.copyWith(
serverId: serverId ?? onDeckEpisode.serverId,
serverName: serverName ?? onDeckEpisode.serverName,
),
base,
);
setState(() {
_fullMetadata = base;
if (onDeckSettled) _onDeckEpisode = onDeckWithServerId;
_isLoadingMetadata = false;
});
return base;
}
// Jellyfin needs a second round trip for on-deck, which the screen does
// not need in order to paint. Publishing the item as soon as it lands
// takes that round trip off the critical path (#1784).
//
// Seasons/extras deliberately still start after the whole lookup
// settles: starting them at the early paint measured *worse*, because
// they contend with the on-deck request rather than overlapping it.
final result = await client.fetchItemWithOnDeck(
_metadata.id,
onItemReady: (item) {
if (mounted) publish(item);
},
);
final metadata = result.item;
final onDeckEpisode = result.onDeckEpisode;
if (!mounted) return;
// Preserve serverId from original metadata
final serverId = _metadata.serverId;
final serverName = _metadata.serverName;
final source = metadata ?? _metadata;
final base = _withFallbackLibrary(
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
_metadata,
);
final onDeckWithServerId = onDeckEpisode == null
? null
: _withFallbackLibrary(
onDeckEpisode.copyWith(
serverId: serverId ?? onDeckEpisode.serverId,
serverName: serverName ?? onDeckEpisode.serverName,
),
base,
);
setState(() {
_fullMetadata = base;
_onDeckEpisode = onDeckWithServerId;
_isLoadingMetadata = false;
});
final base = publish(metadata ?? _metadata, onDeckEpisode: result.onDeckEpisode, onDeckSettled: true);
if (base.isShow) {
unawaited(_loadSeasons());
@@ -3625,7 +3583,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final fields = _tvDetailMetadataParts<String>(
metadata,
text: (value) => value,
rating: (item) => MediaRatingBadge.semanticLabelForMedia(item, fallbackItem: metadata),
rating: (item) => MediaRatingBadgeGroup.semanticLabelForMedia(item, fallbackItem: metadata),
);
for (final field in fields) {
add(field);
@@ -3694,12 +3652,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final fields = _tvDetailMetadataParts<Widget>(
metadata,
text: (value) => Text(value, maxLines: 1, style: textStyle),
rating: (item) => MediaRatingBadge.inlineForMedia(
// Every score shares the one metadata slot, so the bullet separators
// don't multiply with the number of sources.
rating: (item) => MediaRatingBadgeGroup.inlineForMedia(
item: item,
fallbackItem: metadata,
foregroundColor: textStyle.color,
iconSize: textStyle.fontSize,
spacing: 4 * scale,
entrySpacing: 12 * scale,
textStyle: textStyle,
),
);
@@ -36,6 +36,7 @@ import '../focusable_detail_screen_mixin.dart';
import '../libraries/content_state_builder.dart';
import '../../mixins/grid_focus_node_mixin.dart';
import '../../widgets/overlay_sheet.dart';
import '../../widgets/system_bottom_inset.dart';
/// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget {
@@ -757,6 +758,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
onBack: handleBackFromContent,
),
],
const SliverSystemBottomInset(),
],
);
@@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../models/plex/plex_home_user.dart';
import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_avatar.dart';
@@ -269,6 +270,7 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isLocal = _profile.isLocal;
final avatarUrl = context.watch<ActiveProfileProvider>().avatarUrlFor(_profile.id);
return FocusedScrollScaffold(
title: Text(_profile.displayName),
@@ -277,7 +279,9 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Center(child: ProfileAvatar(profile: _profile, size: 96)),
Center(
child: ProfileAvatar(profile: _profile, size: 96, avatarUrl: avatarUrl),
),
const SizedBox(height: 24),
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
@@ -266,6 +266,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
child: _ProfileTile(
borderRadius: tileRadii,
profile: profile,
avatarUrl: view.avatarUrlByProfile[profile.id],
isActive: isActive && !widget.requireSelection,
chips: _chipsFor(profile, view),
onTap: () => _switchTo(profile),
@@ -363,6 +364,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
class _ProfileTile extends StatelessWidget {
final Profile profile;
final String? avatarUrl;
final bool isActive;
final BorderRadius borderRadius;
final List<_ChipData> chips;
@@ -377,6 +379,7 @@ class _ProfileTile extends StatelessWidget {
const _ProfileTile({
required this.profile,
required this.avatarUrl,
required this.isActive,
required this.borderRadius,
required this.chips,
@@ -403,7 +406,7 @@ class _ProfileTile extends StatelessWidget {
padding: const EdgeInsets.all(12),
child: Row(
children: [
ProfileAvatar(profile: profile, size: 44),
ProfileAvatar(profile: profile, size: 44, avatarUrl: avatarUrl),
const SizedBox(width: 14),
Expanded(
child: Column(
+50 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
@@ -10,6 +12,7 @@ import '../media/media_item.dart';
import '../mixins/debounced_media_search.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../mixins/refreshable.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/data_aggregation_service.dart';
import '../utils/app_logger.dart';
@@ -38,10 +41,45 @@ class _SearchScreenState extends State<SearchScreen>
AbortController? _activeSearchAbort;
({String query, SearchAggregationResult result})? _pendingSearchOutcome;
HiddenLibrariesProvider? _hiddenLibraries;
Set<String> _lastSeenHiddenKeys = const {};
@override
void initState() {
super.initState();
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
unawaited(_bindHiddenLibraries());
}
@override
void dispose() {
_hiddenLibraries?.removeListener(_onHiddenLibrariesChanged);
super.dispose();
}
/// Re-run the visible query when a library is hidden or unhidden while
/// results are on screen. The listener is attached only after the provider
/// has hydrated, so its initial load notification cannot race the first
/// query — which awaits that same hydration — into running twice.
Future<void> _bindHiddenLibraries() async {
final hiddenLibraries = context.read<HiddenLibrariesProvider>();
await hiddenLibraries.ensureInitialized();
if (!mounted) return;
_lastSeenHiddenKeys = Set.of(hiddenLibraries.hiddenLibraryKeys);
_hiddenLibraries = hiddenLibraries..addListener(_onHiddenLibrariesChanged);
}
void _onHiddenLibrariesChanged() {
final hiddenLibraries = _hiddenLibraries;
if (hiddenLibraries == null || !mounted) return;
final currentKeys = hiddenLibraries.hiddenLibraryKeys;
if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) {
return;
}
_lastSeenHiddenKeys = Set.of(currentKeys);
final query = searchController.text.trim();
if (query.isEmpty || !hasSearched) return;
unawaited(runSearch(query));
}
@override
@@ -54,10 +92,21 @@ class _SearchScreenState extends State<SearchScreen>
throw const _SearchUnavailableException();
}
// Hidden-library keys must be hydrated before the first query, or a cold
// start would filter nothing. Read before any await: the profile subtree
// can be torn down mid-search.
final hiddenLibraries = context.read<HiddenLibrariesProvider>();
await hiddenLibraries.ensureInitialized();
if (!mounted) return const [];
final abort = AbortController();
_activeSearchAbort = abort;
try {
final result = await multiServerProvider.aggregationService.searchAcrossServers(query, abort: abort);
final result = await multiServerProvider.aggregationService.searchAcrossServers(
query,
hiddenLibraryKeys: hiddenLibraries.hiddenLibraryKeys,
abort: abort,
);
abort.throwIfAborted();
if (result.succeededServerIds.isEmpty && result.failedServerIds.isNotEmpty) {
throw const _SearchUnavailableException();
@@ -461,7 +461,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(24),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: Center(child: _buildQuickConnectPanel(theme)),
),
)
@@ -138,7 +138,7 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(24),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
+6 -1
View File
@@ -26,6 +26,7 @@ import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/system_bottom_inset.dart';
const previousStartupFailureKey = Key('logs-previous-startup-failure');
@@ -419,7 +420,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
?_buildPreviousFailureBanner(theme),
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else
else ...[
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
@@ -435,6 +436,10 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
),
),
),
// Only the log body needs it: the empty state already fills
// the viewport, so a trailing inset would just add slack.
const SliverSystemBottomInset(),
],
],
),
),
@@ -43,6 +43,7 @@ import '../../widgets/overlay_sheet.dart';
import '../../widgets/setting_tile.dart';
import '../../widgets/settings_builder.dart';
import '../../widgets/settings_section.dart';
import '../../widgets/system_bottom_inset.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart';
import '../../watch_together/services/watch_together_relay_endpoint.dart';
@@ -224,6 +225,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
const SizedBox(height: 24),
]),
),
const SliverSystemBottomInset(),
],
),
),
+7 -3
View File
@@ -79,7 +79,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
Widget _buildLoadingSpinner() {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator(color: Colors.white)),
body: Center(child: PlayerLoadingIndicator()),
);
}
@@ -95,7 +95,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
fit: StackFit.expand,
children: [
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
const Center(child: CircularProgressIndicator(color: Colors.white)),
const Center(child: PlayerLoadingIndicator()),
],
);
}
@@ -186,7 +186,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
final startZoom = _pinchStartZoomScale;
final filterManager = _videoFilterManager;
if (!_isPinchZooming || startZoom == null || filterManager == null) return;
final nextZoomScale = VideoFilterManager.normalizeZoomScale(startZoom * details.scale);
// Snap through 100% so pinching back undoes a zoom exactly, which is
// the touch path to an unzoomed picture (#1505).
final nextZoomScale = VideoFilterManager.normalizeZoomScale(
VideoFilterManager.snapPinchZoomScale(startZoom * details.scale),
);
if (!_pinchZoomChanged) {
if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold) {
@@ -109,7 +109,20 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (!_subtitleCycleDrainActive) unawaited(_drainSubtitleCycles());
return;
}
_trackManager?.cycleSubtitleTrack();
_cycleSubtitleTrackNatively();
}
/// Cycle through the native track list, for playback with no source
/// catalog to advance through (downloads, and items whose server exposes no
/// subtitle rows).
///
/// The manager owns the selection and the server write-back; the committed
/// choice is this screen's, and the episode carry-over reads it, so a cycle
/// that lands on Off has to be recorded here or the next episode inherits
/// the choice this one started with.
void _cycleSubtitleTrackNatively() {
final cycled = _trackManager?.cycleSubtitleTrack();
if (cycled != null) _rememberNativeSubtitleSelection(cycled);
}
Future<void> _drainSubtitleCycles() async {
@@ -127,7 +140,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (_isOfflinePlayback || sourceTracks.isEmpty) {
_pendingSubtitleCycleCount -= advances;
for (var i = 0; i < advances; i++) {
_trackManager?.cycleSubtitleTrack();
_cycleSubtitleTrackNatively();
}
continue;
}
@@ -159,6 +159,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
hasCommittedSelection: committedSubtitleSelection != null,
committedTrack: committedSubtitleSelection?.primaryTrack,
nativeTrack: currentPlayer.state.track.subtitle,
declinedPreference: committedSubtitleSelection?.declinedPreference,
sessionPreference: _sessionSubtitlePreference,
);
final secondarySubtitlePreference = followServerSelections
? null
@@ -166,6 +168,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
hasCommittedSelection: committedSubtitleSelection != null,
committedTrack: committedSubtitleSelection?.secondaryTrack,
nativeTrack: currentPlayer.state.track.secondarySubtitle,
sessionPreference: _sessionSecondarySubtitlePreference,
);
await _reloadMediaInPlace(
metadata: episodeMetadata,
@@ -177,6 +180,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// meaningless on the new item, so let preferences pick the track.
useCurrentAudioStreamSelection: false,
preserveCurrentTrackSelection: !followServerSelections,
preservedAudioTrack: _sessionAudioPreference,
preservedSubtitleTrack: primarySubtitlePreference,
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
reason: 'episode navigation',
@@ -221,6 +225,28 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
player == currentPlayer &&
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingSource);
bool sourceSwitchWasSuperseded() => !mounted || player != currentPlayer || transitionLease.wasSuperseded;
void rememberSourceAudioPreference() {
final streamId = newAudioStreamId;
final mediaInfo = _currentMediaInfo;
if (streamId == null || mediaInfo == null) return;
for (final row in mediaInfo.audioTracks) {
if (row.id == streamId) {
_sessionAudioPreference = PlaybackSubtitleResolver.audioTrackForSource(row);
return;
}
}
}
void rememberSourceSubtitlePreference() {
final choice = newSubtitleChoice;
final mediaInfo = _currentMediaInfo;
if (choice == null || mediaInfo == null) return;
// The local-switch path records through the screen's remember chain;
// this covers picks that had to reload, whose resolved outcome never
// reaches _rememberNativeSubtitleSelectionForSlot.
final preference = sessionPreferenceForSourceSubtitleChoice(choice, mediaInfo.subtitleTracks);
if (preference != null) _sessionSubtitlePreference = preference;
}
// Snapshot the backend client before subtitle selection can cross an
// async boundary or the profile-scoped context can disappear.
@@ -275,6 +301,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId;
final isSubtitleChange = newSubtitleChoice != null && newSubtitleChoice != currentSubtitleChoice;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
rememberSourceAudioPreference();
rememberSourceSubtitlePreference();
return PlaybackSourceChangeOutcome.unchanged;
}
@@ -322,6 +350,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
transitionLease: transitionLease,
reason: 'source switch',
);
if (outcome == _MediaReloadOutcome.opened) {
rememberSourceAudioPreference();
rememberSourceSubtitlePreference();
}
return switch (outcome) {
_MediaReloadOutcome.opened => PlaybackSourceChangeOutcome.applied,
_MediaReloadOutcome.rejected => PlaybackSourceChangeOutcome.busy,
@@ -561,6 +593,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final initializationSubtitleTrack = preservesRequestedSubtitleSource
? currentSubtitleTrack
: SubtitlePreference.demoteToIntent(currentSubtitleTrack);
// Same boundary rule for audio: native and Jellyfin stream ids are
// reused per item, so a cross-source carry keeps only its semantics —
// an identity match against a reused id would latch by ordinal and
// bypass the evidence bands' ambiguity decline.
final initializationAudioTrack = preservesRequestedSubtitleSource || currentAudioTrack == null
? currentAudioTrack
: itemAgnosticAudioCarry(currentAudioTrack);
try {
// Eager identity-only: the loading UI shows the new title immediately,
// while the selection/source state flips with the session commit at
@@ -597,6 +636,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preferredVersionSignature: preferredVersionSignature,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
preferredAudioTrack: initializationAudioTrack,
preferredSubtitleTrack: initializationSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
@@ -616,7 +656,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: metadata,
result: result,
preferredAudioTrack: currentAudioTrack,
preferredAudioTrack: initializationAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
preserveSubtitleSourceIdentity:
@@ -770,8 +810,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
metadata: metadata,
plexClient: plexClient,
getProfileSettings: () => userProfileProvider.profileSettings,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredAudioTrack: initializationAudioTrack,
// A declined carry stays alive for the native passes: freezing the
// resolver's off verdict here would turn a metadata mismatch into a
// navigation-priority off that no late track can undo (#1785).
preferredSubtitleTrack:
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
);
_trackManager = trackManager;
@@ -414,6 +414,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
mediaInfo: mediaInfo,
canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _hasRenderedFirstFrame,
subtitleOffIsDeliberate: () => _playbackSession?.subtitleSelection.declinedPreference == null,
onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode'
? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId)
: null,
@@ -123,6 +123,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
selectedMediaSourceId: _requestedMediaSourceId,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
@@ -346,7 +347,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
plexClient: mediaClient is PlexClient ? mediaClient : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
// Same rule as the reload flow: a declined preference is retried by
// the native passes instead of being frozen into off (#1785).
preferredSubtitleTrack:
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
);
@@ -46,6 +46,28 @@ class VideoPlayerMacPipPlaceholder extends StatelessWidget {
}
}
/// The player's loading spinner.
///
/// Labelled rather than silent: a bare [CircularProgressIndicator] contributes
/// no semantics at all, so a screen reader announced nothing while the picture
/// was still coming up. The label also makes "the first frame has not rendered
/// yet" an observable state instead of something inferred from the chrome,
/// which a television no longer raises on startup (#1765).
class PlayerLoadingIndicator extends StatelessWidget {
final double strokeWidth;
const PlayerLoadingIndicator({super.key, this.strokeWidth = 4});
@override
Widget build(BuildContext context) {
return CircularProgressIndicator(
color: Colors.white,
strokeWidth: strokeWidth,
semanticsLabel: t.videoControls.loadingVideo,
);
}
}
class VideoPlayerBufferingOverlay extends StatelessWidget {
final ValueListenable<bool> isBuffering;
final ValueListenable<bool> hasFirstFrame;
@@ -77,7 +99,7 @@ class VideoPlayerBufferingOverlay extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle),
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
child: const PlayerLoadingIndicator(strokeWidth: 3),
),
),
),
+181 -39
View File
@@ -135,17 +135,40 @@ bool shouldAutoStartReloadedMedia({
required bool startPaused,
}) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused;
/// Whether a freshly opened player route starts with its chrome up.
///
/// A television starts with the controls down. Auto-hide cannot even arm until
/// the first frame lands, so chrome raised by the route opening would park the
/// whole OSD and timebar over the first seconds of picture (#1765). The route
/// keeps its own loading surface and buffering overlay, the screen focus node
/// owns back, and the first D-pad press raises the chrome, so a remote loses
/// nothing. Pointer and touch platforms keep it: the viewer's hand is already
/// on the surface, and the title and back affordance belong over the spinner.
bool playerChromeStartsVisible({required bool isTv}) => !isTv;
/// Builds an item-agnostic subtitle preference for an episode replacement.
///
/// Source ids and sidecar URIs belong to the current media item. Only the
/// committed semantic choice — a [SubtitleIntent] — may cross the item
/// boundary; native state is a fallback for sessions created before
/// source-backed selection was recorded.
///
/// [sessionPreference] is the screen's last explicit viewer choice and wins
/// outright: automatic outcomes never overwrite it, so a catalog-gap episode
/// cannot reset the choice for the rest of the session.
///
/// [declinedPreference] is the committed selection's unserved carry: its off
/// is fallout from a metadata mismatch, not a viewer choice, so it must not
/// harden into an explicit off on the next item (#1785).
SubtitlePreference? subtitlePreferenceForItemChange({
required bool hasCommittedSelection,
required SubtitleTrack? committedTrack,
required SubtitleTrack? nativeTrack,
SubtitlePreference? declinedPreference,
SubtitlePreference? sessionPreference,
}) {
final sessionIntent = SubtitlePreference.demoteToIntent(sessionPreference);
if (sessionIntent != null) return sessionIntent;
SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) {
if (track == null) return null;
if (track.id == SubtitleTrack.off.id) return preserveOff ? const SubtitlePreference.off() : null;
@@ -158,6 +181,15 @@ SubtitlePreference? subtitlePreferenceForItemChange({
return normalize(nativeTrack, preserveOff: true);
}
final committedIsOff = committedTrack == null || committedTrack.id == SubtitleTrack.off.id;
if (declinedPreference != null && committedIsOff) {
// Live native state wins — a late native pass may have served the
// declined carry — otherwise the declined preference itself keeps
// crossing item boundaries until the viewer or a richer catalog
// settles it.
return normalize(nativeTrack, preserveOff: false) ?? SubtitlePreference.demoteToIntent(declinedPreference);
}
if (committedTrack == null) return const SubtitlePreference.off();
final committedPreference = normalize(committedTrack, preserveOff: true);
@@ -165,6 +197,59 @@ SubtitlePreference? subtitlePreferenceForItemChange({
return normalize(nativeTrack, preserveOff: false);
}
/// Builds the committed [PlaybackSubtitleSelection] for a user's subtitle
/// pick in one slot, leaving the other slot untouched.
///
/// [sourceTrack] and [sidecar] are null when the pick has no source-catalog
/// identity — items without subtitle rows, or a native track the identity
/// matcher cannot map. The raw native [track] is committed then (without
/// source ids), so the session still reflects what is on screen and the next
/// item boundary demotes it to a semantic intent instead of hardening the
/// stale previous selection into an explicit off (#1785).
PlaybackSubtitleSelection subtitleSelectionForUserPick({
required PlaybackSubtitleSelection currentSelection,
required bool isPrimarySlot,
required SubtitleTrack track,
MediaSubtitleTrack? sourceTrack,
PlaybackSubtitleSidecar? sidecar,
}) {
final resolvedTrack = sourceTrack == null
? track
: PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sidecar);
return PlaybackSubtitleSelection(
primaryTrack: isPrimarySlot ? resolvedTrack : currentSelection.primaryTrack,
primarySourceStreamId: isPrimarySlot ? sourceTrack?.id : currentSelection.primarySourceStreamId,
primarySidecar: isPrimarySlot ? sidecar : currentSelection.primarySidecar,
secondaryTrack: isPrimarySlot ? currentSelection.secondaryTrack : resolvedTrack,
secondarySourceStreamId: isPrimarySlot ? currentSelection.secondarySourceStreamId : sourceTrack?.id,
secondarySidecar: isPrimarySlot ? currentSelection.secondarySidecar : sidecar,
// A primary pick is a decision that retires any unresolved carry; a
// secondary-only change must not relabel the primary's declined off as
// deliberate (that would persist -1 and harden the next boundary).
declinedPreference: isPrimarySlot ? null : currentSelection.declinedPreference,
);
}
/// Session-preference form of a source-catalog subtitle choice that had to
/// go through a reload instead of a local track switch.
///
/// The authoritative row — not the reload's outcome — becomes the session
/// preference: a failed resolution must not turn the viewer's pick into a
/// carried off. Returns null when the row is absent from [rows] so a stale
/// id never overwrites the existing session preference.
SubtitlePreference? sessionPreferenceForSourceSubtitleChoice(
PlaybackSourceSubtitleChoice choice,
List<MediaSubtitleTrack> rows,
) {
if (choice.isOff) return const SubtitlePreference.off();
for (final row in rows) {
if (row.id == choice.sourceStreamId) {
return SubtitlePreference.track(PlaybackSubtitleResolver.subtitleTrackForSource(row));
}
}
return null;
}
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
/// They are mutually exclusive by construction — entry points bail while a
/// transition is in flight.
@@ -251,8 +336,7 @@ _PlaybackOpenTiming _playbackOpenTiming({
/// change should not silently rewrite the whole series' Plex prefs. The
/// explicit path for that lives in the metadata-edit UI.
TrackPreferencePersister _plexTrackPersister(PlexClient? Function() resolve) {
return ({required int partId, required String trackType, int? streamID}) async {
if (streamID == null) return;
return ({required int partId, required String trackType, required int streamID}) async {
final client = resolve();
if (client == null) return;
await (trackType == 'audio'
@@ -368,6 +452,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
AudioTrack? _preferredAudioTrack;
SubtitlePreference? _preferredSubtitleTrack;
SubtitlePreference? _preferredSecondarySubtitleTrack;
/// Last explicit track choices made on this screen. They survive in-place
/// reloads but not route replacement. Automatic selections never overwrite
/// them, so an episode with a catalog gap cannot reset the viewer's choice
/// for the rest of the session (#1785).
AudioTrack? _sessionAudioPreference;
SubtitlePreference? _sessionSubtitlePreference;
SubtitlePreference? _sessionSecondarySubtitlePreference;
bool _serverSupportsTranscoding = false;
// Kicked off early in the player initialization attempt for online non-live playback so
// the metadata fetch (and transcode-decision HTTP, if non-original preset)
@@ -466,6 +558,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// key events never escape the video player route.
late final FocusNode _screenFocusNode;
/// Key for a context below this screen's own [OverlaySheetHost]. The State's
/// context sits ABOVE the host, so resolving the controller with `context`
/// always misses it and the screen would walk its back pipeline (hide chrome,
/// then exit the player) while a sheet is still open (#1741).
final GlobalKey _overlayChildKey = GlobalKey();
BuildContext get _sheetContext => _overlayChildKey.currentContext ?? context;
// VLC-style in-player toast controller (rate changes, backend switch, etc.).
final PlayerToastController _toastController = PlayerToastController();
bool _reclaimingFocus = false;
@@ -729,7 +829,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _hasFatalPlaybackError = false;
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
final PlayerChromeController _chromeController = PlayerChromeController();
final PlayerChromeController _chromeController = PlayerChromeController(
initiallyVisible: playerChromeStartsVisible(isTv: PlatformDetector.isTV()),
);
/// Lets startup-policy coverage assert the chrome this route actually opened
/// with, rather than a controller the test seeded itself.
@visibleForTesting
PlayerChromeController get chromeController => _chromeController;
late final PlayerNavigationCoordinator _playerNavigationCoordinator;
@override
@@ -1075,6 +1183,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
preferredVersionSignature: widget.preferredVersionSignature,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
@@ -1454,7 +1563,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _handleScreenPlayerNavigation(PlayerNavigationKey navigationKey) {
if (navigationKey != PlayerNavigationKey.home) {
final sheetController = OverlaySheetController.maybeOf(context);
final sheetController = OverlaySheetController.maybeOf(_sheetContext);
if (sheetController?.isOpen ?? false) {
sheetController!.pop();
return;
@@ -1821,7 +1930,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackSelectedByUser(track);
Future<void> _onAudioTrackChanged(AudioTrack track) async {
if (track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id) {
_sessionAudioPreference = track;
}
await _trackManager?.onAudioTrackSelectedByUser(track);
}
Future<void> _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
@@ -1846,25 +1960,37 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
required _SubtitleSelectionSlot slot,
int? sourceStreamId,
}) {
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
if (track.id == SubtitleTrack.off.id) {
switch (slot) {
case _SubtitleSelectionSlot.primary:
_sessionSubtitlePreference = const SubtitlePreference.off();
case _SubtitleSelectionSlot.secondary:
_sessionSecondarySubtitlePreference = const SubtitlePreference.off();
}
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
// Dropping only the secondary must not relabel the primary's
// declined off as deliberate.
_SubtitleSelectionSlot.secondary => PlaybackSubtitleSelection(
primaryTrack: currentSelection.primaryTrack,
primarySourceStreamId: currentSelection.primarySourceStreamId,
primarySidecar: currentSelection.primarySidecar,
declinedPreference: currentSelection.declinedPreference,
),
});
if (mounted) _setPlayerState(() {});
return;
}
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
final info = _currentMediaInfo;
final currentPlayer = player;
if (info == null || currentPlayer == null) return;
MediaSubtitleTrack? sourceTrack;
final currentSourceId = switch (slot) {
@@ -1875,42 +2001,57 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_SubtitleSelectionSlot.primary => currentSelection.primarySidecar,
_SubtitleSelectionSlot.secondary => currentSelection.secondarySidecar,
};
if (sourceStreamId != null) {
for (final candidate in info.subtitleTracks) {
if (candidate.id == sourceStreamId) {
sourceTrack = candidate;
break;
if (info != null) {
if (sourceStreamId != null) {
for (final candidate in info.subtitleTracks) {
if (candidate.id == sourceStreamId) {
sourceTrack = candidate;
break;
}
}
} else if (track.isExternal && currentSourceId != null && currentSidecar?.track.uri == track.uri) {
for (final candidate in info.subtitleTracks) {
if (candidate.id == currentSourceId) {
sourceTrack = candidate;
break;
}
}
}
} else if (track.isExternal && currentSourceId != null && currentSidecar?.track.uri == track.uri) {
for (final candidate in info.subtitleTracks) {
if (candidate.id == currentSourceId) {
sourceTrack = candidate;
break;
}
if (sourceTrack == null && currentPlayer != null) {
sourceTrack = findPlexTrackForMpvSubtitle(
track,
info.subtitleTracks,
allMpvTracks: currentPlayer.state.tracks.subtitle,
);
}
}
sourceTrack ??= findPlexTrackForMpvSubtitle(
track,
info.subtitleTracks,
allMpvTracks: currentPlayer.state.tracks.subtitle,
);
if (sourceTrack == null) return;
final sidecar = _sidecarForSourceStreamId(session, sourceTrack.id);
final resolvedTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sidecar);
final selection = PlaybackSubtitleSelection(
primaryTrack: slot == _SubtitleSelectionSlot.primary ? resolvedTrack : currentSelection.primaryTrack,
primarySourceStreamId: slot == _SubtitleSelectionSlot.primary
? sourceTrack.id
: currentSelection.primarySourceStreamId,
primarySidecar: slot == _SubtitleSelectionSlot.primary ? sidecar : currentSelection.primarySidecar,
secondaryTrack: slot == _SubtitleSelectionSlot.secondary ? resolvedTrack : currentSelection.secondaryTrack,
secondarySourceStreamId: slot == _SubtitleSelectionSlot.secondary
? sourceTrack.id
: currentSelection.secondarySourceStreamId,
secondarySidecar: slot == _SubtitleSelectionSlot.secondary ? sidecar : currentSelection.secondarySidecar,
// No source identity (no catalog, or a native track the identity matcher
// cannot map) still commits the raw pick: an uncommitted selection reads
// as the session's previous choice — usually the initial off — and the
// next episode boundary would harden that into an explicit off while the
// viewer visibly watches with subtitles on (#1785). The raw track carries
// no source ids, so it demotes to a semantic intent at the boundary.
final sidecar = sourceTrack == null ? null : _sidecarForSourceStreamId(session, sourceTrack.id);
final selection = subtitleSelectionForUserPick(
currentSelection: currentSelection,
isPrimarySlot: slot == _SubtitleSelectionSlot.primary,
track: track,
sourceTrack: sourceTrack,
sidecar: sidecar,
);
final resolvedTrack = switch (slot) {
_SubtitleSelectionSlot.primary => selection.primaryTrack,
_SubtitleSelectionSlot.secondary => selection.secondaryTrack,
};
if (resolvedTrack != null) {
switch (slot) {
case _SubtitleSelectionSlot.primary:
_sessionSubtitlePreference = SubtitlePreference.track(resolvedTrack);
case _SubtitleSelectionSlot.secondary:
_sessionSecondarySubtitlePreference = SubtitlePreference.track(resolvedTrack);
}
}
_updatePlaybackSessionSubtitleSelection(session, selection);
if (mounted) _setPlayerState(() {});
}
@@ -1998,6 +2139,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_handleScreenPlayerNavigation(PlayerNavigationKey.back);
},
child: Builder(
key: _overlayChildKey,
builder: (sheetContext) => _isPlayerInitialized && player != null
? _buildVideoPlayer(sheetContext)
: (_playerInitializationError != null
@@ -139,7 +139,12 @@ abstract class BaseSharedPreferencesService {
/// unreadable value — quarantining that one would reopen onto the stale
/// in-memory map and write the bad value straight back.
static Future<PrefsRepairOutcome> repairCorruptStore({bool reopenSafe = true}) async {
final (:salvaged, :backupPath) = await PrefsRecovery.quarantine();
final (:salvaged, :backupPath, :shape) = await PrefsRecovery.quarantine();
// Conservative by construction: the warning is dropped only for bytes
// proven to contain nothing. What the salvage recovered cannot stand in
// for that — a store truncated mid-value matches no entry at all while
// still holding most of a vault key in plaintext.
final backupHoldsCredentials = !(shape?.allZero ?? false);
_resetGeneration++;
_initializations.clear();
@@ -158,6 +163,7 @@ abstract class BaseSharedPreferencesService {
appLogger.w('Preference store quarantined; a restart is required before it can be reopened');
return PrefsRepairOutcome(
backupPath: backupPath,
backupHoldsCredentials: backupHoldsCredentials,
vaultKeySalvaged: seeded && salvaged.vaultKey != null,
sessionsSalvaged: seeded ? salvaged.sessions.length : 0,
sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length,
@@ -190,6 +196,7 @@ abstract class BaseSharedPreferencesService {
return PrefsRepairOutcome(
backupPath: backupPath,
backupHoldsCredentials: backupHoldsCredentials,
vaultKeySalvaged: salvaged.vaultKey != null,
sessionsSalvaged: salvaged.sessions.length,
sessionsLost: salvaged.losses,
@@ -1,5 +1,6 @@
import 'package:collection/collection.dart';
import '../../media/media_rating.dart';
import '../../media/media_kind.dart';
import '../../models/anilist/anilist_media.dart';
import '../../models/catalog/catalog_cast_member.dart';
@@ -218,8 +219,8 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
];
static List<CatalogRatingSource>? _ratingsFor(double? meanRating) =>
meanRating == null ? null : [CatalogRatingSource(source: 'anilist', value: meanRating)];
static List<MediaRatingSource>? _ratingsFor(double? meanRating) =>
meanRating == null ? null : [MediaRatingSource(source: 'anilist', value: meanRating)];
static CatalogNextEpisode? _nextEpisodeFor(AnilistNextAiringEpisode? airing) => airing == null
? null
@@ -363,7 +364,12 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
if (!external.hasAny) return null;
final rows = await _fribb.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
final rows = await _fribb.lookup(
anidbId: external.anidb,
tvdbId: external.tvdb,
tmdbId: external.tmdb,
imdbId: external.imdb,
);
final row = _pickRow(kind, rows);
if (row?.anilistId == null) return null;
return CatalogItemIds(
+6 -1
View File
@@ -368,7 +368,12 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
if (!external.hasAny) return null;
final rows = await _fribb.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
final rows = await _fribb.lookup(
anidbId: external.anidb,
tvdbId: external.tvdb,
tmdbId: external.tmdb,
imdbId: external.imdb,
);
final malId = _pickRow(kind, rows)?.malId;
if (malId == null) return null;
return CatalogItemIds(mal: malId, imdb: external.imdb, tmdb: external.tmdb, tvdb: external.tvdb);
+18 -43
View File
@@ -1,3 +1,4 @@
import '../../media/media_rating.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_cast_member.dart';
import '../../models/catalog/catalog_item.dart';
@@ -7,6 +8,7 @@ import '../../utils/app_logger.dart';
import '../../utils/country_codes.dart';
import '../../utils/json_utils.dart';
import '../plex_discover_client.dart';
import '../plex_mappers.dart';
import 'catalog_source.dart';
import 'catalog_watchlist_machinery.dart';
@@ -101,7 +103,9 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async {
if (!external.hasAny) return null;
// Plex Discover matches on imdb/tmdb/tvdb only; an AniDB-only item has
// nothing to send it.
if (!external.hasCatalogIds) return null;
final metadata = await _client.match(external);
final matchedKind = metadata == null ? null : _kindFor(metadata['type']);
if (metadata == null || matchedKind != kind) return null;
@@ -259,8 +263,8 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
}
}
final headlineRating = _normalizedRating(metadata['rating']);
final audienceRating = _normalizedRating(metadata['audienceRating']);
final headlineRating = normalizedPlexRating(metadata['rating']);
final audienceRating = normalizedPlexRating(metadata['audienceRating']);
return CatalogItem(
source: CatalogSourceId.plex,
kind: kind,
@@ -343,46 +347,17 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
return credits.isEmpty ? null : credits;
}
static List<CatalogRatingSource>? _ratingsFor(Map<String, dynamic> metadata) {
final ratings = <CatalogRatingSource>[];
final imdbVotes = flexibleInt(metadata['imdbRatingCount']);
void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) {
final value = _normalizedRating(rawValue);
if (value == null) return;
final source = _ratingSource(image: image, type: type, fallback: fallbackSource);
if (ratings.any((rating) => rating.source == source && rating.value == value)) return;
ratings.add(CatalogRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null));
}
add(metadata['rating'], fallbackSource: 'critic', image: metadata['ratingImage']);
add(metadata['audienceRating'], fallbackSource: 'audience', image: metadata['audienceRatingImage']);
for (final rating in flexibleMapList(metadata['Rating'])) {
add(rating['value'], fallbackSource: 'audience', image: rating['image'], type: rating['type']);
}
return ratings.isEmpty ? null : ratings;
}
static String _ratingSource({Object? image, Object? type, required String fallback}) {
final scheme = Uri.tryParse(_nonEmptyString(image) ?? '')?.scheme.toLowerCase();
if (scheme == 'imdb') return 'imdb';
if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb';
final ratingType = _nonEmptyString(type)?.toLowerCase() ?? fallback;
if (scheme == 'rottentomatoes') {
return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience';
}
return switch (ratingType) {
'critic' => 'critic',
'audience' => 'audience',
_ => fallback,
};
}
static double? _normalizedRating(Object? value) {
final rating = flexibleDouble(value);
if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null;
return rating > 10 ? rating / 10 : rating;
}
static List<MediaRatingSource>? _ratingsFor(Map<String, dynamic> metadata) => plexRatingSources(
rating: metadata['rating'],
ratingImage: metadata['ratingImage'],
audienceRating: metadata['audienceRating'],
audienceRatingImage: metadata['audienceRatingImage'],
ratingSources: [
for (final rating in flexibleMapList(metadata['Rating']))
(image: rating['image'], type: rating['type'], value: rating['value']),
],
imdbVotes: flexibleInt(metadata['imdbRatingCount']),
);
static String? _overviewFor(Map<String, dynamic> metadata) {
var overview = _nonEmptyString(metadata['summary']);
@@ -1,3 +1,4 @@
import '../../media/media_rating.dart';
import '../../media/media_kind.dart';
import '../../models/catalog/catalog_metadata.dart';
import '../../models/catalog/catalog_item.dart';
@@ -211,14 +212,14 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
if (type == SimklCatalogType.anime && simklId != null) _animeIds.add(simklId);
}
static List<CatalogRatingSource>? _ratingsFor(SimklRatings? ratings) {
static List<MediaRatingSource>? _ratingsFor(SimklRatings? ratings) {
if (ratings == null) return null;
final result = <CatalogRatingSource>[];
final result = <MediaRatingSource>[];
for (final (source, rating) in [('simkl', ratings.simkl), ('imdb', ratings.imdb), ('mal', ratings.mal)]) {
final value = rating?.rating;
if (value == null || value < 0 || value > 10) continue;
final votes = rating!.votes;
result.add(CatalogRatingSource(source: source, value: value, votes: votes != null && votes >= 0 ? votes : null));
result.add(MediaRatingSource(source: source, value: value, votes: votes != null && votes >= 0 ? votes : null));
}
return result.isEmpty ? null : result;
}
@@ -564,7 +565,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null;
@override
Future<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit) async {
@@ -117,7 +117,7 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null;
@override
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
+121 -60
View File
@@ -3,6 +3,7 @@ import '../media/ids.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_item_merge.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_server_client.dart';
@@ -53,6 +54,33 @@ Map<String, int> _searchKindCounts(Iterable<MediaItem> items) {
return counts;
}
/// Drop items belonging to a hidden library.
///
/// Items the backend could not attribute to a library ([MediaItem.libraryGlobalKey]
/// is null) are kept: Plex search and `/library/shared/all` return shared and
/// external rows that have no local section, and those are not something the
/// user hid.
List<MediaItem> _withoutHiddenLibraries(List<MediaItem> items, Set<String>? hiddenLibraryKeys) {
if (hiddenLibraryKeys == null || hiddenLibraryKeys.isEmpty) return items;
return items.where((item) {
final libraryKey = item.libraryGlobalKey;
return libraryKey == null || !hiddenLibraryKeys.contains(libraryKey);
}).toList();
}
/// The server-local library ids [serverId] owns within [hiddenLibraryKeys],
/// which hold cross-server `serverId:libraryId` global keys. Backends that
/// cannot attribute a search hit to a library need these to scope the request.
Set<String> _hiddenLibraryIdsOn(String serverId, Set<String>? hiddenLibraryKeys) {
if (hiddenLibraryKeys == null || hiddenLibraryKeys.isEmpty) return const {};
final ids = <String>{};
for (final key in hiddenLibraryKeys) {
final parsed = parseGlobalKey(key);
if (parsed != null && parsed.serverId == serverId) ids.add(parsed.ratingKey);
}
return ids;
}
/// Cross-server aggregation: fans calls out to every online client and
/// merges the results. Single-server operations now go through the
/// [MediaServerClient] interface directly (resolved via
@@ -171,17 +199,8 @@ class DataAggregationService {
failureMessage: (serverId) => 'Failed on-deck fetch from $serverId',
fetch: (_, client) => client.fetchContinueWatching(count: limit),
);
final allOnDeck = fetched.items;
// Filter out items from hidden libraries
List<MediaItem> filteredOnDeck = allOnDeck;
if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) {
filteredOnDeck = allOnDeck.where((item) {
if (item.libraryId == null || item.serverId == null) return true;
final globalKey = buildGlobalKey(ServerId(item.serverId!), item.libraryId!);
return !hiddenLibraryKeys.contains(globalKey);
}).toList();
}
var filteredOnDeck = _withoutHiddenLibraries(fetched.items, hiddenLibraryKeys);
// Sort by most recently viewed, falling back to addedAt for unwatched items.
// Same key as JellyfinClient's continue-watching merge (MediaItem.recencySortKey)
@@ -387,6 +406,8 @@ class DataAggregationService {
if (tmdb != null) keys.add('$scope:tmdb:$tmdb');
final tvdb = externalIds.tvdb;
if (tvdb != null) keys.add('$scope:tvdb:$tvdb');
final anidb = externalIds.anidb;
if (anidb != null) keys.add('$scope:anidb:$anidb');
}
String? _stableMediaGuid(String? guid) {
@@ -434,29 +455,35 @@ class DataAggregationService {
final serverLibraries = libraries?[serverId];
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
final hubItemLimit = limit ?? defaultHubPreviewLimit;
final hubs = shouldUseGlobalHubs
? [
...await client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs),
// Plex's promoted/global hub endpoint never includes music
// libraries — append their per-library hubs so music rows
// reach home. No-op (zero extra calls) without a visible
// music library.
...await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: serverLibraries ?? const [],
kinds: const {MediaKind.artist},
),
]
: await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: useGlobalHubs ? serverLibraries : null,
);
List<MediaHub> hubs;
if (shouldUseGlobalHubs) {
// Both legs are independent, so start them before awaiting either.
// Spreading `...await a, ...await b` into one list literal evaluates
// them in order, which serialised the music rows behind the global
// hub round trip.
final globalFuture = client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs);
// Plex's promoted/global hub endpoint never includes music
// libraries — append their per-library hubs so music rows
// reach home. No-op (zero extra calls) without a visible
// music library.
final musicFuture = _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: serverLibraries ?? const [],
kinds: const {MediaKind.artist},
);
hubs = [...await globalFuture, ...await musicFuture];
} else {
hubs = await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: useGlobalHubs ? serverLibraries : null,
);
}
return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys);
},
);
@@ -487,31 +514,39 @@ class DataAggregationService {
return true;
}).toList();
// Sliding window rather than batches of three separated by a barrier: a
// batch waits for its slowest member before the next one starts, so wall
// time was the sum of per-batch maxima and one slow library stalled every
// library queued behind it (#1784). Starting the next request the moment
// any slot frees keeps the same peak concurrency with no head-of-line
// blocking. Results are written back by index so hub order stays the
// library order regardless of completion order.
const concurrency = 3;
final all = <MediaHub>[];
for (var start = 0; start < visible.length; start += concurrency) {
final batch = visible.skip(start).take(concurrency);
final results = await Future.wait(
batch.map((l) async {
try {
return await client.fetchLibraryHubs(
l.id,
libraryName: l.title,
limit: limit,
includePlaybackHubs: includePlaybackHubs,
libraryKind: l.kind,
);
} catch (e, st) {
appLogger.e('Failed to fetch library hubs for ${l.globalKey}', error: e, stackTrace: st);
return <MediaHub>[];
}
}),
);
for (final list in results) {
all.addAll(list);
final results = List<List<MediaHub>>.filled(visible.length, const []);
var next = 0;
Future<void> worker() async {
while (true) {
final index = next++;
if (index >= visible.length) return;
final library = visible[index];
try {
results[index] = await client.fetchLibraryHubs(
library.id,
libraryName: library.title,
limit: limit,
includePlaybackHubs: includePlaybackHubs,
libraryKind: library.kind,
);
} catch (e, st) {
appLogger.e('Failed to fetch library hubs for ${library.globalKey}', error: e, stackTrace: st);
}
}
}
return all;
await Future.wait([for (var i = 0; i < concurrency && i < visible.length; i++) worker()]);
return [for (final list in results) ...list];
}
/// Filter hidden-library items and drop empty hubs.
@@ -537,7 +572,16 @@ class DataAggregationService {
/// Search across all online servers (Plex + Jellyfin). Per-server outcomes
/// distinguish authoritative empty results from failed or cancelled legs.
Future<SearchAggregationResult> searchAcrossServers(String query, {int? limit, AbortController? abort}) async {
///
/// [hiddenLibraryKeys] excludes results the user has hidden, matching every
/// other aggregated surface. Backends whose search rows carry no library id
/// cannot be filtered here; they must scope the search server-side instead.
Future<SearchAggregationResult> searchAcrossServers(
String query, {
int? limit,
Set<String>? hiddenLibraryKeys,
AbortController? abort,
}) async {
if (query.trim().isEmpty) {
return (
items: const <MediaItem>[],
@@ -566,7 +610,12 @@ class DataAggregationService {
failureMessage: (serverId) => 'Search failed on $serverId',
fetch: (serverId, client) async {
final stopwatch = Stopwatch()..start();
final items = await client.searchItems(query, limit: fetchLimit, abort: abort);
final items = await client.searchItems(
query,
limit: fetchLimit,
abort: abort,
excludedLibraryIds: _hiddenLibraryIdsOn(serverId, hiddenLibraryKeys),
);
appLogger.i(
'Search completed on $serverId in ${stopwatch.elapsedMilliseconds}ms: '
'${items.length} results ${_searchKindCounts(items)}',
@@ -575,7 +624,10 @@ class DataAggregationService {
},
);
abort?.throwIfAborted();
final items = rankMediaSearchResults(fetched.items, query, limit: resultLimit);
// Before ranking, so hidden results cannot spend the `resultLimit` budget
// and silently shrink what the user sees.
final visible = _withoutHiddenLibraries(fetched.items, hiddenLibraryKeys);
final items = rankMediaSearchResults(visible, query, limit: resultLimit);
appLogger.i(
'Search aggregation completed: ${items.length} results '
@@ -594,6 +646,15 @@ class DataAggregationService {
/// Reverse external-id lookup fanned out to every online server (see
/// [MediaServerClient.findByExternalIds]). One request wave per tap on an
/// Explore catalog item; per-server failures are logged and skipped.
///
/// Every server contributes every copy it holds, not one apiece: the same
/// movie routinely sits in a 4K library and an HD library on one server
/// (#1754). Results are deduped by global key and ordered best-first with
/// [compareLibraryCopies] so the chooser is stable across repeated passes.
///
/// Because per-server failures are dropped here, a caller holding earlier
/// results must merge rather than replace (see [mergeLibraryCopies]) — a
/// degraded wave is not evidence that a copy went away.
Future<List<MediaItem>> findByExternalIdsAcrossServers(
ExternalIds ids, {
required MediaKind kind,
@@ -618,11 +679,11 @@ class DataAggregationService {
);
} catch (e, st) {
appLogger.w('External-id lookup failed on ${entry.key}', error: e, stackTrace: st);
return null;
return const <MediaItem>[];
}
});
return (await Future.wait(futures)).nonNulls.toList();
return mergeLibraryCopies(const [], (await Future.wait(futures)).expand((items) => items));
}
/// Group libraries by server (internal aggregation helper).
+7 -4
View File
@@ -253,14 +253,17 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
// readers above do: one place to compare when a server adds a key.
// ---------------------------------------------------------------------------
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–5;
/// lyrics/other types beyond that map to [MediaStreamKind.unknown].
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–4
/// ([PlexStreamType]); anything else maps to [MediaStreamKind.unknown].
///
/// Type 4 is lyrics, not an embedded image — music tracks are the only items
/// that carry it, so the mismatch only became visible once File Info was
/// offered for tracks.
MediaStreamKind plexStreamKind(Map<String, dynamic> s) => switch (flexibleInt(s['streamType'])) {
PlexStreamType.video => MediaStreamKind.video,
PlexStreamType.audio => MediaStreamKind.audio,
PlexStreamType.subtitle => MediaStreamKind.subtitle,
4 => MediaStreamKind.image,
5 => MediaStreamKind.data,
PlexStreamType.lyrics => MediaStreamKind.lyric,
_ => MediaStreamKind.unknown,
};
+7
View File
@@ -28,12 +28,14 @@ class _JellyfinAuthenticationResponse {
final String userId;
final String userName;
final bool isAdministrator;
final String? primaryImageTag;
const _JellyfinAuthenticationResponse({
required this.accessToken,
required this.userId,
required this.userName,
required this.isAdministrator,
this.primaryImageTag,
});
}
@@ -149,6 +151,7 @@ class JellyfinConnectionAuthService {
accessToken: auth.accessToken,
deviceId: validDeviceId,
isAdministrator: auth.isAdministrator,
primaryImageTag: auth.primaryImageTag,
);
} finally {
client.close();
@@ -316,6 +319,7 @@ class JellyfinConnectionAuthService {
accessToken: auth.accessToken,
deviceId: validDeviceId,
isAdministrator: auth.isAdministrator,
primaryImageTag: auth.primaryImageTag,
);
} finally {
exchangeClient.close();
@@ -419,6 +423,7 @@ class JellyfinConnectionAuthService {
userId: userId,
userName: userName,
isAdministrator: policy?['IsAdministrator'] as bool? ?? false,
primaryImageTag: JellyfinConnection.readPrimaryImageTag(user),
);
} on TimeoutException {
// MediaServerHttpClient normally wraps timeouts, but keep raw client
@@ -445,6 +450,7 @@ class JellyfinConnectionAuthService {
required String accessToken,
required String deviceId,
required bool isAdministrator,
required String? primaryImageTag,
}) {
final now = DateTime.now();
return JellyfinConnection(
@@ -458,6 +464,7 @@ class JellyfinConnectionAuthService {
accessToken: accessToken,
deviceId: deviceId,
isAdministrator: isAdministrator,
primaryImageTag: primaryImageTag,
status: ConnectionStatus.online,
createdAt: now,
lastAuthenticatedAt: now,
+34 -15
View File
@@ -39,6 +39,7 @@ import '../utils/app_logger.dart';
import '../utils/device_identity.dart';
import '../utils/failover_http_client.dart';
import '../utils/media_server_retry.dart';
import '../utils/future_extensions.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/external_ids.dart';
@@ -111,7 +112,12 @@ class JellyfinClient
_JellyfinLiveTvMethods,
_JellyfinImageDownloadMethods,
_JellyfinMetadataEditMethods
implements MediaServerClient, SeasonEpisodePagingClient, ScopedMediaServerClient, GracefullyCloseable {
implements
MediaServerClient,
SeasonEpisodePagingClient,
MediaDeletionPermissionClient,
ScopedMediaServerClient,
GracefullyCloseable {
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
@@ -171,6 +177,11 @@ class JellyfinClient
baseUrl: connection.baseUrl,
defaultHeaders: headers,
logLabel: 'Jellyfin',
// Same pool tuning Plex uses: the home fan-out issues several concurrent
// requests per pass, and the untuned dart:io default drops idle
// connections after 15s — a fresh TLS handshake per request on a
// high-RTT/CDN link.
usePlexApiClient: true,
prioritizedEndpoints: connection.baseUrls,
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
onAllEndpointsExhausted: onAllEndpointsExhausted,
@@ -311,9 +322,10 @@ class JellyfinClient
/// real call to 401.
///
/// Side-effect: when the response body carries a fresh
/// `Policy.IsAdministrator` that differs from the cached one, refresh the
/// connection so admin-gated UI catches the server-side change without
/// requiring re-auth (see [onConnectionUpdated]).
/// `Policy.IsAdministrator` or primary profile-picture tag that differs
/// from the cached value, refresh the connection so admin-gated UI and
/// profile avatars catch server-side changes without requiring re-auth
/// (see [onConnectionUpdated]).
///
/// 401/403 surfaces as [HealthStatus.authError] so the manager can
/// distinguish a revoked token from a generic transport failure.
@@ -326,17 +338,24 @@ class JellyfinClient
final data = response.data;
if (data is Map<String, dynamic>) {
final policy = data['Policy'];
if (policy is Map<String, dynamic>) {
final fresh = policy['IsAdministrator'] as bool?;
if (fresh != null && fresh != _connection.isAdministrator) {
_connection = _connection.copyWith(isAdministrator: fresh);
final listener = onConnectionUpdated;
if (listener != null) {
try {
await Future.sync(() => listener(_connection));
} catch (e, st) {
appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
}
final freshIsAdministrator = policy is Map<String, dynamic> ? policy['IsAdministrator'] as bool? : null;
final freshPrimaryImageTag = JellyfinConnection.readPrimaryImageTag(data);
final isAdministratorChanged =
freshIsAdministrator != null && freshIsAdministrator != _connection.isAdministrator;
final primaryImageTagChanged = freshPrimaryImageTag != _connection.primaryImageTag;
if (isAdministratorChanged || primaryImageTagChanged) {
_connection = _connection.copyWith(
isAdministrator: freshIsAdministrator,
primaryImageTag: freshPrimaryImageTag,
clearPrimaryImageTag: primaryImageTagChanged && freshPrimaryImageTag == null,
);
final listener = onConnectionUpdated;
if (listener != null) {
try {
await Future.sync(() => listener(_connection));
} catch (e, st) {
appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
}
}
}
+316 -51
View File
@@ -2,23 +2,21 @@ part of '../../jellyfin_client.dart';
String _segment(String value) => Uri.encodeComponent(value);
/// Transport policy for a hub surface: bounded transient retries, no
/// endpoint failover. See `_getItemsResponse`.
typedef _HubRetryPolicy = ({String operation, List<Duration> attemptTimeouts});
/// Transport policy for a hub surface: one whole-request deadline, retries
/// only on immediate connection errors, no endpoint failover. See
/// `_getItemsResponse` and [retryTransientMediaServerCall].
typedef _HubRetryPolicy = ({String operation, Duration deadline});
const _HubRetryPolicy _homeHubRetry = (
operation: 'Jellyfin home hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
);
const _HubRetryPolicy _homeHubRetry = (operation: 'Jellyfin home hubs', deadline: MediaServerTimeouts.homeHubDeadline);
const _HubRetryPolicy _libraryHubRetry = (
operation: 'Jellyfin library hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
deadline: MediaServerTimeouts.libraryHubDeadline,
);
const _HubRetryPolicy _continueWatchingRetry = (
operation: 'Jellyfin continue watching',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
deadline: MediaServerTimeouts.homeHubDeadline,
);
List<Map<String, dynamic>> _itemsArray(Object? data) {
@@ -58,21 +56,57 @@ LibraryPage<T> _pagedItems<T>(
/// list calls; we ask for the minimum extras needed to drive the
/// MediaItem mapper:
/// - `RecursiveItemCount`/`ChildCount` for series leaf count
/// - `UserData` is included in defaults but pinned for safety
/// - `PremiereDate` for sort-by-release-date and episode metadata
/// - `OriginalTitle`/`SortName` for sort + alphabetised display
/// - `Overview` so list rows can show their description
///
/// Heavier fields (`MediaSources`, `People`, `Genres`, `Tags`, `Studios`,
/// `Taglines`, `ProviderIds`, `Chapters`) stay in [_detailFields] — together
/// they added seconds to large-library pages on small home servers.
const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview';
///
/// `UserData` and `PremiereDate` are deliberately absent: neither is a member
/// of Jellyfin's `ItemFields` enum, so the server's
/// `CommaDelimitedCollectionModelBinder` drops them element-by-element and
/// they never did anything. `UserData` is governed by `EnableUserData`
/// (default true) and `dto.PremiereDate` is set unconditionally.
const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview';
/// Field set for the home / per-library hub rows (Recently Added, Continue
/// Watching, Next Up). Poster cards render artwork, title, year and the
/// watch badge; only `Overview` needs asking for, to feed the mobile hero
/// (`discover_screen`) and the TV spotlight blurb.
///
/// Crucially this drops `RecursiveItemCount`/`ChildCount`. `/Items/Latest`
/// groups a TV library by series, so those rows are Series FOLDER dtos and
/// each count field costs a per-row DB query server-side
/// (`Folder.GetRecursiveChildCount` / `Folder.GetChildCount`) — the same cost
/// already documented on [_folderBrowseFields] and [_musicAlbumRowFields],
/// which the home rows never got (#1784).
///
/// Watch state survives intact: Jellyfin computes `UserData.Played` from
/// `UnplayedItemCount` when `RecursiveItemCount` is absent
/// (`Folder.FillUserDataDtoValues`), and [MediaItem.unwatchedCount] falls back
/// to `UserData.UnplayedItemCount`. Only the season progress bar needs real
/// leaf totals, and seasons never appear on a hub row.
const _hubRowFields = 'Overview';
/// How far back `/Shows/NextUp` looks for a series to resume, mirroring
/// Jellyfin web's `maxDaysForNextUp` default. Without it the server's
/// `GetNextUpSeriesKeys` scan is unbounded over every series the user has ever
/// played an episode of.
const _nextUpDateCutoffDays = 365;
String _nextUpDateCutoff() =>
DateTime.now().toUtc().subtract(const Duration(days: _nextUpDateCutoffDays)).toIso8601String();
/// Existing episode-row requests can show Plex-style quality labels when the
/// response includes `MediaSources`. Keep this off broad library/search/latest
/// queries because it is the heaviest item field Jellyfin returns.
const _episodeRowFields = '$_browseFields,MediaSources';
/// Media types global search surfaces. Episodes are included so a user can
/// find a single episode by name.
const _searchItemTypes = 'Movie,Series,Episode,MusicAlbum,Audio';
/// Folder-tree field set for MEDIA children. The tree renders
/// title/thumb/watch state plus default dto fields (year, runtime, ratings);
/// it deliberately skips `RecursiveItemCount`/`ChildCount` — per-item COUNT
@@ -197,9 +231,51 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
// endpoints. We mirror that exactly so requests hash the same way against
// proxy rules and rate limiters as a stock Jellyfin app.
/// Views as of the last load, reused by scoped search.
///
/// Scoped search runs on every debounced keystroke and `/Views` sits
/// serially in front of its per-library legs, so re-fetching it each pass is
/// pure added latency. `LibrariesProvider` loads libraries before the content
/// tabs refresh (main_screen `_primeOnlineServices` awaits `loadLibraries()`),
/// so this is already warm by the time the user can type, and every later
/// load replaces it — search can never be working from a staler library list
/// than the one on screen. Dies with the client, like Plex's
/// `_providerLibraries`.
List<MediaLibrary>? _loadedLibraryViews;
/// In-flight `/Views` request, shared by concurrent callers.
///
/// At cold start `LibrariesProvider.loadLibraries()` and
/// `DataAggregationService.getHubsFromAllServers` both ask for libraries at
/// the same time, and the hub fan-out sits *serially* behind its copy. Two
/// identical uncached round trips is a full RTT of pure cold-start latency on
/// a remote server (#1784). Plex has never paid it — its library list comes
/// from the `/media/providers` response cached at client creation.
///
/// Single-flight only: once the request settles the next caller re-fetches,
/// so a library added server-side still shows up on the next refresh.
Future<List<MediaLibrary>>? _inFlightLibraries;
@override
Future<List<MediaLibrary>> fetchLibraries() async {
final response = await _http.get('/Users/${_segment(connection.userId)}/Views');
Future<List<MediaLibrary>> fetchLibraries() {
final inFlight = _inFlightLibraries;
if (inFlight != null) return inFlight;
final request = _fetchLibraries().then((libraries) {
_loadedLibraryViews = libraries;
return libraries;
});
_inFlightLibraries = request;
return request.whenComplete(() {
if (identical(_inFlightLibraries, request)) _inFlightLibraries = null;
});
}
/// [abort] tears the view fetch down with the pass that owns it — a
/// superseded search must not leave `/Views` running.
Future<List<MediaLibrary>> _fetchLibraries({AbortController? abort}) async {
final response = await _http.get('/Users/${_segment(connection.userId)}/Views', abort: abort);
abort?.throwIfAborted();
throwIfHttpError(response);
final items = _itemsArray(response.data);
// Jellyfin surfaces the user's collection (BoxSet) and playlist roots as
@@ -536,12 +612,23 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
/// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex
/// OnDeck semantics: returns the resume episode when one exists, or S1E1
/// when the user hasn't started. Movies and other kinds short-circuit.
///
/// The chain stays sequential — firing both together measured no better,
/// because the requests contend rather than overlap (`/Shows/NextUp` went
/// from 380ms alone to 1395ms beside the detail fetch) and a movie would pay
/// for a request it can never use. Instead the item is handed to
/// [onItemReady] the moment it lands, so the caller can paint without
/// waiting for the on-deck round trip (#1784).
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
String id, {
void Function(MediaItem item)? onItemReady,
}) async {
final item = await fetchItem(id);
if (item == null || item.kind != MediaKind.show) {
return (item: item, onDeckEpisode: null);
}
onItemReady?.call(item);
final nextUp = await _safeFetchItemsArray('/Shows/NextUp', {
'seriesId': id,
'userId': connection.userId,
@@ -553,8 +640,33 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
return (item: item, onDeckEpisode: onDeckEpisode);
}
/// In-flight `fetchItem` requests, keyed by item id.
///
/// Opening a detail screen issues two identical full-detail GETs for the
/// same id at the same time — `_loadFullMetadata` and, from
/// `_initWatchlistState`, `fetchExternalIds` — and playback start adds three
/// more. Each one makes the server rebuild the whole dto (People, Chapters
/// and MediaSources are a DB query apiece, Trickplay several), so the
/// duplicates are expensive on both ends (#1784).
///
/// Single-flight only: once a request settles the next caller re-fetches, so
/// nothing here can serve a stale item.
final Map<String, Future<MediaItem?>> _inFlightItems = {};
@override
Future<MediaItem?> fetchItem(String id) async {
Future<MediaItem?> fetchItem(String id) {
final existing = _inFlightItems[id];
if (existing != null) return existing;
late final Future<MediaItem?> request;
request = _fetchItemOnce(id).whenComplete(() {
if (identical(_inFlightItems[id], request)) _inFlightItems.remove(id);
});
_inFlightItems[id] = request;
return request;
}
Future<MediaItem?> _fetchItemOnce(String id) async {
final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}';
// Contract:
// - 200 with parseable Map → MediaItem
@@ -1084,25 +1196,85 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
// only matches folder-derived MusicArtist rows (under folder names), so
// tag-only artists would never appear in search. The artists leg is
// best-effort — a music-endpoint hiccup shouldn't sink video search.
Future<List<MediaItem>> searchItems(
String query, {
int limit = 100,
AbortController? abort,
Set<String> excludedLibraryIds = const {},
}) async {
if (excludedLibraryIds.isEmpty) return _searchEverywhere(query, limit: limit, abort: abort);
// Jellyfin search rows cannot be attributed to a library after the fact:
// there is no library field, and `ParentId` (which this request does not
// even ask for) resolves to a season or physical folder, never the owning
// CollectionFolder. A hidden library can therefore only be excluded by
// scoping the request, one per visible library.
var libraries = _loadedLibraryViews;
if (libraries == null) {
libraries = await _fetchLibraries(abort: abort);
// `??=`, not `=`: an explicit library load that started later can finish
// first, and this older response must not clobber its newer views.
_loadedLibraryViews ??= libraries;
}
abort?.throwIfAborted();
final visible = [
for (final library in libraries)
if (!excludedLibraryIds.contains(library.id)) library,
];
// Nothing hidden actually belongs to this server — keep the single query.
if (visible.length == libraries.length) return _searchEverywhere(query, limit: limit, abort: abort);
if (visible.isEmpty) return const [];
// Each leg keeps the full candidate budget. Splitting it would cap a
// library that holds every match (two visible libraries, 100 matches in
// one, would return 50), and the caller's pre-ranking budget guarantee is
// worth more than the payload saved.
const concurrency = 3;
// Legs can overlap: `/Artists` resolves parentId to an *ancestor* filter,
// so an artist with tracks in two visible music libraries comes back from
// both. Ranking does not deduplicate, so the first hit wins here — the
// same merge the Plex search does across its supplemental legs.
final deduplicated = <String, MediaItem>{};
for (var start = 0; start < visible.length; start += concurrency) {
abort?.throwIfAborted();
final batch = visible.skip(start).take(concurrency);
final results = await Future.wait([
for (final library in batch) _searchLibrary(library, query, limit: limit, abort: abort),
]);
for (final items in results) {
for (final item in items) {
deduplicated.putIfAbsent(item.id, () => item);
}
}
}
return deduplicated.values.toList();
}
/// Unscoped search: one request across every library the user can see.
///
/// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
/// only matches folder-derived MusicArtist rows (under folder names), so
/// tag-only artists would never appear in search. The artists leg is
/// best-effort — a music-endpoint hiccup shouldn't sink video search.
Future<List<MediaItem>> _searchEverywhere(String query, {required int limit, AbortController? abort}) async {
final results = await Future.wait([
_fetchItemsArray('/Items', {
'userId': connection.userId,
'SearchTerm': query,
'Recursive': 'true',
'Limit': limit.toString(),
'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio',
'IncludeItemTypes': _searchItemTypes,
'Fields': _browseFields,
// Search ranks and trims client-side and never reads the total, which
// the server pays for separately on a broad term.
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, abort: abort),
_safeFetchItemsArray('/Artists', {
'userId': connection.userId,
'searchTerm': query,
'Limit': limit.toString(),
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, abort: abort),
]);
@@ -1110,17 +1282,94 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
return _mapItems([...results.first, ...results[1]]);
}
/// Search a single library. Results are stamped with it, which is the only
/// way a Jellyfin search hit ever learns which library it came from.
Future<List<MediaItem>> _searchLibrary(
MediaLibrary library,
String query, {
required int limit,
AbortController? abort,
}) async {
// MusicAlbum is a folder DTO: requesting UserData or count fields makes
// small servers compute recursive unplayed/count data per album (#1552).
// Keep albums and Audio leaves on separate requests so albums can disable
// UserData while tracks retain their cheap direct play-state lookup.
final itemFutures = library.kind == MediaKind.artist
? <Future<List<Map<String, dynamic>>>>[
_fetchItemsArray('/Items', {
'userId': connection.userId,
'SearchTerm': query,
'Recursive': 'true',
'Limit': limit.toString(),
'IncludeItemTypes': 'MusicAlbum',
'ParentId': library.id,
'Fields': _musicAlbumRowFields,
'EnableUserData': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, abort: abort),
_fetchItemsArray('/Items', {
'userId': connection.userId,
'SearchTerm': query,
'Recursive': 'true',
'Limit': limit.toString(),
'IncludeItemTypes': 'Audio',
'ParentId': library.id,
'Fields': _musicTrackRowFields,
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, abort: abort),
]
: <Future<List<Map<String, dynamic>>>>[
_fetchItemsArray('/Items', {
'userId': connection.userId,
'SearchTerm': query,
'Recursive': 'true',
'Limit': limit.toString(),
'IncludeItemTypes': _searchItemTypes,
'ParentId': library.id,
'Fields': _browseFields,
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, abort: abort),
];
// `/Artists` takes parentId and resolves it to an ancestor filter, but
// only a music library can contain any — asking elsewhere just buys an
// empty response.
final artistsFuture = library.kind == MediaKind.artist
? _safeFetchItemsArray('/Artists', {
'userId': connection.userId,
'searchTerm': query,
'Limit': limit.toString(),
'parentId': library.id,
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, abort: abort)
: Future<List<Map<String, dynamic>>>.value(const []);
final results = await Future.wait([...itemFutures, artistsFuture]);
abort?.throwIfAborted();
return [
for (final item in _mapItems([for (final result in results) ...result]))
item.copyWith(libraryId: library.id, libraryTitle: library.title),
];
}
/// Jellyfin removed `anyProviderIdEquals` (silently ignored on 10.11.10, so
/// it returns the unfiltered page), leaving a title search verified against
/// each candidate's inline `ProviderIds`. [plexGuid] is a Plex-only hint and
/// has no meaning in Jellyfin's provider-id model, so it is ignored.
///
/// Every id-verified candidate of the first matching title is returned, not
/// just the first: one movie can sit in both a 4K library and an HD library
/// as two separate items, and the caller shows the user each copy (#1754).
///
/// Jellyfin cannot report season ordering to a non-admin: on 10.11.10,
/// `/Library/VirtualFolders` returns 403 and Series items omit
/// `DisplayOrder`. Resolve against an unknown provider rather than guessing
/// from `ProviderIds`, so only seasons on which TVDB and TMDB agree are gated.
@override
Future<MediaItem?> findByExternalIds(
Future<List<MediaItem>> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -1133,7 +1382,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
MediaKind.show => 'Series',
_ => null,
};
if (itemType == null || !ids.hasAny || titles.isEmpty) return null;
if (itemType == null || !ids.hasAny || titles.isEmpty) return const [];
final seasonIndex = season?.agreedSeason;
final shouldGateSeason = kind == MediaKind.show && seasonIndex != null && seasonIndex > 1;
@@ -1155,20 +1404,34 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'years': ?years,
...jellyfinImageQueryParameters,
});
final match = ExternalIds.jellyfinCandidateMatching(candidates, ids);
if (match == null) continue;
final item = _mapItems([match]).firstOrNull;
if (item == null) continue;
final matches = _mapItems(ExternalIds.jellyfinCandidatesMatching(candidates, ids));
if (matches.isEmpty) continue;
if (shouldGateSeason) {
final children = await fetchChildren(item.id);
if (!children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)) {
return null;
}
}
return _withLibraryFromAncestors(item);
final kept = shouldGateSeason ? await _keepMatchesWithSeason(matches, seasonIndex) : matches;
// A title that verified but has no season-gated survivor is a definitive
// "this server has the show, just not that season"; broader title forms
// would only reach other shows.
if (kept.isEmpty) return const [];
return Future.wait([for (final item in kept) _withLibraryFromAncestors(item)]);
}
return null;
return const [];
}
/// Keep only the series that actually have [seasonIndex]. One
/// `fetchChildren` per candidate, issued concurrently because the match list
/// is deliberately never truncated (see
/// [MediaServerClient.findByExternalIds]).
Future<List<MediaItem>> _keepMatchesWithSeason(List<MediaItem> items, int seasonIndex) async {
final kept = await Future.wait([
for (final item in items)
fetchChildren(
item.id,
).then((children) => children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)),
]);
return [
for (var index = 0; index < items.length; index++)
if (kept[index]) items[index],
];
}
/// Best-effort library stamp for items found outside a library context
@@ -1238,7 +1501,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
_fetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': ?count?.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'MediaTypes': 'Video',
'Recursive': 'true',
'EnableTotalRecordCount': 'false',
@@ -1247,8 +1510,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': ?count?.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'EnableResumable': 'false',
'NextUpDateCutoff': _nextUpDateCutoff(),
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, retry: _continueWatchingRetry),
@@ -1343,7 +1607,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': ?parentId,
'Fields': _browseFields,
'Fields': _hubRowFields,
'IncludeItemTypes': ?latestItemTypes,
...jellyfinImageQueryParameters,
}, retry: retry);
@@ -1371,7 +1635,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'ParentId': ?parentId,
'Limit': limit.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'MediaTypes': 'Video',
'Recursive': 'true',
'EnableTotalRecordCount': 'false',
@@ -1382,8 +1646,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'ParentId': ?parentId,
'Limit': limit.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'EnableResumable': 'false',
'NextUpDateCutoff': _nextUpDateCutoff(),
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, retry: retry)
@@ -1525,7 +1790,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'IncludeItemTypes': 'Movie,Series,Episode,Video,MusicVideo,Photo',
'SortBy': 'DateCreated,SortName,ProductionYear',
'SortOrder': 'Descending,Descending,Descending',
'Fields': _browseFields,
'Fields': _hubRowFields,
...jellyfinImageQueryParameters,
},
offset: offset,
@@ -1556,7 +1821,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'StartIndex': offset.toString(),
'Limit': effectiveLimit,
'Fields': _browseFields,
'Fields': _hubRowFields,
'Recursive': 'true',
'EnableTotalRecordCount': 'true',
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
@@ -1573,9 +1838,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'StartIndex': offset.toString(),
'Limit': effectiveLimit,
'Fields': _browseFields,
'Fields': _hubRowFields,
'ParentId': ?parentId,
'EnableResumable': 'false',
'NextUpDateCutoff': _nextUpDateCutoff(),
'EnableTotalRecordCount': 'true',
...jellyfinImageQueryParameters,
},
@@ -1860,14 +2126,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
}
/// GET [path], optionally under a hub-surface transport policy ([retry]):
/// bounded transient retries with per-attempt timeouts and **no endpoint
/// failover** — a slow hub row must not move the whole client off an
/// otherwise working endpoint (same policy as Plex's three hub fetches;
/// see [retryTransientMediaServerCall] / [FailoverHttpClient]).
/// one whole-request deadline, retries only on immediate connection errors,
/// and **no endpoint failover** — a slow hub row must not move the whole
/// client off an otherwise working endpoint (same policy as Plex's three hub
/// fetches; see [retryTransientMediaServerCall] / [FailoverHttpClient]).
///
/// [timeout] and [allowEndpointFailover] configure the un-retried path only; a
/// [retry] policy carries its own per-attempt timeouts and always disables
/// failover.
/// [retry] policy carries its own deadline and always disables failover.
Future<MediaServerResponse> _getItemsResponse(
String path,
Map<String, dynamic> queryParameters,
@@ -1888,7 +2153,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
abort?.throwIfAborted();
return retryTransientMediaServerCall(
operation: retry.operation,
attemptTimeouts: retry.attemptTimeouts,
deadline: retry.deadline,
call: (timeout, attemptAbort) => _http.get(
path,
queryParameters: queryParameters,
@@ -144,4 +144,54 @@ mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
throwIfHttpError(response);
return true;
}
/// `/Items?ids=` rather than `/Users/{id}/Items/{id}`: the single-item route
/// ignores `Fields` and returns the whole dto (measured 33 KB / ~150 ms on a
/// remote server), while the list route honours it and answers with a ~0.5 KB
/// body in ~40 ms. Images and user data are switched off for the same reason.
///
/// An id the user cannot see comes back as an empty `Items` array, which is
/// the same answer as "not allowed" for gating purposes.
///
/// A context menu waits on this, so the probe carries a real wall-clock
/// ceiling: [MediaServerHttpClient] applies its `timeout` to the connect and
/// receive phases separately (so it alone would allow roughly double), and
/// `allowEndpointFailover: false` keeps a dead endpoint from walking the
/// candidate list while the user holds a long-press. On expiry the request is
/// aborted rather than left running, and the timeout propagates so the caller
/// fails closed.
// No `@override`: like [fetchSeasonEpisodesPage], this satisfies an optional
// capability interface that the concrete client implements, not a member of
// the mixin's superclass constraint.
Future<bool?> fetchDeletePermission(MediaItem item) async {
final abort = AbortController();
try {
final response = await _http
.get(
'/Items',
queryParameters: {
'ids': item.id,
'userId': connection.userId,
'Fields': 'CanDelete',
'EnableImages': 'false',
'EnableUserData': 'false',
'EnableTotalRecordCount': 'false',
},
timeout: MediaServerTimeouts.jellyfinDeletePermission,
abort: abort,
allowEndpointFailover: false,
)
.namedTimeout(MediaServerTimeouts.jellyfinDeletePermission, operation: 'jellyfin delete permission');
throwIfHttpError(response);
final items = _itemsArray(response.data);
if (items.isEmpty) return false;
return items.first['CanDelete'] as bool?;
} on TimeoutException catch (e) {
// Stop the request rather than leave it running, and hand the caller the
// same exception shape `MediaServerHttpClient` raises for its own
// per-phase expiries.
abort.abort();
throw MediaServerHttpException.from(e);
}
}
}
@@ -176,7 +176,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
final preset = options.qualityPreset;
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final requestedAudioStreamId = options.selectedAudioStreamId == null
? options.preferredAudioTrack == null
? null
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
final int? maxStreamingBitrate = wantsOriginal
? null
+66
View File
@@ -5,6 +5,7 @@ import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_part.dart';
import '../media/media_rating.dart';
import '../media/media_role.dart';
import '../media/media_stream.dart';
import '../media/media_version.dart';
@@ -127,6 +128,40 @@ class JellyfinImageAbsolutizer {
}
}
/// Absolute URL of a Jellyfin user's own profile picture, or `null` when the
/// user has none (absent [tag]) — returning null keeps us from firing a
/// request that can only 404.
///
/// Unlike item artwork this endpoint carries **no `api_key`**: the user-image
/// GET has never been authenticated (no `[Authorize]`, and Jellyfin sets no
/// ASP.NET `FallbackPolicy`) on any release from 10.6 through 12.0-dev.
/// Leaving the token out keeps it off the image cache key and out of anything
/// that logs or persists the URL.
///
/// The legacy `/Users/{id}/Images/Primary` route is used rather than 10.9's
/// `/UserImage` because Plezy declares no minimum server version; upstream
/// still routes the legacy shape and annotates it "Kept for backwards
/// compatibility". The `{imageType}` segment is bound but ignored server-side
/// — it always serves the profile image.
///
/// [tag] is the server's `PrimaryImageTag`, `MD5(imagePath + lastModified)`,
/// so the URL changes exactly when the picture does and is a safe immutable
/// cache key. [maxSize] is honoured up to 10.10 and silently ignored from
/// 10.11 on, so callers must still bound the decode themselves.
String? jellyfinUserImageUrl({
required String baseUrl,
required String userId,
required String? tag,
int maxSize = 240,
}) {
if (baseUrl.isEmpty || userId.isEmpty || tag == null || tag.isEmpty) return null;
final uri = JellyfinImageAbsolutizer.joinUri(
baseUrl: baseUrl,
urlOrPath: '/Users/${Uri.encodeComponent(userId)}/Images/Primary',
);
return uri.replace(queryParameters: {'tag': tag, 'maxWidth': '$maxSize', 'maxHeight': '$maxSize'}).toString();
}
/// Pure mapping functions from Jellyfin's `BaseItemDto` JSON shape into the
/// neutral [MediaItem] / [MediaLibrary] domain types.
///
@@ -236,6 +271,7 @@ class JellyfinMappers {
addedAt: jellyfinIsoToEpochSeconds(item['DateCreated'] as String?),
updatedAt: jellyfinIsoToEpochSeconds(item['DateLastSaved'] as String? ?? item['DateModified'] as String?),
rating: (item['CommunityRating'] as num?)?.toDouble(),
ratings: _ratingSources(item, kind),
isFavorite: _userData(item)?['IsFavorite'] as bool?,
genres: _stringList(item['Genres']),
directors: _peopleByType(item['People'], 'Director'),
@@ -368,6 +404,36 @@ class JellyfinMappers {
return total - unplayed;
}
/// Jellyfin's two rating slots, community score first.
///
/// `BaseItemDto` has no per-source array — the server collapses whatever the
/// metadata fetchers found into these two fields, so this is at most two
/// entries. Both arrive on every response; neither is gated behind `Fields`.
///
/// `CommunityRating` is 0-10 but its 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 badge.
/// `CriticRating` is the Rotten Tomatoes Tomatometer as a 0-100 percent, so
/// it is divided rather than range-sniffed: a Tomatometer of 9 means 9%.
static List<MediaRatingSource>? _ratingSources(Map<String, dynamic> item, MediaKind kind) {
// Photo items reuse CommunityRating for the EXIF 0-5 star rating.
if (kind == MediaKind.photo) return null;
final ratings = <MediaRatingSource>[];
if (_finiteRating(item['CommunityRating']) case final community? when community >= 0 && community <= 10) {
ratings.add(MediaRatingSource(source: 'audience', value: community));
}
if (_finiteRating(item['CriticRating']) case final critic? when critic >= 0 && critic <= 100) {
ratings.add(MediaRatingSource(source: 'rottenTomatoesCritic', value: critic / 10));
}
return ratings.isEmpty ? null : ratings;
}
static double? _finiteRating(Object? value) {
final rating = flexibleDouble(value);
return rating != null && rating.isFinite ? rating : null;
}
static String? _firstString(Object? list) {
if (list is List && list.isNotEmpty && list.first is String) return list.first as String;
return null;
+11 -4
View File
@@ -70,12 +70,19 @@ List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, i
return [for (final track in tracks) track.withSelected(track.index == defaultStreamIndex)];
}
/// Marks the row Jellyfin selected for this user, and only that row.
///
/// `DefaultSubtitleStreamIndex` is the server's whole answer: it folds in the
/// user's `SubtitleMode`, their language preference, and any per-item choice
/// Plezy persisted through playback progress reports (including `-1` for a
/// deliberate off). A null index is part of that answer — "play no subtitle" —
/// not a missing field. Synthesising a selection from the container's
/// default/forced flags overrode `SubtitleMode: None` at the server-selected
/// priority, so a user who turned subtitles off on the server had them
/// switched back on by every fresh item (#1779).
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
return [
for (final track in tracks)
track.withSelected(
defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
),
for (final track in tracks) track.withSelected(defaultStreamIndex != null && track.index == defaultStreamIndex),
];
}
+6 -1
View File
@@ -5,8 +5,13 @@ import 'plex_constants.dart';
/// Browse responses retain up to three backdrops so hero surfaces can rotate
/// artwork without allowing image-tag payloads to grow without bound.
const jellyfinBackdropImageLimit = 3;
/// `Thumb` is deliberately absent: `JellyfinMappers` never reads
/// `ImageTags['Thumb']`, and `parentThumbPath`/`grandparentThumbPath` are built
/// from the season/series *Primary* tags. Asking for it added a dead image type
/// to ~40 requests and widened the server's inherited-image parent walk.
const jellyfinImageQueryParameters = <String, String>{
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'EnableImageTypes': 'Primary,Backdrop,Logo',
'ImageTypeLimit': '$jellyfinBackdropImageLimit',
};
@@ -42,6 +42,13 @@ class PlaybackInitializationOptions {
/// server pick".
final int? selectedAudioStreamId;
/// Semantic audio carry for negotiation when [selectedAudioStreamId] is
/// null: the id belongs to another item, but language, title, codec, and
/// channels let a backend resolve the equivalent stream on this one
/// (transcodes bake the audio choice in, so post-open switching cannot
/// recover it). Resolution failure falls back to the server's pick.
final AudioTrack? preferredAudioTrack;
/// Preferred subtitle carried across navigation/reloads. Backends that put
/// embedded subtitles in the rendition can use this during negotiation;
/// sidecar-capable backends keep subtitle delivery independent.
@@ -62,6 +69,7 @@ class PlaybackInitializationOptions {
this.qualityPreset = TranscodeQualityPreset.original,
this.audioQualityPreset,
this.selectedAudioStreamId,
this.preferredAudioTrack,
this.preferredSubtitleTrack,
this.sessionIdentifier,
this.transcodeSessionId,
+198 -47
View File
@@ -20,9 +20,14 @@ import '../utils/watch_state_notifier.dart';
/// Both Plex and Jellyfin go through the unified
/// [MediaServerClient.reportPlayback*] surface — Plex maps the three signals
/// onto `/:/timeline` updates with appropriate `state`, Jellyfin uses the
/// three `/Sessions/Playing*` endpoints. Scrobble fires once the position
/// crosses the client's [watchedThreshold] (per-server pref on Plex, fixed
/// 90% on Jellyfin).
/// three `/Sessions/Playing*` endpoints.
///
/// Local watched state flips as soon as the position crosses the client's
/// [MediaServerClient.watchedThreshold] (per-server pref on Plex, fixed 90% on
/// Jellyfin). The *server-side* mark is a separate decision: both backends
/// already mark an item played from a threshold crossing they observe in the
/// reports this tracker sends, so an explicit mark is issued only for sessions
/// that gave them no such crossing (#1287, #1740).
class PlaybackProgressTracker {
/// Server client for online progress updates (null when offline). Pinned
/// for the tracker's lifetime — one playback session against the server
@@ -82,6 +87,14 @@ class PlaybackProgressTracker {
/// must not turn an unrendered native clock position into watched progress.
final bool Function()? hasRenderedPlayback;
/// Whether an off subtitle state is a real decision (viewer or server)
/// rather than the fallout of a declined cross-item carry. When false, the
/// off state is not reported as an explicit `-1` stream index — persisting
/// it would make Jellyfin hand the off back as this item's default on every
/// later open, latching a metadata mismatch into a server-side choice
/// (#1785). Callers default to deliberate.
final bool Function()? subtitleOffIsDeliberate;
/// Timer for periodic progress updates
Timer? _progressTimer;
@@ -96,9 +109,44 @@ class PlaybackProgressTracker {
/// Timer ticks to skip before retrying after failures (exponential backoff).
int _ticksToSkip = 0;
/// Whether we've already scrobbled (marked as watched) for this playback session.
/// Whether this playback session considers the item watched locally. Latched
/// on the first observed threshold crossing, delivered to the server or not.
bool _scrobbled = false;
/// The backend has received a report from this session at a position that is
/// both strictly positive and below [MediaServerClient.watchedThreshold], so
/// a later at-or-above report reads as a crossing.
///
/// Position zero does not count. Verified against PMS 1.43: a session
/// reporting `time=0` and then the full duration is not marked played, while
/// the same session starting at `time=1000` is. Plex treats a zero position
/// as session initialisation rather than progress, so it has nothing to
/// cross from. (Retention of a resume point is a separate, higher bar —
/// reports at 5s and 30s arm the crossing without persisting an offset.)
bool _deliveredBelow = false;
/// The backend has received an at-or-above-threshold report while already
/// holding a sub-threshold offset — it observed the crossing and marked the
/// item itself, so an explicit mark would record the same watch twice
/// (#1287 Jellyfin, #1740 Plex).
bool _serverObservedCrossing = false;
/// The terminal stopped report has been delivered; no further report can
/// change what the backend saw.
bool _sessionEnded = false;
/// The in-flight [_settleServerMark], so the terminal stopped report can wait
/// for the explicit mark it triggers instead of leaving it racing teardown.
Future<void>? _pendingSettle;
/// The server-side mark is resolved: either the backend marked the item from
/// its own crossing, or we issued the explicit mark. Reset on failure so the
/// next delivered report retries.
bool _serverMarkSettled = false;
/// The post-watch hook has run; it fires at most once per tracker.
bool _scrobbledHookRan = false;
/// Whether the final stopped progress event was already emitted locally.
bool _stopProgressNotified = false;
@@ -109,7 +157,8 @@ class PlaybackProgressTracker {
static const Duration _progressNotifyDelta = Duration(seconds: 30);
final PlaybackReportSession? _reportSession;
/// Built in the constructor body so the delivery callback can bind `this`.
late final PlaybackReportSession? _reportSession;
PlaybackProgressTracker({
required this.client,
@@ -125,17 +174,21 @@ class PlaybackProgressTracker {
this.onPausedKeepalive,
this.canReportPlayback,
this.hasRenderedPlayback,
this.subtitleOffIsDeliberate,
this.updateInterval = const Duration(seconds: 10),
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
assert(isOffline || client != null, 'client is required when isOffline is false'),
_reportSession = isOffline || client == null
? null
: PlaybackReportSession(
client: client,
itemId: metadata.id,
playSessionId: playSessionId,
playMethod: playMethod,
);
assert(isOffline || client != null, 'client is required when isOffline is false') {
final reportingClient = client;
_reportSession = isOffline || reportingClient == null
? null
: PlaybackReportSession(
client: reportingClient,
itemId: metadata.id,
playSessionId: playSessionId,
playMethod: playMethod,
onDelivered: _onReportDelivered,
);
}
void startTracking() {
if (_progressTimer != null) {
@@ -203,6 +256,12 @@ class PlaybackProgressTracker {
void resumeAfterStoppedReport() {
_stoppedProgressFuture = null;
_reportSession?.resetAfterStop();
// A re-armed session is a new server-side session: backends only act on a
// threshold crossing observed within one, so it must earn its own
// below-threshold report before we can rely on it again.
_deliveredBelow = false;
_serverObservedCrossing = false;
_sessionEnded = false;
}
Future<void> _sendProgress(String state, {Duration? positionOverride}) async {
@@ -240,6 +299,10 @@ class PlaybackProgressTracker {
// by a fatal error, use the last position captured while output was
// healthy rather than the still-advancing native media clock.
final accepted = await _sendOnlineProgress(state, position, duration, allowScrobble: canCommitStoppedProgress);
// The explicit mark is resolved at session end, so it has to ride the
// terminal report's future — callers that await the stop before tearing
// the player down would otherwise drop it.
await _pendingSettle;
_resetBackoff();
if (accepted && canCommitStoppedProgress) {
_notifyProgressIfNeeded(position, duration, force: true);
@@ -363,44 +426,128 @@ class PlaybackProgressTracker {
return info == null ? PlaybackStreamSelection.none : PlaybackStreamSelection(mediaSourceId: info.mediaSourceId);
}
/// Records what the backend actually received, then re-evaluates whether the
/// explicit mark is still needed.
///
/// Both backends mark an item played from a watched-threshold *crossing*
/// observed inside a single reporting session — a report below the threshold
/// followed by one at or above it. Absolute position is not enough: a session
/// whose every report sits above the threshold, or one resuming past it, is
/// never marked server-side.
void _onReportDelivered(PlaybackReportSnapshot snapshot) {
final threshold = client?.watchedThreshold;
// isWatchedProgress reports false for an unknown duration; treating that as
// a below-threshold report would wrongly arm the crossing.
if (threshold == null || snapshot.duration.inMilliseconds <= 0) return;
if (snapshot.isStopped) _sessionEnded = true;
if (isWatchedProgress(
positionMs: snapshot.position.inMilliseconds,
durationMs: snapshot.duration.inMilliseconds,
threshold: threshold,
)) {
if (_deliveredBelow) _serverObservedCrossing = true;
} else if (snapshot.position > Duration.zero) {
// Zero is session initialisation, not progress: the backend has nothing
// to cross from, so it will never mark the item off such a session.
_deliveredBelow = true;
}
// _settleServerMark swallows its own failures, so this never escapes.
final settle = _settleServerMark(client);
_pendingSettle = settle;
unawaited(settle);
}
/// Issues the explicit server-side mark, but only once it is clear the
/// backend will not record the watch itself.
///
/// Called after the local crossing latches and again on every delivered
/// report — between them those cover every transition that can change the
/// answer.
Future<void> _settleServerMark(MediaServerClient? c) async {
if (c == null || !_scrobbled || _serverMarkSettled) return;
// Backends that mark played from the playback-stopped report do it there,
// and the terminal stop is always sent. An explicit mark on top would
// double-scrobble through the Jellyfin Trakt plugin (#1287).
if (c.marksWatchedOnPlaybackStopped) {
_serverMarkSettled = true;
await _runScrobbledHook();
return;
}
// The backend observed the crossing and marked the item itself. Marking
// again records the same watch twice (#1740).
if (_serverObservedCrossing) {
_serverMarkSettled = true;
await _runScrobbledHook();
return;
}
// Never mark while the session is still live. A crossing can appear at any
// point until the stop: even a session that began past the threshold can
// seek back below it and cross again, and the backend records that crossing
// itself. No eager decision can know a future rewind won't create one.
// Marking eagerly and then hitting that path reproduces the very
// double-count this guards against — verified against PMS 1.43, where an
// explicit mark followed by an in-session crossing leaves viewCount at 2
// with a Play History row (#1740).
//
// A session that only ever sent its stop reaches this already ended, so the
// common crossing-less case is still resolved immediately.
if (!_sessionEnded) return;
// The session is over and the backend never saw a crossing: a resume that
// stayed past the threshold, one that only sent its stop, or one whose
// crossing was coalesced away and never re-delivered. It will not mark this
// itself.
_serverMarkSettled = true;
try {
await c.markWatched(metadata);
} catch (e) {
appLogger.w('Failed to mark ${metadata.id} watched', error: e);
_serverMarkSettled = false; // Retry on the next delivered report.
return;
}
await _runScrobbledHook();
}
/// Runs the post-watch hook once, after the item's own watched state is
/// accounted for server-side.
///
/// Its production caller marks same-file sibling episodes (#1500) with real
/// server writes, so it must never run ahead of the primary: a resumed
/// session whose explicit mark is still pending — or has just failed — would
/// otherwise leave the siblings watched and the episode actually played
/// unwatched. Hook failures are logged and never un-settle the mark.
Future<void> _runScrobbledHook() async {
final hook = onScrobbled;
if (hook == null || _scrobbledHookRan) return;
_scrobbledHookRan = true;
try {
await hook();
} catch (e) {
appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e);
}
}
Future<void> _maybeScrobble(MediaServerClient c, Duration position, Duration duration) async {
// Explicitly scrobble once progress crosses the watched threshold.
// Some servers (Plex with no active play session, Jellyfin always)
// don't auto-mark from progress updates alone.
if (!_scrobbled &&
isWatchedProgress(
if (_scrobbled ||
!isWatchedProgress(
positionMs: position.inMilliseconds,
durationMs: duration.inMilliseconds,
threshold: c.watchedThreshold,
)) {
final percent = position.inMilliseconds / duration.inMilliseconds;
final threshold = c.watchedThreshold;
_scrobbled = true;
try {
// Backends that mark the item played from the playback-stopped report
// (Jellyfin) only emit the local watch event here — an explicit
// markWatched would double-scrobble via the Trakt plugin (#1287).
// Plex still issues the server call. Either path emits the watched
// event through WatchStateNotifier, so no extra notify is needed.
await c.markWatchedFromPlaybackStop(metadata);
appLogger.d(
'Scrobbled ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)',
);
} catch (e) {
appLogger.w('Failed to scrobble ${metadata.id}', error: e);
_scrobbled = false; // Retry on next tick
}
// After (and only after) the primary mark succeeded. A failure here
// must not reset _scrobbled — that would re-scrobble the primary
// item and inflate its view count.
if (_scrobbled && onScrobbled != null) {
try {
await onScrobbled!();
} catch (e) {
appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e);
}
}
return;
}
final percent = position.inMilliseconds / duration.inMilliseconds;
final threshold = c.watchedThreshold;
_scrobbled = true;
// Local state flips on the observed crossing, whether or not the backend
// received that particular report. The server-side mark is a separate
// question, answered by _settleServerMark once delivery is known.
c.notifyWatchedFromPlaybackSession(metadata);
appLogger.d(
'Watched ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)',
);
// The #1500 sibling hook runs from _settleServerMark, once this item's own
// watched state is accounted for server-side.
await _settleServerMark(c);
}
Future<PlaybackStreamSelection> _currentStreamSelectionForProgress() async {
@@ -467,7 +614,11 @@ class PlaybackProgressTracker {
int? _currentSubtitleStreamIndex(MediaSourceInfo info) {
final track = player.state.track.subtitle;
if (track == null || track.id == 'no') return -1;
if (track == null || track.id == 'no') {
// An off that merely fell out of a declined carry is withheld rather
// than persisted as an explicit -1 (see [subtitleOffIsDeliberate]).
return (subtitleOffIsDeliberate?.call() ?? true) ? -1 : null;
}
if (track.isExternal && track.uri != null) {
for (final mediaTrack in info.subtitleTracks) {
+14
View File
@@ -64,6 +64,7 @@ class PlaybackReportSession {
this.playSessionId,
this.playMethod,
this.liveStreamId,
this.onDelivered,
});
final MediaServerClient client;
@@ -72,6 +73,16 @@ class PlaybackReportSession {
final String? playMethod;
final String? liveStreamId;
/// Invoked with each snapshot the backend actually received, right after its
/// report call returns.
///
/// Coalesced and superseded snapshots never reach this: [report] resolving
/// `true` is not delivery. A same-state heartbeat arriving while the start
/// report is in flight is dropped by [_reportProgress] yet still completes
/// its future, so callers that need to know what the server saw — such as
/// watched-threshold crossing detection — must key on this instead.
final void Function(PlaybackReportSnapshot snapshot)? onDelivered;
_PlaybackReportState _state = _PlaybackReportState.idle;
PlaybackReportSnapshot? _startSnapshot;
_PendingProgressReport? _pendingProgress;
@@ -256,6 +267,7 @@ class PlaybackReportSession {
audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex,
);
onDelivered?.call(snapshot);
}
Future<bool> _sendProgress(PlaybackReportSnapshot snapshot) async {
@@ -273,6 +285,7 @@ class PlaybackReportSession {
audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex,
);
onDelivered?.call(snapshot);
return true;
}
@@ -287,5 +300,6 @@ class PlaybackReportSession {
mediaSourceId: selection.mediaSourceId,
report: snapshot.report,
);
onDelivered?.call(snapshot);
}
}
+45 -15
View File
@@ -45,6 +45,14 @@ class PlaybackSubtitleSelection {
final PlaybackSubtitleSidecar? secondarySidecar;
final List<PlaybackSubtitleSidecar> preloadedSidecars;
/// The primary preference the resolver could not serve when this selection
/// is off. Distinguishes "the carried choice declined and the ladder fell
/// through" from a deliberate off (#1785): the open flow hands the declined
/// preference back to the track manager so late-arriving native tracks may
/// still serve it, and progress reports must not persist the fallout as an
/// explicit server-side off. A user or server decision leaves this null.
final SubtitlePreference? declinedPreference;
const PlaybackSubtitleSelection({
required this.primaryTrack,
this.primarySourceStreamId,
@@ -53,9 +61,10 @@ class PlaybackSubtitleSelection {
this.secondarySourceStreamId,
this.secondarySidecar,
this.preloadedSidecars = const [],
this.declinedPreference,
});
const PlaybackSubtitleSelection.off({this.preloadedSidecars = const []})
const PlaybackSubtitleSelection.off({this.preloadedSidecars = const [], this.declinedPreference})
: primaryTrack = SubtitleTrack.off,
primarySourceStreamId = null,
primarySidecar = null,
@@ -192,13 +201,25 @@ class PlaybackSubtitleResolver {
);
final primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio);
final primary = primaryResult?.track;
// A non-off preference that still lands on off (or resolves to a track
// this catalog cannot back) was declined, not chosen — keep it on the
// selection so the open flow can retry it against native tracks (#1785).
final declinedPreference = primaryPreference != null && primaryPreference is! SubtitleOffPreference
? primaryPreference
: null;
if (primary == null || primary.id == SubtitleTrack.off.id) {
return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars);
return PlaybackSubtitleSelection.off(
preloadedSidecars: preloadedSidecars,
declinedPreference: declinedPreference,
);
}
final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull;
if (primaryCandidate == null) {
return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars);
return PlaybackSubtitleSelection.off(
preloadedSidecars: preloadedSidecars,
declinedPreference: declinedPreference,
);
}
_SubtitleCandidate? secondaryCandidate;
@@ -242,7 +263,11 @@ class PlaybackSubtitleResolver {
final playable = sidecar?.track;
return SubtitleTrack(
id: 'source:${sourceTrack.id}',
title: playable?.title ?? sourceTrack.displayTitle ?? sourceTrack.title ?? sourceTrack.language,
// The row's own title first: server display titles collapse to the bare
// language ("English") and are identical across same-language rows, so
// a carried intent built from them cannot tell a Signs/Songs track from
// the full dialogue track on the next episode (#1785).
title: sourceTrack.title ?? playable?.title ?? sourceTrack.displayTitle ?? sourceTrack.language,
language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language,
codec: playable?.codec ?? sourceTrack.codec,
isDefault: sourceTrack.selected,
@@ -304,18 +329,23 @@ class PlaybackSubtitleResolver {
return choices[(normalizedCurrentIndex + advances) % choices.length];
}
/// Stable semantic descriptor for a source audio row — the audio twin of
/// [subtitleTrackForSource]. The row's own title comes first: server
/// display titles collapse to the bare language and cannot tell a
/// commentary or alternate mix from the main track on another item.
static AudioTrack audioTrackForSource(MediaAudioTrack track) {
return AudioTrack(
id: 'source:${track.id}',
title: track.title ?? track.displayTitle ?? track.language,
language: track.languageCode ?? track.language,
codec: track.codec,
channels: track.channels,
isDefault: track.selected,
);
}
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
return [
for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[])
AudioTrack(
id: 'source:${track.id}',
title: track.displayTitle ?? track.title ?? track.language,
language: track.languageCode ?? track.language,
codec: track.codec,
channels: track.channels,
isDefault: track.selected,
),
];
return [for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[]) audioTrackForSource(track)];
}
}
+215 -124
View File
@@ -73,6 +73,7 @@ import 'plex_lyrics_parser.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
import 'track_selection_service.dart';
part 'plex_client/parts/live_tv.dart';
part 'plex_client/parts/playlists.dart';
@@ -844,6 +845,16 @@ class PlexClient
return null;
}
/// Every raw `Metadata` entry of a container, for callers that must inspect
/// each sibling rather than assume the first is the only one (the
/// external-id reverse lookup: `/library/all` is server-wide, so one movie
/// held by two libraries answers with two entries).
List<Map<String, dynamic>> _getMetadataJsonList(MediaServerResponse response) {
final metadata = _getMediaContainer(response)?['Metadata'];
if (metadata is! List) return const [];
return metadata.whereType<Map<String, dynamic>>().toList();
}
List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
final container = _getMediaContainer(response);
if (container != null && container['Directory'] != null) {
@@ -1528,7 +1539,11 @@ class PlexClient
const allowedTypes = {'movie', 'show', 'artist', 'album', 'track'};
if (!allowedTypes.contains(type)) continue;
results.add(_createTaggedMetadata(metadata));
// Library-aware: search rows normally carry `librarySectionID`, but the
// tolerant resolver also accepts the `librarySectionKey` /
// `targetLibrarySectionID` forms. Without a section id the item cannot
// be matched against the user's hidden libraries.
results.add(_createTaggedMetadataWithLibrary(metadata));
} catch (e) {
appLogger.w('Failed to parse search result', error: e);
}
@@ -1550,7 +1565,7 @@ class PlexClient
final response = await retryTransientMediaServerCall(
operation: 'Plex continue watching hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
deadline: MediaServerTimeouts.homeHubDeadline,
call: (timeout, abort) => _getWithFailover(
continueWatchingHubKey ?? '/hubs',
queryParameters: queryParameters,
@@ -1665,8 +1680,23 @@ class PlexClient
}
}
/// Get chapters and markers from cached metadata or fetch if needed
/// Uses same cache key as other metadata methods for consistency
/// Chapters and markers for [ratingKey], from the shared
/// `/library/metadata/{id}` cache row.
///
/// Cache-first is safe here because of an ordering contract, not because
/// markers are static: in the normal online player flow
/// [getPlaybackInitialization] runs [getVideoPlaybackData] — a
/// network-first read of this same cache key with a superset of the query
/// params — before the controls mount and load extras, so the row this
/// serves was refreshed seconds earlier. Offline, and when that read fell
/// back to cache, the row is as old as the cache; a caller that needs the
/// current server state (e.g. after a PMS intro-detection pass finished)
/// must pass [forceRefresh].
///
/// The network call here deliberately stays lean (no `checkFiles` /
/// `includeStreams`) and runs only on a cache miss or [forceRefresh], so it
/// rarely overwrites the shared row with a payload thin enough to force the
/// re-fetch in [_fetchFileInfo].
Future<PlaybackExtras> getPlaybackExtras(
String ratingKey, {
String? introPattern,
@@ -1900,30 +1930,6 @@ class PlexClient
return _getFirstMetadataJsonFromData(data);
}
/// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
///
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
/// an empty list if the item has no external IDs / can't be fetched.
/// Used by the Trakt integration to match Plex items against Trakt's catalog.
Future<List<dynamic>> fetchExternalGuids(String ratingKey) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1});
final data = response.data;
if (data is! Map) return const [];
final container = data['MediaContainer'] as Map?;
final metadata = container?['Metadata'];
if (metadata is! List || metadata.isEmpty) return const [];
final first = metadata.first;
if (first is! Map) return const [];
final guids = first['Guid'];
if (guids is List) return guids;
return const [];
} catch (e) {
appLogger.d('fetchExternalGuids failed for $ratingKey', error: e);
return const [];
}
}
/// Mark media as watched (transport only — see [MediaServerClient.markWatched]).
Future<void> markAsWatched(String ratingKey) async {
await _getWithFailover(
@@ -2215,7 +2221,7 @@ class PlexClient
required String path,
required Map<String, dynamic> queryParameters,
required String operation,
required List<Duration> attemptTimeouts,
required Duration deadline,
required String failureLabel,
int? librarySectionID,
String? librarySectionTitle,
@@ -2224,7 +2230,7 @@ class PlexClient
try {
final response = await retryTransientMediaServerCall(
operation: operation,
attemptTimeouts: attemptTimeouts,
deadline: deadline,
call: (timeout, abort) => _getWithFailover(
path,
queryParameters: queryParameters,
@@ -2262,7 +2268,7 @@ class PlexClient
path: '/hubs/sections/$sectionId',
queryParameters: {'count': limit, 'includeGuids': 1},
operation: 'Plex library hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
deadline: MediaServerTimeouts.libraryHubDeadline,
failureLabel: 'library hubs',
librarySectionID: _librarySectionIdFromString(sectionId),
librarySectionTitle: libraryName,
@@ -2276,7 +2282,7 @@ class PlexClient
path: _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs',
queryParameters: {'count': limit, 'includeGuids': 1},
operation: 'Plex global hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
deadline: MediaServerTimeouts.homeHubDeadline,
failureLabel: 'global hubs',
);
@@ -2285,7 +2291,7 @@ class PlexClient
path: '/hubs/metadata/$ratingKey/related',
queryParameters: {'count': count},
operation: 'Plex related hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
deadline: MediaServerTimeouts.libraryHubDeadline,
failureLabel: 'related hubs',
filter: _videoOrCollectionHubItem,
);
@@ -3223,6 +3229,10 @@ class PlexClient
if (!data.hasValidVideoUrl) {
throw PlaybackException(t.messages.fileInfoNotAvailable, reason: PlaybackFailureReason.noPlayableSource);
}
final carriedAudioTrack = options.selectedAudioStreamId == null ? options.preferredAudioTrack : null;
final carriedAudioStreamId = carriedAudioTrack == null || data.mediaInfo == null
? null
: findSourceAudioTrackForIntent(carriedAudioTrack, data.mediaInfo!.audioTracks)?.id;
// Tracks consult the music preset — [qualityPreset] is video-shaped
// (resolution/videoQuality) and is ignored for audio.
@@ -3257,7 +3267,9 @@ class PlexClient
return _transcodeFallbackResult(data, result.outcome, options);
}
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
final resolvedAudioId = carriedAudioTrack == null
? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo)
: carriedAudioStreamId;
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
@@ -3285,7 +3297,7 @@ class PlexClient
);
}
return _transcodeFallbackResult(data, result.outcome, options);
return _transcodeFallbackResult(data, result.outcome, options, activeAudioStreamId: carriedAudioStreamId);
}
return PlaybackInitializationResult(
@@ -3294,6 +3306,7 @@ class PlexClient
mediaInfo: data.mediaInfo,
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
activeAudioStreamId: carriedAudioStreamId,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
@@ -3311,8 +3324,9 @@ class PlexClient
PlaybackInitializationResult _transcodeFallbackResult(
PlexVideoPlaybackData data,
TranscodeDecisionOutcome outcome,
PlaybackInitializationOptions options,
) {
PlaybackInitializationOptions options, {
int? activeAudioStreamId,
}) {
final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly
? TranscodeFallbackReason.directPlayOnly
: TranscodeFallbackReason.decisionFailed;
@@ -3325,6 +3339,7 @@ class PlexClient
isOffline: false,
isTranscoding: false,
fallbackReason: fallbackReason,
activeAudioStreamId: activeAudioStreamId,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
@@ -3578,8 +3593,16 @@ class PlexClient
return getFirstCharacters(libraryId, filters: filters);
}
/// [excludedLibraryIds] is unused: `/library/search` has no section-scoping
/// parameter, and every row carries its `librarySectionID`, so the caller
/// filters hidden libraries out of the mapped results.
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
Future<List<MediaItem>> searchItems(
String query, {
int limit = 100,
AbortController? abort,
Set<String> excludedLibraryIds = const {},
}) async {
final results = await _search(query, limit: limit, abort: abort);
return results.map((m) => PlexMappers.mediaItem(m)).toList();
}
@@ -3732,10 +3755,14 @@ class PlexClient
}
/// Full item with on-deck episode from a single `/library/metadata/{id}`
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck];
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck]. Both
/// halves arrive together, so there is no window in which the item is known
/// and on-deck is not — `onItemReady` is intentionally never invoked.
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
String id, {
void Function(MediaItem item)? onItemReady,
}) async {
try {
final result = await getMetadataWithImagesAndOnDeck(id, shouldFallback: _shouldFallbackPlexItemLookup);
final itemDto = result['metadata'] as PlexMetadataDto?;
@@ -3802,53 +3829,111 @@ class PlexClient
@override
double get watchedThreshold => watchedThresholdPercent / 100.0;
/// Plex's `/:/timeline?state=stopped` doesn't reliably mark watched without
/// an active play session, so the in-player auto-scrobble still issues the
/// explicit `markWatched` (`/:/scrobble`). See [marksWatchedOnPlaybackStopped].
/// A single `/:/timeline?state=stopped` does not mark watched: PMS only acts
/// on a threshold crossing it observes inside one session — a report below
/// `LibraryVideoPlayedThreshold` followed by one at or above it. Verified
/// against PMS 1.43: consecutive above-threshold reports mark nothing (it
/// won't even store an above-threshold `viewOffset`), and a resume point left
/// by an earlier session does not arm a new one.
///
/// So paths with no observable crossing — queued offline replay, external
/// players, same-file siblings — still need the explicit `markWatched`
/// (`/:/scrobble`). In-player sessions that did produce a crossing must not
/// send it: PMS has already recorded the watch, and the extra call inflates
/// `viewCount` and (before PMS 1.40) adds a second Play History row (#1740).
@override
bool get marksWatchedOnPlaybackStopped => false;
@override
Map<String, String> get streamHeaders => Map.unmodifiable(config.headers);
/// Reads both guid shapes Plex can answer with. The `Guid` array only exists
/// for items matched by the Plex Movie / Plex TV Series agents; a library
/// still on a legacy agent (HAMA, `com.plexapp.agents.thetvdb`, ...) carries
/// its ids in the scalar `guid` instead, so reading only the array left every
/// tracker, watchlist and dedupe path blind to those libraries (#1788).
///
/// The array wins per field; the scalar only fills what it left null.
@override
Future<ExternalIds> fetchExternalIds(String itemId) async {
final guids = await fetchExternalGuids(itemId);
return ExternalIds.fromGuids(guids);
try {
final response = await _getWithFailover('/library/metadata/$itemId', queryParameters: {'includeGuids': 1});
final data = response.data;
if (data is! Map) return const ExternalIds();
final metadata = (data['MediaContainer'] as Map?)?['Metadata'];
if (metadata is! List || metadata.isEmpty) return const ExternalIds();
final first = metadata.first;
if (first is! Map) return const ExternalIds();
final guids = first['Guid'];
final modern = guids is List ? ExternalIds.fromGuids(guids) : const ExternalIds();
return modern.fillFrom(ExternalIds.fromLegacyPlexGuid(first['guid']));
} catch (e) {
appLogger.d('fetchExternalIds failed for $itemId', error: e);
return const ExternalIds();
}
}
/// Drop a sequel match when the server does not actually have that season.
/// Map id-verified candidates to items, dropping any sequel the server does
/// not actually have that season of.
///
/// Only an [ExternalSeasonRef.agreedSeason] is gated on. Which provider a
/// library numbers its seasons by is a server-side setting no dataset can
/// supply, and reading it costs two extra requests per lookup, so a
/// disagreeing ref is left ungated rather than gated on a guess.
Future<MediaItem?> _applyExternalIdSeasonGate(
Map<String, dynamic> metadata, {
///
/// Gating costs one `fetchChildren` per show candidate. The candidate list
/// is deliberately never truncated (see
/// [MediaServerClient.findByExternalIds]), so the gates run concurrently
/// rather than letting latency grow with the number of library copies.
Future<List<MediaItem>> _gateExternalIdMatches(
Iterable<Map<String, dynamic>> candidates, {
required MediaKind kind,
required ExternalSeasonRef? season,
}) async {
final item = PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata));
if (kind != MediaKind.show) return item;
final entries = [
for (final metadata in candidates)
(metadata: metadata, item: PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata))),
];
final seasonIndex = season?.agreedSeason;
if (seasonIndex == null || seasonIndex <= 1) return item;
if (kind != MediaKind.show || seasonIndex == null || seasonIndex <= 1) {
return [for (final entry in entries) entry.item];
}
final ratingKey = metadata['ratingKey']?.toString();
// Cannot ask the question => do not gate.
if (ratingKey == null || ratingKey.isEmpty) return item;
final children = await fetchChildren(ratingKey);
return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex) ? item : null;
final kept = await Future.wait([for (final entry in entries) _hasSeason(entry.metadata, seasonIndex)]);
return [
for (var index = 0; index < entries.length; index++)
if (kept[index]) entries[index].item,
];
}
/// Server-wide title filter + client-side external-ID verification. Plex's
/// `guid=` field filter matches only the item's primary `plex://` guid
/// (verified against PMS 1.43), so external ids in modern `Guid` arrays are
/// verified after title search. A resolved primary [plexGuid] can use the
/// exact server-side filter directly.
Future<bool> _hasSeason(Map<String, dynamic> metadata, int seasonIndex) async {
final ratingKey = metadata['ratingKey']?.toString();
// Cannot ask the question => do not gate.
if (ratingKey == null || ratingKey.isEmpty) return true;
final children = await fetchChildren(ratingKey);
return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex);
}
/// Server-wide external-id reverse lookup. Plex's `guid=` field filter
/// matches only the item's primary `plex://` guid (verified against PMS
/// 1.43), so a resolved [plexGuid] uses that exact filter while ids in
/// modern `Guid` arrays are verified client-side after a title search.
///
/// `/library/all` is server-wide — it is not scoped to a section — so a
/// movie held by both a 4K and an HD library answers as two sibling
/// `Metadata` entries, each carrying its own `librarySectionID`. Every
/// id-verified entry is kept (#1754).
///
/// An exact-guid hit does not short-circuit the title ladder: a library
/// still on a legacy agent carries `com.plexapp.agents.*` as its primary
/// guid, so that copy is invisible to the `guid=` filter and only the
/// id-verified title search finds it. Likewise the year-filtered page can
/// surface a copy the unfiltered page cut off at the container size, so
/// both contribute. The extra requests are spent once per uncached lookup,
/// off the render path and memoized for the session by
/// `CatalogLibraryMatcher`.
@override
Future<MediaItem?> findByExternalIds(
Future<List<MediaItem>> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -3861,82 +3946,88 @@ class PlexClient
MediaKind.show => 2,
_ => null,
};
if (plexType == null) return null;
if (!ids.hasAny && plexGuid == null) return null;
if (titles.isEmpty && plexGuid == null) return null;
if (plexType == null) return const [];
if (!ids.hasAny && plexGuid == null) return const [];
if (titles.isEmpty && plexGuid == null) return const [];
// Rating-key keyed so the guid filter and the title ladder can both
// contribute without doubling a copy they agree on. Kept in three buckets
// because the result order is exact-guid hits, then modern `Guid`
// matches, then legacy-agent ones.
final exact = <String, Map<String, dynamic>>{};
final modern = <String, Map<String, dynamic>>{};
final legacy = <String, Map<String, dynamic>>{};
void collect(Map<String, Map<String, dynamic>> into, Map<String, dynamic> item) {
final ratingKey = item['ratingKey']?.toString();
if (ratingKey == null || ratingKey.isEmpty) return;
into.putIfAbsent(ratingKey, () => item);
}
if (plexGuid != null) {
final response = await _getWithFailover(
'/library/all',
queryParameters: {'guid': plexGuid, 'type': plexType, 'includeGuids': 1},
);
final metadata = _getFirstMetadataJson(response);
if (metadata != null) {
return _applyExternalIdSeasonGate(metadata, kind: kind, season: season);
for (final item in _getMetadataJsonList(response)) {
collect(exact, item);
}
}
// Title attempts confirm candidates by external-id intersection, so
// without external ids they cannot match anything — stop at the exact
// guid lookup instead of burning requests that always come back empty.
if (!ids.hasAny) return null;
Future<({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})> attempt(
String title, {
required int size,
String? years,
}) async {
final response = await _getWithFailover(
'/library/all',
queryParameters: {
'title': title,
'type': plexType,
'includeGuids': 1,
'X-Plex-Container-Size': size,
'year': ?years,
},
);
final container = _getMediaContainer(response);
final metadata = container?['Metadata'];
if (metadata is! List) return (modern: null, legacy: null);
Map<String, dynamic>? legacy;
for (final item in metadata) {
if (item is! Map<String, dynamic>) continue;
final guids = item['Guid'];
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
return (modern: item, legacy: legacy);
}
if (legacy == null && ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
legacy = item;
}
}
return (modern: null, legacy: legacy);
}
// Not `resolve(null)`: when the two providers disagree the season number is
// unresolvable but the entry is still a sequel, and the ±1 window around a
// sequel's own year excludes the parent show (its year is season one's).
final skipYearWindow = season?.isSequel ?? false;
for (var index = 0; index < titles.length; index++) {
final title = titles[index];
final size = index == 0 ? 20 : 50;
({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})? filtered;
if (index == 0 && year != null && !skipYearWindow) {
filtered = await attempt(title, size: size, years: '${year - 1},$year,${year + 1}');
final modern = filtered.modern;
if (modern != null) {
return _applyExternalIdSeasonGate(modern, kind: kind, season: season);
if (ids.hasAny) {
Future<bool> attempt(String title, {required int size, String? years}) async {
final response = await _getWithFailover(
'/library/all',
queryParameters: {
'title': title,
'type': plexType,
'includeGuids': 1,
'X-Plex-Container-Size': size,
'year': ?years,
},
);
var matched = false;
for (final item in _getMetadataJsonList(response)) {
final guids = item['Guid'];
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
collect(modern, item);
matched = true;
} else if (ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
collect(legacy, item);
matched = true;
}
}
return matched;
}
final unfiltered = await attempt(title, size: size);
final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy;
if (match != null) {
return _applyExternalIdSeasonGate(match, kind: kind, season: season);
// Not `resolve(null)`: when the two providers disagree the season number is
// unresolvable but the entry is still a sequel, and the ±1 window around a
// sequel's own year excludes the parent show (its year is season one's).
final skipYearWindow = season?.isSequel ?? false;
for (var index = 0; index < titles.length; index++) {
final title = titles[index];
final size = index == 0 ? 20 : 50;
final filteredMatched = index == 0 && year != null && !skipYearWindow
? await attempt(title, size: size, years: '${year - 1},$year,${year + 1}')
: false;
final unfilteredMatched = await attempt(title, size: size);
// The ladder exists to widen a title that matched nothing; once a
// title has produced copies, broader forms would only add other shows.
if (filteredMatched || unfilteredMatched) break;
}
}
return null;
final ordered = <String, Map<String, dynamic>>{...exact};
for (final bucket in [modern, legacy]) {
for (final entry in bucket.entries) {
ordered.putIfAbsent(entry.key, () => entry.value);
}
}
if (ordered.isEmpty) return const [];
return _gateExternalIdMatches(ordered.values, kind: kind, season: season);
}
@override
+109 -3
View File
@@ -23,6 +23,7 @@ import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_part.dart';
import '../media/media_playlist.dart';
import '../media/media_rating.dart';
import '../media/media_role.dart';
import '../media/media_source_info.dart';
import '../media/media_stream.dart';
@@ -300,6 +301,24 @@ class PlexRoleDto {
factory PlexRoleDto.fromJson(Map<String, dynamic> json) => _$PlexRoleDtoFromJson(json);
}
/// One entry of Plex's `Rating[]` child array, present on
/// `/library/metadata/{id}` responses but never on section listings.
///
/// [image] is a source URI (`imdb://image.rating`,
/// `rottentomatoes://image.rating.ripe`, `themoviedb://image.rating`) and
/// [type] is `critic` or `audience`; together they attribute [value].
@JsonSerializable(createToJson: false)
class PlexRatingDto {
final String? image;
final String? type;
@JsonKey(fromJson: flexibleDouble)
final double? value;
const PlexRatingDto({this.image, this.type, this.value});
factory PlexRatingDto.fromJson(Map<String, dynamic> json) => _$PlexRatingDtoFromJson(json);
}
@JsonSerializable(createToJson: false)
class PlexMediaVersionDto {
@JsonKey(fromJson: flexibleIntOrZero)
@@ -605,6 +624,10 @@ class PlexMetadataDto {
final List<PlexRoleDto>? role;
@JsonKey(name: 'Media', includeToJson: false)
final List<PlexMediaVersionDto>? mediaVersions;
@JsonKey(name: 'Rating', includeToJson: false)
final List<PlexRatingDto>? ratingSources;
@JsonKey(fromJson: flexibleInt)
final int? imdbRatingCount;
@JsonKey(name: 'Genre', fromJson: _tagListFromJson, includeToJson: false)
final List<String>? genre;
@JsonKey(name: 'Director', fromJson: _tagListFromJson, includeToJson: false)
@@ -693,6 +716,8 @@ class PlexMetadataDto {
this.childCount,
this.role,
this.mediaVersions,
this.ratingSources,
this.imdbRatingCount,
this.genre,
this.director,
this.writer,
@@ -825,6 +850,8 @@ class PlexMetadataDto {
int? childCount,
List<PlexRoleDto>? role,
List<PlexMediaVersionDto>? mediaVersions,
List<PlexRatingDto>? ratingSources,
int? imdbRatingCount,
List<String>? genre,
List<String>? director,
List<String>? writer,
@@ -895,6 +922,8 @@ class PlexMetadataDto {
childCount: childCount ?? this.childCount,
role: role ?? this.role,
mediaVersions: mediaVersions ?? this.mediaVersions,
ratingSources: ratingSources ?? this.ratingSources,
imdbRatingCount: imdbRatingCount ?? this.imdbRatingCount,
genre: genre ?? this.genre,
director: director ?? this.director,
writer: writer ?? this.writer,
@@ -937,6 +966,75 @@ Map<String, Object?>? _rawMetadata(PlexMetadataDto dto) {
return raw.isEmpty ? null : raw;
}
/// Every attributed score a Plex payload carries, headline first.
///
/// Section listings only ever send the scalar pair ([rating]/[ratingImage] and
/// [audienceRating]/[audienceRatingImage]), so they yield one or two entries.
/// `/library/metadata/{id}` additionally sends the `Rating[]` child array —
/// IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, so
/// detail responses yield up to four. Plex offers no listing parameter that
/// includes the array, which is why cards stay shorter than detail screens.
///
/// Entries are deduped on `(source, value)` because the array repeats whichever
/// source the scalar pair was drawn from. Insertion order keeps the server's
/// own headline first, so `ratings.first` matches the scalar `rating` the
/// cards and sorts already use.
List<MediaRatingSource>? plexRatingSources({
Object? rating,
Object? ratingImage,
Object? audienceRating,
Object? audienceRatingImage,
Iterable<({Object? image, Object? type, Object? value})> ratingSources = const [],
int? imdbVotes,
}) {
final ratings = <MediaRatingSource>[];
void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) {
final value = normalizedPlexRating(rawValue);
if (value == null) return;
final source = _plexRatingSource(image: image, type: type, fallback: fallbackSource);
if (ratings.any((rating) => rating.source == source && rating.value == value)) return;
ratings.add(MediaRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null));
}
add(rating, fallbackSource: 'critic', image: ratingImage);
add(audienceRating, fallbackSource: 'audience', image: audienceRatingImage);
for (final entry in ratingSources) {
add(entry.value, fallbackSource: 'audience', image: entry.image, type: entry.type);
}
return ratings.isEmpty ? null : ratings;
}
String _plexRatingSource({Object? image, Object? type, required String fallback}) {
final scheme = Uri.tryParse(_nonEmptyRatingString(image) ?? '')?.scheme.toLowerCase();
if (scheme == 'imdb') return 'imdb';
if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb';
final ratingType = _nonEmptyRatingString(type)?.toLowerCase() ?? fallback;
if (scheme == 'rottentomatoes') {
return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience';
}
return switch (ratingType) {
'critic' => 'critic',
'audience' => 'audience',
_ => fallback,
};
}
/// Coerce a Plex score onto the neutral 0-10 scale, rejecting out-of-range
/// noise. Plex sends percentages as 0-10 already; a stray 0-100 value is
/// folded rather than dropped.
double? normalizedPlexRating(Object? value) {
final rating = flexibleDouble(value);
if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null;
return rating > 10 ? rating / 10 : rating;
}
String? _nonEmptyRatingString(Object? value) {
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
/// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers].
///
/// Methods come in two flavours:
@@ -1011,10 +1109,18 @@ class PlexMappers {
addedAt: dto.addedAt,
updatedAt: dto.updatedAt,
rating: dto.rating,
audienceRating: dto.audienceRating,
userRating: dto.userRating,
ratingImage: dto.ratingImage,
audienceRatingImage: dto.audienceRatingImage,
ratings: plexRatingSources(
rating: dto.rating,
ratingImage: dto.ratingImage,
audienceRating: dto.audienceRating,
audienceRatingImage: dto.audienceRatingImage,
ratingSources: [
for (final entry in dto.ratingSources ?? const <PlexRatingDto>[])
(image: entry.image, type: entry.type, value: entry.value),
],
imdbVotes: dto.imdbRatingCount,
),
genres: dto.genre,
directors: dto.director,
writers: dto.writer,
+12
View File
@@ -16,6 +16,13 @@ PlexRoleDto _$PlexRoleDtoFromJson(Map<String, dynamic> json) => PlexRoleDto(
count: flexibleInt(json['count']),
);
PlexRatingDto _$PlexRatingDtoFromJson(Map<String, dynamic> json) =>
PlexRatingDto(
image: json['image'] as String?,
type: json['type'] as String?,
value: flexibleDouble(json['value']),
);
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) =>
PlexMediaVersionDto(
id: flexibleIntOrZero(json['id']),
@@ -123,6 +130,10 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
mediaVersions: (json['Media'] as List<dynamic>?)
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
.toList(),
ratingSources: (json['Rating'] as List<dynamic>?)
?.map((e) => PlexRatingDto.fromJson(e as Map<String, dynamic>))
.toList(),
imdbRatingCount: flexibleInt(json['imdbRatingCount']),
genre: _tagListFromJson(json['Genre']),
director: _tagListFromJson(json['Director']),
writer: _tagListFromJson(json['Writer']),
@@ -191,6 +202,7 @@ Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'imdbRatingCount': ?instance.imdbRatingCount,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
-2
View File
@@ -74,7 +74,6 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
List<MediaVersion> availableVersions = [];
var selectedMediaIndex = 0;
var selectedPartIndex = 0;
final markers = plexMarkersFromCacheJson(metadataJson);
if (metadataJson != null) {
final mediaList = _mapList(metadataJson['Media']);
@@ -148,7 +147,6 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
videoUrl: videoUrl,
mediaInfo: mediaInfo,
availableVersions: availableVersions,
markers: markers,
selectedMediaIndex: selectedMediaIndex,
selectedPartIndex: selectedPartIndex,
);

Some files were not shown because too many files have changed in this diff Show More