Commit Graph
2917 Commits
Author SHA1 Message Date
edde746 3b76cf3948 fix(player): make TrueHD carrier-or-decode and never lose access units
Three defects in the carrier path, two of them found on hardware (#1804).

Falling through to the normal sink when the carrier was unavailable handed
TrueHD straight back to media3's raw ENCODING_DOLBY_TRUEHD path — the exact
configuration this issue is about. TrueHD is now binary: the carrier, or
reported unsupported so the bundled FFmpeg decoder takes it. media3's raw path
has no demonstrated working case here and two broken ones, and even Kodi's raw
fallback is a different thing, offered only after verifying at 192kHz.

The 44.1kHz family was decided from a packer flag that is only set once a major
sync has been parsed, long after selection. The carrier was therefore chosen for
those streams and then packed nothing, which is silence rather than a glitch. It
is decided from Format.sampleRate now, with the packer flag left as a loud
runtime backstop for a bitstream that disagrees with its container.

handleBuffer consumed the whole input buffer even when a burst was refused
part-way through, dropping every access unit behind it — a media3 sample holds
sixteen. The buffer position now advances per unit and the method returns false
with the remainder intact, which is media3's own retry contract. A test rejects
a burst mid-sample and asserts the carrier output is still byte-identical.

The capability gate also needed tightening. getMinBufferSize answers yes for the
192kHz/7.1 IEC tuple on a Shield and the AudioTrack then fails to initialise: it
reports that a buffer can be sized, not that the route will carry the format.
Without getDirectPlaybackSupport there is no way to separate the two, so the
carrier is not offered below API 33 and TrueHD decodes exactly as before.

Verified on both connected boxes. SEI Box R (Android 14): carrier selected,
AudioTrack built as IEC61937 at 192kHz/7.1, no decoder instantiated, clock
tracks wall time. Nvidia Shield (Android 11): carrier declined, FFmpeg decoder
selected, identical to its behaviour before this work.
2026-08-08 10:51:15 +02:00
edde746 b7a438789f feat(player): bitstream TrueHD through the MAT/IEC 61937 carrier
Copies the path Kodi uses, and replaces nothing-but-detection with a route that
actually plays (#1804).

Android will not bitstream raw TrueHD on the TV routes measured here. Both
connected boxes report ENCODING_DOLBY_TRUEHD as offload-only while reporting
ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable. Kodi models exactly that
split: it packs the carrier itself and offers "AudioTrack (IEC)" as the
recommended sink, treating raw TrueHD as a fallback that it still runs at
192kHz. Media3 only ever hands Android raw TrueHD at the stream rate, which on
the reporter's box takes one write and then never advances the playback head.

TrueHdCarrierSink routes TrueHD onto a dedicated delegate and leaves everything
else on the existing processed sink. The split is deliberate rather than
enforcing that the normal processors stay inactive: the carrier is a bit-exact
byte stream shaped like PCM, so a downmix, Sonic pass or silence skip turns it
into full-scale noise at the receiver. A delegate built with an empty
AudioProcessorChain makes that impossible by construction, instead of putting
the guarantee in a different class from the thing it protects.

The carrier delegate keeps OutputConfig at PCM 16-bit so media3's position,
pending-data and release accounting all stay on their mature PCM path — correct
here, because after packing the stream really is a fixed-rate 192kHz 8-channel
carrier. Only the AudioTrack itself is switched, through the builder modifier
upstream applies just before AudioTrack.Builder.build(). That avoids
reimplementing AudioOutput and avoids the encoded frame-domain mismatch in
androidx/media#3329.

Burst timestamps come from the carrier cadence rather than from whichever
access unit closed the frame; anchoring on the closing unit drifts against the
time the sink derives from written frames and reports a discontinuity on nearly
every frame.

Availability is Kodi's test, not media3's: getMinBufferSize for the exact
192kHz/7.1 IEC tuple, plus getDirectPlaybackSupport where it exists to confirm
the route will bitstream rather than quietly decode. Speed changes, downmix,
normalization and 44.1kHz-family streams all decline the carrier and decode.

Verified on a SEI Robotics Box R 4K Plus (Android 14, armeabi-v7a): the carrier
is selected, the AudioTrack is built as IEC61937 at 192kHz/7.1, no audio decoder
is instantiated, zero timestamp discontinuities, and the clock tracks wall time
with no frozen samples. The same box freezes for ten seconds on raw TrueHD.
2026-08-08 10:51:15 +02:00
edde746 33c33c3d57 feat(player): pack TrueHD into a MAT/IEC 61937 carrier
Groundwork for bitstreaming TrueHD the way other players do (#1804).

Android will not bitstream raw TrueHD on the TV routes measured so far. Both
connected Android TV boxes report ENCODING_DOLBY_TRUEHD as offload-only while
reporting ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable, and the
reporter's box takes a raw TrueHD AudioTrack and then never advances its
playback head. Kodi models this split explicitly: it offers an "AudioTrack
(IEC)" sink where it packs the carrier itself and treats handing raw TrueHD to
Android as the fallback, and even that fallback runs at 192kHz. Media3 only
ever does the raw form, at the stream rate.

This adds the packer half: split a sample into TrueHD access units, assemble
MAT frames with timing-derived padding, and emit IEC 61937 bursts. It is a port
of FFmpeg's spdif_header_truehd rather than Kodi's CAEBitstreamPacker, because
Kodi's is a thin wrapper over an already-assembled buffer while the MAT code
placement and padding live in FFmpeg's stateful packer.

Details the port has to get right. Media3's Matroska path concatenates 16
syncframes into one sample, so access units are split here; reading a single
input_timing for sixteen frames would desynchronise the carrier. Burst buffers
alternate and are reused rather than allocated, because a fresh 61,440 byte
array every 20ms is roughly 3MB/s of garbage on the low-power hardware this
runs on. A 44.1kHz-family stream rides a 176.4kHz carrier instead of 192kHz,
which changes the whole AudioTrack tuple, so it is reported as unsupported for
the caller to decode instead.

A wrong byte here is not subtle — the receiver drops sync or renders full-scale
noise — so the test compares against FFmpeg's own output byte for byte, using
its input and output as fixtures.

No caller yet; the sink that routes TrueHD through this follows.
2026-08-08 10:51:15 +02:00
edde746 636fd48f40 fix(player): settle the watched patch the backend recorded itself
Watching an episode to the end left it stuck as watched for the rest of the
session. Unmarking it on another device and refreshing did nothing; only a
restart cleared it. Unlike #1829 this needs no second device to cause -- a
normal watch-through is enough, and the second device only makes it visible.

A threshold crossing writes an unacknowledged overlay patch, deliberately:
reporting success proves the backend received the report, not that it
classified the item as played, so the patch stays owed until something
settles it. _settleServerMark has three settled outcomes and only one of them
did. The explicit-mark branch promoted; the two branches that skip the mark
because the backend already recorded the watch itself -- Jellyfin from
/Sessions/Playing/Stopped, Plex from a timeline crossing past
LibraryVideoPlayedThreshold -- returned without promoting. Those are the
common paths, so nearly every completed playback stranded a patch that the
store then refused to suppress, because an unacknowledged entry is never
retired by an authoritative read.

Both now promote, through one idempotent helper that clears the id so the
delivery callback and the settle paths cannot promote twice.

Promotion has to follow delivery rather than the settle decision. A
marks-on-stop backend settles when the crossing latches, which happens before
the stop is sent, and until that stop lands the watch really is still owed --
promoting there would let a refresh retire a patch the server had never
heard about. MediaBrowser also drops a stop for a session it never opened, in
which case the watch it would have recorded never happens at all. So the stop
path promotes only once the report reached a session able to act on it, which
is the same condition that already governs whether the stop persists its
position; that condition is now named rather than recomputed, and reset with
its siblings when a session re-arms. The crossing branch needs no such gate:
it is assembled from two delivered reports, so delivery is already proven.

Verified against a live Jellyfin server driving the real client and tracker:
before, the server reported the item unwatched after a second device cleared
it while the overlay still rendered watched; after, the overlay follows the
server. The optimistic mark still appears immediately during playback -- it
now yields to a later authoritative read instead of outliving one.

The #1287 and #1740 contracts are unchanged: neither branch issues an
explicit mark, and the tests assert that alongside the promotion.
2026-08-08 10:02:04 +02:00
edde746 5f397a99d9 fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing
Refresh left the first device showing the old "minutes left". Restarting the
app showed the right value. Two independent defects produce that, and either
alone reproduces the report.

The first is the watch-state overlay. Every local watch event lands in
WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites
viewOffsetMs unconditionally; isNewerThan only ever orders one patch against
another, never against the server row underneath. Nothing expires a patch and
nothing clears the map except a profile switch, so the Mac's own paused
position kept winning over every subsequent fetch until the process died.

A patch exists to bridge the gap between a local action and the next server
read of that item, so it should stop applying once that read happens. The
store now records the watermark at which a successful authoritative response
returned each key, and suppresses an acknowledged session patch at or below
it. Only a watermark is stored, never the observed state: WatchStateSnapshot
cannot hold a container's leaf counts, and keeping max() per key makes the
order two concurrent responses complete irrelevant. Suppression is a
read-time predicate, so nothing mutates during build.

The barrier covers the parentChain too. patchForItem picks the newest of the
item's own entry and its ancestors', so retiring only the item's entry would
let an older season mark win and render watched/0 -- worse than either the
stale value or the fresh one. An authoritative read of a child already
reflects any container mark that preceded it, so the child's observation
judges its ancestors as well; a newer container action still wins.

Provenance decides what may be suppressed at all. WatchStateEvent now carries
serverAcknowledged, defaulting to false so an unclassified emit site degrades
to today's behaviour rather than silently becoming retireable. An offline
write is owed to the server and a read must never retire it, so it stays
until a WatchPatchPromotionNotifier promotion says the queue replayed it. That
channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts
to watched/unwatched by purging queued progress, so replaying one there would
delete a newer rewatch. Promotion matches an exact WatchPatchId -- session
minted for live crossings, derived from the persisted (profile, row, revision)
for queued ones so it still joins after a restart.

Report acceptance is not delivery: PlaybackReportSession resolves true for a
same-state startup heartbeat it drops, so acknowledgement now keys on
onDelivered. A MediaBrowser Started saves play count and last-played date but
not the position, so it cannot acknowledge an offset. No report-derived
watched crossing is acknowledged on any backend -- Jellyfin hard-codes its
threshold and Plex never loads the server pref that would tell it the real
one -- so only an awaited explicit markWatched settles one.

The second defect is that a failed Refresh reported success. Plex _fetchHubs
and the Jellyfin hub legs both degrade a failure to an empty list, and the
library prefetch discarded its failures, so a server whose every hub request
failed was recorded as succeeded; DiscoverProvider then kept the previous rows,
set loaded and surfaced nothing. Worse, the background Continue Watching
refresh wiped the row outright on zero success.

Hub legs now report what they degraded through a HubFetchDiagnostics sink,
which keeps partial rows alongside the failure and leaves every existing
caller untouched. Failures ride through the aggregation results, a leg that
could not run because discovery failed contributes that failure rather than a
successful no-op, and loaded-server ids became succeeded - failed - cancelled
so one bad leg no longer caches a server as covered and blocks its retry. The
toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad
snackbar on failure while the retained rows stay on screen. Rollback after a
mid-pass exception is version-guarded, refilters against the current hidden
libraries and no longer publishes a system shelf the pass never committed.

Observations are staged with the pass and flushed only once the same disposal,
generation and exception checks that authorise committing those rows have
passed, so a discarded or rolled-back response can never suppress a patch.

Also fixes a live data-loss race the promotion work would have built on:
upsertProgressAction stamped a millisecond timestamp and updated the row in
place, so a rewatch queued during an in-flight replay was deleted by id.
Revisions are now strictly monotonic per row, replay deletes and retry updates
compare against them, and the upsert resets the retry fields because a new
revision is a new logical action.

close #1829
2026-08-08 09:09:48 +02:00
edde746 3364b3c22c fix(startup): keep the platform launch screen behind the loading frame
Since 2.10.0 the app opens on a Flutter-owned startup frame, and that frame
paints an opaque themed Scaffold before any preference is readable. Its
themeMode defaults to system, so the theme comes from platform brightness --
and a TV has no system dark-mode toggle, so Fire TV and Shield report light.
The result was a near-white #F7F7F8 sheet held for the whole gate, from
prefs through Sentry to the database open, over an Android window the
television resource qualifier had already painted black. Before 2.10.0 the
gate ran ahead of runApp and no Flutter frame existed to cover it.

Nothing in the loading frame is worth covering the launch screen for. Android
composites Flutter in TransparencyMode.transparent over a window whose colour
MainActivity already restored from plezy_prefs, so the loading Scaffold is
transparent there and the launch screen carries the launch. Every other
platform composites opaquely with nothing behind Flutter, so they keep
painting their own background.

The spinner and the failure screen still need a colour, and platform
brightness is the wrong one for exactly the devices this bug is about, so the
startup frames now adopt the persisted theme once it can be read. TV
detection has to run before that read: the theme_mode default is TV-aware and
isTVSync answers false until its singleton exists, which would resolve a
fresh Android TV install to the light theme. Both singletons are memoised and
awaited again by the gate. The read is best-effort -- an unreadable store is
the gate's failure to report, not this path's -- and it also stops a startup
failure from rendering as a full-screen white error page on a TV.

darkThemeFor and materialThemeModeFor move onto ThemeProvider so the startup
frames and the provider resolve OLED from one mapping rather than two.

Verified on an Android TV emulator in television/notnight mode, clean install,
cold start: peak frame luma 228 for 78 frames before, 0 frames above 120
after, and the same on a returning launch.

close #1833
2026-08-08 08:29:37 +02:00
edde746 24a041977b fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.

BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus
Flexible(child:), and each sheet body shrink-wraps its own scrollable. The
scaffold's shrinkWrap flag is gone: its old true branch put the child on an
unbounded axis, where an over-tall list overflowed instead of clamping and
scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px
to 118px for one chapter and the two-column track sheet from 750px to 154px
for one audio and one subtitle track, both still clamping at the cap.

Add SheetSplitColumns for the three side-by-side sheet layouts. A bare
VerticalDivider has no intrinsic height, so it inflated those rows to the cap
on its own; the rule now paints from a Positioned.fill that cannot size the
Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics.

Because sheets are bottom-anchored, a content-driven height moves the sheet's
top edge and everything above the change point. Three surfaces opt out for
that reason and say so at the call site: SubtitleSearchSheet and its language
picker keep filling, since both refilter under an autofocused field;
FiltersBottomSheet holds the outgoing page's height through its loading
transient; and RatingBottomSheet no longer hides MAL/AniList rows
asynchronously, which used to slide live rating controls down two rows several
hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet
boundary rather than editing a widget with 33 filling call sites.

The host gains an AnimatedSize keyed per sheet session so nested pushes ease
while a replacing show adopts its own height, a 720px absolute height ceiling
on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold
so short sheets neither close on a nudge nor become undismissable.

Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
2026-08-08 00:37:17 +02:00
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00
edde746 feb34caeb7 fix(i18n): shorten nav labels and complete translations in all locales
Nav bar labels that overflow their tab slot on phones are shortened to
idiomatic short forms: fr (Bibliothèque, Téléchargement, Recherche),
ru/bg/it (Live TV), pl (Home).

All 21 locales get the ~280 keys that were empty (falling back to
English at runtime): explore detail/badges/stats, fileInfo, startup
repair flow, mediaMenu delete dialogs, addServer, rating sources,
downloads sync-rule removal, and more.

settings.displayScale was missing entirely in 16 locales; the new
exoplayer playbackBuffer keys (upstream feat) are translated too.

Fixes mis-translations found in review: es/zh/zh-Hant sidecar-format
wording, sv adaptation, nb transcoding.

Regenerated with dart run slang; translation hygiene and i18n tests
pass.

Close #1823
2026-08-07 10:15:48 +02:00
edde746 4816e3928f fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make
the next press rebase off a position the seek has not reached yet. Nothing
retired that pin when something else moved the playhead, so for the ten
seconds it survived, a skip taken after a timeline tap, a chapter jump, an
OS media control or a peer sync resumed from the superseded target and threw
the user back across their own jump.

Publish every playhead movement on the player and retire the pin whenever
the announced destination is not the accumulator's own commit. Overlapping
seeks and backend-chosen relocations arbitrate by which operation the
backend accepted, so a request that was merely asked for cannot speak for
where the playhead ended up.

close #1819
2026-08-07 08:43:48 +02:00
edde746 660e375248 feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s
ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from
one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or
less free, with no way to raise it. On hardware where mpv cannot render at all
that ceiling is the whole buffer budget.

Playback Buffer offers Auto, Large and Extra Large. The durations are taken
from jellyfin-androidtv and jellyfin-android so the same words mean the same
thing across Jellyfin clients; Auto keeps the memory-tiered values that
shipped. Named tiers rather than a duration because a duration would be a
promise the load control cannot keep: prioritizeTimeOverSizeThresholds is
disabled, so targetBufferBytes stops the loader even below minBufferMs and the
byte cap binds first above roughly 23 Mbit/s.

The tier crosses the method channel as a string and resolves in the core,
where an unrecognised name falls back to Auto. LoadControlPolicy clamps the
resulting pair: media3 validates the ordering with Guava Preconditions, an
unconditional throw R8 does not elide, so a bad pair would be an
IllegalArgumentException out of player construction rather than a bad buffer.

The two play-start thresholds stay fixed even though the Jellyfin tiers move
them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from
BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the
stall watchdog indict a player that is obeying its own load control.

That leaves the byte target as a second, often smaller ceiling, and nothing
surfaced either. The resolved values now reach getStats, and the overlay's
Buffer section gains a Cache Limit row reading "120s / 128MB" next to the
buffered-ahead duration, so a tier that appears to do nothing on a
high-bitrate file explains itself.

close #1816
2026-08-07 08:43:47 +02:00
edde746 f5ccaab3ab test(player): anchor the passthrough absence check to the audio block
The check that keeps Audio Passthrough out of the in-player settings
sheet dragged the first Scrollable ten times and then asserted the
label was absent. That scrolling never moved: at the pumped 900x700
viewport the sheet fits its own content, so maxScrollExtent is 0 and
the offset stays there through every drag. The assertion passed
identically with no drags at all.

It caught a reintroduced toggle only because the whole list happens to
sit in the element tree at rest. Grow the sheet, shrink the viewport or
give it a lazy delegate and findsNothing starts passing because the
label is offscreen rather than gone, with nothing in the test to say
so.

Land on the audio block that used to hold the toggle first, then
assert the absence. scrollUntilVisible throws when that block is
missing entirely, so the guard fails loudly instead of quietly
weakening.
2026-08-07 08:43:47 +02:00
edde746 f63d0fe49e fix(music): shuffle the head of a shuffled queue too
Starting a music playlist, album, or artist on shuffle always opened on
the list's first track: MusicQueueController.load anchored _order[cursor]
and shuffled only the rest, and _startQueue collapsed "no start track"
into startIndex 0, so the anchor was always the head.

Anchoring is right for the two callers that do have a track which must
play first -- the now-playing shuffle toggle, and a load with an explicit
start track -- so make "no explicit start" representable instead of
inferring it from the index: load takes int? startIndex and shuffles the
whole list, head included, when it is null. A start track the list turns
out not to contain now drops the anchor rather than falling back to 0.

Video playback was never affected: Plex shuffles server-side via
/playQueues and Jellyfin already shuffles its full local list.

The queue's Random is injectable so the service-level regression is
deterministic without depending on the SDK's seeded-PRNG sequence.

Close #1811
2026-08-06 06:11:12 +02:00
Tolu Adegbehingbe f5488cb7ff fix(player): hold the Watch Together anchor while the host reloads (#1809)
An in-place source switch — audio, subtitle, version or quality — detaches
the host's player for the duration of the reload. `_broadcast` falls back to
a position of 0 when no player is attached, so any state published in that
window names 0:00 as the authoritative position and every guest hard-seeks
to the start of the item.

Heartbeats already suppress themselves while detached, which is why this
hides: the paths that leak the zero are the ones that answer on demand.
`onStateRequested`, `onPeerJoined` and `onReconnected` all broadcast
regardless of whether a player is attached, so a guest entering the player,
joining, or reconnecting mid-reload is the trigger.

Fall back to the last broadcast anchor instead. That field is only assigned
for untargeted broadcasts, so it holds the last position the room was
actually told, and the reload's own re-attach path already re-anchors from
it once the player comes back.
2026-08-06 05:54:17 +02:00
edde746 db4f7a643b test: prune low-value coverage 2026-08-06 05:33:18 +02:00
edde746 094be1fa3e fix(continue-watching): clear the resume position when an item is marked watched
Marking a movie or episode watched left it sitting in Continue Watching with a
checkmark, and the only way to shift it was to play it and skip to the end.

Continue Watching membership on a MediaBrowser server is derived from
UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played
normally zeroes that position as a side effect, so the row usually disappears
and nothing ever checked that it had. When something writes a position back
afterwards the item is left played *and* resumable, which the resume route
happily keeps returning forever. markWatched now reads the UserItemDataDto the
mark already returns and clears the bookmark itself when the server left one
behind, so the postcondition holds however the item got into that state. The
follow-up write costs a request only when the invariant is actually broken.

The writer putting items there is our own offline queue. insertWatchAction
already drops queued progress for an item when the mark is itself queued, but
the online mark writes straight to the server and queues nothing, so a progress
row recorded earlier survived and replayed afterwards — pending actions go out
oldest first — restoring the very position the mark had cleared. The sync
service now listens for watch-state events and discards queued progress for
that item as the mark lands. Progress recorded after a mark is a rewatch and is
queued later, so it is untouched. Plex never showed this because it forwards the
recorded-at timestamp and lets the server discard a stale replay; the
MediaBrowser stop report has nowhere to put one.

Continue Watching also drops the row locally now instead of waiting a round trip
for the refetch to confirm it, matching what removal events already did, and
marking a season or show takes its on-deck episode with it.

Watched items are deliberately still not filtered out of the shelf: Jellyfin
keeps Played set when new progress arrives, so a rewatch in progress is
indistinguishable from a stuck row, and filtering would hide it.

close #1812
2026-08-06 04:21:24 +02:00
edde746 309a107912 feat(downloads): let Android move the app and its downloads to adoptable storage
Declare android:installLocation="auto" so the app becomes eligible for the
Settings "change storage" flow and pm move-package. Adoptable storage relocates
the private data directory with the APK, so downloads follow the app onto a USB
drive adopted by an Android TV.

Moving the app changes the private data directory, which invalidated any download
task already enqueued: those pinned BaseDirectory.root plus an absolute directory
that background_downloader persists verbatim, so a queued or paused download
resumed writing to a volume the app no longer owns. Enqueue app-storage targets
against the base directory the downloader re-resolves from the live app context
instead, and drop the tasks and records a previous location left behind so the
download restarts under the current one.

That sweep runs before the downloader is wired up, because initialization delivers
statuses accumulated while suspended — which can mark the row failed, and a failed
row is deliberately not restarted — and because rescheduleKilledTasks re-enqueues
every killed record it finds, stale absolute directory included.

Compare paths by containment rather than by string prefix while making a stored
path relative. A custom download root that merely starts with the base directory's
name is a sibling the app does not own, and stripping it re-rooted the download
inside app storage.

close #1794
2026-08-06 03:47:43 +02:00
edde746 21cf1ff8d4 fix(exoplayer): recover a stalled playback session instead of spinning on it
A session that lost its sink sat spinning forever: the watchdog measured media
time, which does not advance while the picture is frozen, so a stall could not be
told from an ordinary rebuffer and the recovery ladder was never climbed.

The stall is now judged in playout time against the load control's own view of
whether the buffer was ever enough. Readiness is the union of the three signals
rather than a precedence chain, because media3 stops asking its load control once
a renderer wedges - the very failure this watchdog exists to catch - and a stale
verdict could otherwise hold it shut. The watchdog is armed on every path that
replaces the source, including the same-state reloads that produce no state change
of their own, and a seek rebaselines it so a backward seek does not inherit the
old clock.

Handing over to MPV keeps the position playback reached rather than restarting the
episode, and a play or pause issued mid-handover is recorded on the queued open,
which is the only thing left to command while one core is being disposed and its
replacement does not yet exist.
2026-08-06 03:45:09 +02:00
edde746 f63a4b039a fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was
left staring at a launcher error with no way to link the account. The QR code and
the linking code are now rendered in the app on a car, so the pairing happens on
a phone while the vehicle shows what to scan.
2026-08-06 03:45:09 +02:00
edde746 961e9c0326 feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
2026-08-06 03:45:09 +02:00
edde746 4607d165fd fix(automotive): keep video from starting while a car is driving
DD-3 gives video no exemption: a restricted vehicle must not play it at all. The
gate is read at the single point where media actually opens, so every path that
can start a picture - an explicit play, a gapless arm, a track or channel switch,
a frame-rate-match resume, a reload, and the queue navigation commands of the OS
media session - is covered by one check rather than by a guard at each call site.
A seek can also start playback with no play call, because mpv resumes when it
seeks off the end of a file, so a restricted seek is followed by a pause.

Watch Together needed the pause to be local. A vehicle stopping one peer is not a
room-wide intent: a guest's forced pause is swallowed by the attachment's ledger
rather than published, while a host's still pauses the room, because a host that
kept broadcasting a frozen anchor would stall or rewind every guest it was meant
to protect. The layer that owns a pause owns the resume for it, and one
acknowledgement is recorded per event, so a surplus cannot eat the user's next
real pause.
2026-08-06 03:45:09 +02:00
edde746 3a56218a12 fix(automotive): keep music playing while a car is parked, and silence it while driving
Music ran under a foreground service whose lifecycle observer was registered for
App TV, so backgrounding the app on a head unit never paused it and driving never
stopped it. Both halves were wrong for a car: parked audio must survive the app
going to the background, and DD-2 requires it to stop when the vehicle starts
moving.

The vehicle now owns exactly the pause it caused. It is claimed when a restriction
arrives and discharged on the event that proves the resume, so a track the user
paused during a drive stays paused when the car parks. A restriction landing while
the next source is still resolving silences the native player as well as the
session, because the previous track is still coming out of it, and a pause that
throws ends the session rather than leaving audio running in a moving car.
2026-08-06 03:45:09 +02:00
edde746 7ce5a443fd feat(automotive): read the vehicle's driver-distraction state
Android Automotive tells an app when the car requires distraction optimization,
and Plezy never asked. A monitor now watches CarUxRestrictions and publishes the
verdict over the existing platform channel, where a single Dart gate answers
whether playback may start.

The car service is reached through the lifecycle-listener overload rather than
Car.createCar(Context). That overload blocks its caller for up to five seconds
polling ServiceManager, and on car-service death it reaches killClient(), which
kills the hosting process for any context that is not an Activity or a Service -
a crash in a system component would take the app down with it. Head units on
Android 9 and 10 predate the listener, so a legacy ServiceConnection is used
there, with the same identity guard on reconnect.

A vehicle that has not answered yet counts as restricted, and one deadline is
spent resolving it rather than one per request, so a wedged car service delays
playback once instead of on every open.
2026-08-06 03:45:09 +02:00
edde746 f93952ba6f fix(android): stop tunneling 24p video on the Fire TV Stick 4K
Tunneled playback on an AFTMM judders continuously through 23.976p direct play.
The #1802 reporter isolated it: turning off Tunneled Playback with every other
setting unchanged makes it smooth, and their log shows tunneling active for the
whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing.

Audio Passthrough looked like the trigger only because it is the one user-facing
switch that decides it. Passthrough off, or Downmix to Stereo on, both force the
Dolby track to decode to PCM, which trips the #1458 guard and takes tunneling
down with it. Passthrough on with downmix off is the only combination that keeps
a bitstreamed track, so it is the only one that stays tunneled.

Withdraw tunneling on that model for content at or below 30fps. The cut-off
keeps 4K50/60 tunneled, which is the workload Amazon documents the feature for.
The mechanism stays unconfirmed: tunneling fires no VideoFrameMetadataListener
and stops media3 counting frames in the codec, so nothing app-side can measure
the cadence. Only the trigger is established, and the quirk is scoped to it.

That needs a frame rate the app did not have. Neither MatroskaExtractor nor
Mp4Extractor populates Format.frameRate, and a tunneled session renders no
frames back for the native detector, so the server's rate now rides on the open
call. It is sent only for direct play, matching _primeDisplayCriteria: a
transcode's metadata describes the source, not what the server is about to send.

Also move Audio Passthrough out of the in-player settings sheet. It configures
the audio output route rather than the current playback, and applying it
mid-stream bounces the audio renderer and re-decides tunneling. Settings > Video
Playback already owns it, next to Tunneled Playback, which is applied the same
way. That description now mentions stutter, not only black HDR video, so the
workaround is findable on hardware this quirk does not cover.

The mpv backend failing to start the same 4K file is a separate defect and is
not addressed here; its uploaded log is no longer retrievable.
2026-08-06 03:45:09 +02:00
edde746 1b6a811c07 fix(delete): name the delete target and verify what its files back
"Delete from server" read identically for an episode, a season and a
whole show: same menu label, same dialog title, same red button, and a
body that named nothing. The menu header did not disambiguate either,
because MediaItem.displayTitle collapses an episode to its show name.
A reporter deleted a whole series from the detail hero's ⋮ believing it
acted on the episode he had highlighted, and the confirmation gave him
nothing to catch it with. Every one of those strings now names the kind,
and the body names the exact item — show, season and episode number, and
episode title.

Deleting a single item also destroyed files the confirmation never
mentioned: a Plex multi-episode file (S01E01-E03.mkv) takes its other
episodes with it, and a split item takes every part. The dialog now
reports that up front and, on success, emits deletion events for the
siblings the server destroyed so their rows do not linger.

The scope behind that warning is only asserted when it is established.
MediaItem.allPartFiles drops parts with no path, so a non-empty set
proves nothing about the ones it filtered out; a version is trusted only
when every part reports a file. A browse row that omits paths is missing
evidence rather than proof of a distinct file, so both the target and
each candidate sibling fall back to the detail endpoint before any
conclusion — otherwise a thin row, including the file-less part
PlexMappers fabricates for an empty payload, would look like a server
that withholds paths. When the answer cannot be established the dialog
says so in an error-tinted block and its button reads "Delete anyway",
separating a transient probe failure from a server that never sends
paths. It deliberately does not refuse: Plex withholds paths from
restricted users the server itself authorizes to delete, so failing
closed would take the feature away from them permanently.

Probing a season stays bounded in both directions. Siblings resolve one
at a time, so a season of thin rows cannot fan out a detail request per
episode, and expiry cancels the walk rather than merely abandoning it —
`Future.timeout` completes the future the caller awaits but leaves the
work behind it running, which would resume on the next sibling once the
outstanding request answered. A cooperative flag is checked before each
lookup, so at most the one already in flight outlives the deadline; the
neutral client exposes no abort handle for item lookups, so that one
cannot be recalled.

The spinner covering the probe was only barrierDismissible, which does
not stop system back. Back dismissed it and the cleanup pop then closed
the screen underneath, dropping the user out of the detail page
mid-flow. It now traps back, matching the non-dismissible contract its
own doc claims, which also repairs the log uploader and the file-info
sheet.

Coverage splits by what each layer owns. The dialog, its copy and the
DELETE wiring are backend-neutral and stay in the menu widget tests.
Plex — the backend multi-episode files actually come from — gets the
resolver over a real PlexClient and a mocked transport: a row with no
media at all, scope recovered from /library/metadata/{id}, siblings and
paths from /children, a Part that names no file, a sibling whose path
never resolves, the request count a sixty-episode thin season may cost,
and the rating key the DELETE carries. Those are plain async tests
because the Plex metadata cache is a real database whose I/O the widget
tester's fake clock never drives. Deadline behaviour needs the opposite,
so it is pinned separately under fakeAsync against a gated fake client,
with no wall-clock waiting anywhere.

close #1781
2026-08-06 03:45:08 +02:00
edde746 9d51a040c3 fix(player): keep the remote on the player surface after a window switch (#1797)
Returning to the desktop window with the chrome still up left arrow keys
navigating the OSD instead of seeking: the first press seeked and silently
moved focus onto Play/Pause, and every press after that walked the buttons.

A window blur drops Flutter's primary focus to the root scope, so the player
screen's reclaim parks it on its own node. The only handoff back down to the
controls was the chrome visible->hidden transition, so with the OSD up nothing
reclaimed it -- hence the reported workarounds of letting the controls hide, or
moving the pointer off the player and back. Pointer exit normally hides the
chrome and masks this, which is why it only shows when the pointer stays over
the player while another window takes focus.

Hand the surface back on window re-activation, next to the existing hide-path
claim, and rename the helper since it is no longer hidden-chrome specific. The
claim runs synchronously because a platform callback is not guaranteed to be
followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the
two no longer compete.

Also gate the screen's self-heal so a directional key no longer pulls focus into
the OSD when "Video Player Navigation" is off -- Tab and select keep their path
in, which the ungated return value would otherwise consume with nowhere to go.
2026-08-05 15:24:57 +02:00
edde746 23b8befe11 test(theme): load the deferred locale off the widget tester's fake clock
`await LocaleSettings.setLocale` inside a `testWidgets` body waits on a
deferred library load that only completes on the real event loop, so the
regex-dialog test hung indefinitely rather than failing. It passed only
because an earlier plain `test` in the same file loads `de` first —
running the file alone, under a name filter, or sharded apart from that
test stalled the run until the ten-minute timeout.
2026-08-05 13:17:26 +02:00
edde746 26dbce0277 fix(profiles): name the Plex user and account in one translated chip
A Plex account connection labels itself with the account owner's name.
Under a profile tile that reads as being signed in as the owner: the
Plex Home tile showed the owner beneath the Home user's own name, and a
local profile that borrowed a Home user out of someone else's account
showed only the lender.

Both halves of the relation now go through a single translated string,
so a locale orders them itself instead of inheriting the English
"user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the
account first. When the Home cache cannot resolve the connection's uuid
the chip names the account alone rather than falling back to a bare
name. ProfilesView carries the Home user cache that resolution needs,
and chip labels ellipsize now that an account label can be an email.
2026-08-05 13:05:15 +02:00
edde746 edaff1fbfc Merge pull request #1789 from JackDanger/fix/plex-home-account-chip
fix(profiles): label a Plex Home parent connection as an account

Conflict resolution: regenerated the Slang outputs against main's
translation set, added the empty locale placeholders the translation
gate requires, and gave the new widget test the StorageService provider
the picker now reads for profile recency.
2026-08-05 12:31:29 +02:00
edde746 eaa1736c4e feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a
Device Code app so no client secret or redirect URI ships in the binary
and TV, mobile and desktop all use the same flow.

MDBList omits `verification_uri_complete`, but its device page seeds the
code field from a `user_code` query parameter and the sign-in redirect
preserves the query string, so the activation link is built locally and
the dialog's open button lands on a filled-in form instead of an empty
one. A server-supplied complete URL still wins if one ever appears.

Poll state is read from the response body rather than the status code:
`authorization_pending` and `slow_down` both arrive as HTTP 400, and a
missing grant answers 404 `device_not_found`.

Writes go out as real-time `/scrobble/*` reports plus `/sync/watched`
for the marks that never pass through the player, with ratings on
`/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block
has no `tvdb` field, so a TVDB-only item is skipped rather than written
under an empty id block.
2026-08-05 12:03:05 +02:00
edde746 541fc2c097 test(player): measure AudioTrack release accounting on real hardware
The #1790 fix turns on an invariant no JVM fake can observe: `DefaultAudioSink`
charges a static, process-wide counter per flush and discharges it only from
`Listener::onReleased`, and any lasting imbalance stops media3 escalating audio
failures at all. The wrapper tests pin the wrapper's side of that contract
against a fake; nothing checked it against a real sink.

`onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased`
once per answered flush, both on the public `AnalyticsListener`, so counting them
across the cycles the reuse cache actually creates measures the counter directly
without reaching into media3 internals. Seeking repeatedly exercises the
park-and-reuse path; switching to a fixture with a different channel count forces
the eviction path.

On a Shield with the pre-fix wrapper this reports initialized=5, released=0 after
four seeks — the counter climbing once per seek in a live session, which is the
state that makes a later AudioTrack failure unrecoverable.

A second case runs the same measurement on a bitstream route, which is the output
that failed on the reporter's device. It probes the live route the way the app
does and skips when there is no encoded surround, so a phone or a TV set to PCM
does not report coverage it never had.
2026-08-05 12:03:05 +02:00
edde746 b97a22c213 fix(player): report every AudioTrack release so a failed one can recover
An episode that opens but never plays, forever, with no error and no way out
except force-quitting the app. The reporter's log has the whole shape: media
opens at 85206ms, the first video frame renders, `AudioTrack init failed 0
Config(48000, 252, 5, 40000)` is logged exactly once, and the position never
moves again. Force-quitting fixes it for a while, which is the tell — the state
that breaks recovery is process-wide and static.

`DefaultAudioSink` releases its `AudioOutput` on every flush — every seek, every
renderer disable, every reconfigure — and increments a private static
`pendingReleaseCount` as it does. It decrements only from `Listener::onReleased`.
`RawPositionAudioOutput.release` never called `delegate.release()` for a
cacheable output, and it forwarded `addListener` straight through, so the sink's
listener sat on the real output while the wrapper was parked and the increment
was never balanced. media3's own delivery is lossy too: it posts `onReleased` to
the playback looper, which `ExoPlayer.release()` has already quit by the time the
20ms-delayed release runs, so even a real release drops its decrement at
teardown.

A counter that never returns to zero silently disables media3's escalation of
both init and write failures: `PendingExceptionHolder` arms its throw deadline
only when nothing is pending, and short-circuits every retry while something is.
So the `InitializationException` is never thrown, the audio renderer never
becomes ready, and the player is pinned in `STATE_BUFFERING`. No
`PlaybackException` means `retryAfterAudioTrackError` never runs, which is why
the same failure recovered onto decoded PCM earlier in the same log and hung
outright later.

The wrapper now owns the listener set and answers every flush exactly once: at
once when it parks the track, because a parked track is never going to release;
on the delegate's confirmation for a real release; and from the provider at
teardown, where nothing else ever will. Bitstream outputs are not parked at all —
a direct route is often single-instance and a parked one would block its own
successor.

An eviction therefore builds its replacement while the old AudioTrack is still
going away, as upstream does. Holding the count open across the park to buy
media3 patience for that window was tried and is worse: it pins the counter above
zero for the whole live track after the first seek, which is the hang above.
Refusing to allocate until the release confirms is worse too — the refusal
reaches media3 as an init failure with no pending release to excuse it, so the
200ms deadline starts immediately and a slow TV teardown turns an ordinary config
change into a playback error. If the overlapping allocation does fail, media3
escalates into the audio recovery ladder and the watchdog below backs it up.

Because no amount of accounting hygiene guarantees media3 will raise the next
failure, add the watchdog that was missing. Nothing covered "buffering, holding
data, not moving": the frame watchdog wants `STATE_READY` and zero frames, the
decoder-hang check is cancelled by the first frame, `ResumeStallPolicy` treats a
frozen clock as explicitly not its business, `EndOfStreamPolicy` wants the
position past the duration, and media3's stuck-buffering detector wants an empty
buffer. `BufferingStallPolicy` covers exactly that hole and escalates through the
existing audio ladder — now shared with the exception path — then to the mpv
backend rather than leaving a spinner up.

The watchdog only indicts a player that could have started. `DefaultLoadControl`
is configured to hold playback until 5s is buffered after a rebuffer, so the
stall threshold is derived from that same constant rather than guessing at one,
and a buffer below it reads as starved — the loader's business, not the
renderer's. Starvation also restarts the stall clock, so a minute of network
rebuffering cannot bank the timeout and have the first poll after recovery
report a stall that never happened.

Also raise the passthrough buffer to a second. media3 defaults it to 250ms, which
the AC3 factor doubles to the 40000 bytes that failed here, and 1.10.1's only
retry is to keep halving; upstream adopted the same 1s floor in #3207.

Recovery now resumes from the furthest position reached rather than `lastPosition`,
which the poller writes down as freely as up — a dead clock reporting 0 is how an
audio recovery restarted a resumed episode from the top. On the Dart side the
episode loading flags are cleared on every exit of the in-place reload, not just
the success and rollback paths; a flag stranded by a superseded reload made the
Next button a no-op for the rest of the session.

close #1790
2026-08-05 12:03:05 +02:00
Torin Cooper-BennunandTorin Cooper-Bennun 80d3537975 fix(linux): do not schedule audio recovery after playback resume (#1786)
This was resulting in two audio stutters per playback resume.

Regression originates in 9f2e05079 (release 2.10.0).

Co-authored-by: Torin Cooper-Bennun <torin.cbennun@googlemail.com>
2026-08-05 11:55:28 +02:00
Vincent Vallee 8879941d29 fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on
Windows and Linux whenever HTPC-style player navigation was off, so it dropped
the window out of fullscreen regardless of who put it there. For anyone running
with "start in fullscreen" (or who had toggled fullscreen from the browse UI),
backing out of a movie left the app windowed, with "exit fullscreen on player
close" switched off.

Track fullscreen ownership instead: FullscreenStateManager now exposes a scope
that the player opens in initState and closes in dispose, and setFullscreen —
the single funnel every desktop platform reports through (window_manager on
Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) —
records whether the fullscreen currently active was entered inside that scope.
Escape only exits fullscreen the player itself entered; otherwise it is plain
Back. The scope is depth-counted so the next-episode swap, where the incoming
screen's initState runs before the outgoing screen's dispose, carries ownership
across rather than resetting it.

Nothing changes for a user who fullscreens from inside the player: Escape still
exits fullscreen first, then acts as Back. The fullscreen toggle button and its
shortcut are untouched, as is exitFullscreenOnPlayerClose.

Fixes #1624.
2026-08-05 07:14:11 +02:00
edde746 3b0b407cd0 docs: list Emby alongside Plex and Jellyfin
Feature footnotes distinguish the two MediaBrowser backends where they diverge:
favorite and unwatched filters work on both, while Quick Connect stays
Jellyfin-only because Emby exposes no such route. LAN discovery covers both,
since Emby answers only its own datagram.
2026-08-05 06:09:27 +02:00
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00
edde746 f36e20bcad fix(profiles): keep the profile picker highlighted while its list sorts
The picker resolved StorageService asynchronously and rebuilt its profiles
stream once it landed. Storage is what supplies profile recency, so the second
view arrived re-sorted a microtask after first paint. The sliver children
carried no keys, so that reorder handed each tile's Element the next profile's
focus node; detaching the old node dropped primary focus onto the enclosing
scope and took the D-pad highlight with it. The launch picker has no back
route on tvOS, so a user who can no longer see or move the selection has
nothing useful left to press.

Read StorageService from the provider graph, where it is already resolved
before any route exists, so the stream is built once and the first painted
frame is already recency-sorted. Key the tiles and add findChildIndexCallback
so a later re-sort from a refreshed profile source moves a tile instead of
destroying it: without the lookup the sliver re-inflates the tile, which keeps
primary focus but resets FocusableWrapper's chrome to unfocused.

close #1792
2026-08-05 06:09:26 +02:00
github-actions[bot] d7c4ea0a8b chore: update cask to 2.12.1 2026-08-05 03:57:31 +00:00
Jack DangerandClaude Opus 5 dcc06768e0 fix(profiles): label a Plex Home parent connection as an account
A Plex Home profile's chip rendered the parent connection's displayLabel,
which for a Plex account is the account owner's username. The owner's name
appeared directly beneath the Home user's own, reading as the wrong user
being signed in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fdzda9t7kQtq7LoQ2v5nVF
2026-08-04 10:11:38 -07:00
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