b7a438789f635e1314c82030de1a42393135f740
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
24a041977b |
fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a one-item player queue or a two-track picker filled ~75% of a desktop window with empty space. BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus Flexible(child:), and each sheet body shrink-wraps its own scrollable. The scaffold's shrinkWrap flag is gone: its old true branch put the child on an unbounded axis, where an over-tall list overflowed instead of clamping and scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px to 118px for one chapter and the two-column track sheet from 750px to 154px for one audio and one subtitle track, both still clamping at the cap. Add SheetSplitColumns for the three side-by-side sheet layouts. A bare VerticalDivider has no intrinsic height, so it inflated those rows to the cap on its own; the rule now paints from a Positioned.fill that cannot size the Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics. Because sheets are bottom-anchored, a content-driven height moves the sheet's top edge and everything above the change point. Three surfaces opt out for that reason and say so at the call site: SubtitleSearchSheet and its language picker keep filling, since both refilter under an autofocused field; FiltersBottomSheet holds the outgoing page's height through its loading transient; and RatingBottomSheet no longer hides MAL/AniList rows asynchronously, which used to slide live rating controls down two rows several hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet boundary rather than editing a widget with 33 filling call sites. The host gains an AnimatedSize keyed per sheet session so nested pushes ease while a replacing show adopts its own height, a 720px absolute height ceiling on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold so short sheets neither close on a nudge nor become undismissable. Add videoControls.noAudioDevicesAvailable so the audio output page shows a placeholder instead of a bare header while devices load. |
||
|
|
e3703892b3 |
fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and dropped focus onto Play/Pause, even with Video Player Navigation off. Two independent paths did it. InputModeTracker promoted on any key satisfying isNavigationKey, a set that unioned activation, dismissal and the menu key with the arrows and consulted no setting at all; separately the surface's Select handler always asked the chrome for focus. Escape had the same effect, which on desktop reads as the mouse cursor vanishing mid-playback. Both now ask one predicate. eventRequestsFocusNavigation decides whether the app switches to keyboard mode and whether a key may hand focus to the chrome, so the two cannot disagree and focus can never land on a control while focus chrome is still suppressed. Activation and dismissal act on what already has focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an arrow that will really traverse answer yes. The one input the predicate cannot read off the event, whether the focused feature owns arrow keys, rides on the node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet, prompt and OSD button stays an ordinary traversal target with nothing to re-enable. playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace five hand-copied pref-or-isTV expressions and a screen-level cache that disagreed with the live getter after a toggle. Services whose input is synthesized past HardwareKeyboard announce themselves through InputModeTracker.reportNonPointerInput rather than two static callbacks and three copies of a highlight-strategy write. That registration is now identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker after the incoming one initialised and cleared both callbacks, so gamepad and companion remote input had stopped switching to keyboard mode entirely. Falling out of the same rule: a companion heartbeat no longer flips an idle desktop host into keyboard mode, analog-stick drift promotes only past the deadzone that actually navigates, Enter keeps toggling playback once the chrome is up, Tab both reaches and traverses the OSD, and the player surface claims the remote from mount rather than only when the chrome starts hidden, so the first key on a desktop route is a playback shortcut instead of the screen node's chrome-raising self-heal. isNavigationKey becomes isReservedControlKey, since its real meaning is a shell key rather than a text character and the old name is what invited the conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it. |
||
|
|
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 |
||
|
|
4816e3928f |
fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819 |
||
|
|
660e375248 |
feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s
ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or less free, with no way to raise it. On hardware where mpv cannot render at all that ceiling is the whole buffer budget. Playback Buffer offers Auto, Large and Extra Large. The durations are taken from jellyfin-androidtv and jellyfin-android so the same words mean the same thing across Jellyfin clients; Auto keeps the memory-tiered values that shipped. Named tiers rather than a duration because a duration would be a promise the load control cannot keep: prioritizeTimeOverSizeThresholds is disabled, so targetBufferBytes stops the loader even below minBufferMs and the byte cap binds first above roughly 23 Mbit/s. The tier crosses the method channel as a string and resolves in the core, where an unrecognised name falls back to Auto. LoadControlPolicy clamps the resulting pair: media3 validates the ordering with Guava Preconditions, an unconditional throw R8 does not elide, so a bad pair would be an IllegalArgumentException out of player construction rather than a bad buffer. The two play-start thresholds stay fixed even though the Jellyfin tiers move them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the stall watchdog indict a player that is obeying its own load control. That leaves the byte target as a second, often smaller ceiling, and nothing surfaced either. The resolved values now reach getStats, and the overlay's Buffer section gains a Cache Limit row reading "120s / 128MB" next to the buffered-ahead duration, so a tier that appears to do nothing on a high-bitrate file explains itself. close #1816 |
||
|
|
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. |
||
|
|
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 |
||
|
|
db4f7a643b | test: prune low-value coverage | ||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
f63a4b039a |
fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was left staring at a launcher error with no way to link the account. The QR code and the linking code are now rendered in the app on a car, so the pairing happens on a phone while the vehicle shows what to scan. |
||
|
|
961e9c0326 |
feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone, and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default, adjustable in Appearance - by giving the app a smaller logical viewport and scaling the result back, so text, spacing and touch targets grow together instead of a font size being nudged in isolation. The scale sits above the messenger and the root Scaffold so snackbars and dialogs are scaled too, and insets are divided back into the scaled space so a system bar still reserves its physical size. A scaled surface is also a short one: the setup screen's fixed offsets and the now-playing transport are laid out to survive it, and a mistyped scale in a hand-edited settings file is clamped rather than failing startup. |
||
|
|
4607d165fd |
fix(automotive): keep video from starting while a car is driving
DD-3 gives video no exemption: a restricted vehicle must not play it at all. The gate is read at the single point where media actually opens, so every path that can start a picture - an explicit play, a gapless arm, a track or channel switch, a frame-rate-match resume, a reload, and the queue navigation commands of the OS media session - is covered by one check rather than by a guard at each call site. A seek can also start playback with no play call, because mpv resumes when it seeks off the end of a file, so a restricted seek is followed by a pause. Watch Together needed the pause to be local. A vehicle stopping one peer is not a room-wide intent: a guest's forced pause is swallowed by the attachment's ledger rather than published, while a host's still pauses the room, because a host that kept broadcasting a frozen anchor would stall or rewind every guest it was meant to protect. The layer that owns a pause owns the resume for it, and one acknowledgement is recorded per event, so a surplus cannot eat the user's next real pause. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
26dbce0277 |
fix(profiles): name the Plex user and account in one translated chip
A Plex account connection labels itself with the account owner's name. Under a profile tile that reads as being signed in as the owner: the Plex Home tile showed the owner beneath the Home user's own name, and a local profile that borrowed a Home user out of someone else's account showed only the lender. Both halves of the relation now go through a single translated string, so a locale orders them itself instead of inheriting the English "user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the account first. When the Home cache cannot resolve the connection's uuid the chip names the account alone rather than falling back to a bare name. ProfilesView carries the Home user cache that resolution needs, and chip labels ellipsize now that an account label can be an email. |
||
|
|
edaff1fbfc |
Merge pull request #1789 from JackDanger/fix/plex-home-account-chip
fix(profiles): label a Plex Home parent connection as an account Conflict resolution: regenerated the Slang outputs against main's translation set, added the empty locale placeholders the translation gate requires, and gave the new widget test the StorageService provider the picker now reads for profile recency. |
||
|
|
eaa1736c4e |
feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a Device Code app so no client secret or redirect URI ships in the binary and TV, mobile and desktop all use the same flow. MDBList omits `verification_uri_complete`, but its device page seeds the code field from a `user_code` query parameter and the sign-in redirect preserves the query string, so the activation link is built locally and the dialog's open button lands on a filled-in form instead of an empty one. A server-supplied complete URL still wins if one ever appears. Poll state is read from the response body rather than the status code: `authorization_pending` and `slow_down` both arrive as HTTP 400, and a missing grant answers 404 `device_not_found`. Writes go out as real-time `/scrobble/*` reports plus `/sync/watched` for the marks that never pass through the player, with ratings on `/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block has no `tvdb` field, so a TVDB-only item is skipped rather than written under an empty id block. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
05fd622968 |
feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
|
||
|
|
f36e20bcad |
fix(profiles): keep the profile picker highlighted while its list sorts
The picker resolved StorageService asynchronously and rebuilt its profiles stream once it landed. Storage is what supplies profile recency, so the second view arrived re-sorted a microtask after first paint. The sliver children carried no keys, so that reorder handed each tile's Element the next profile's focus node; detaching the old node dropped primary focus onto the enclosing scope and took the D-pad highlight with it. The launch picker has no back route on tvOS, so a user who can no longer see or move the selection has nothing useful left to press. Read StorageService from the provider graph, where it is already resolved before any route exists, so the stream is built once and the first painted frame is already recency-sorted. Key the tiles and add findChildIndexCallback so a later re-sort from a refreshed profile source moves a tile instead of destroying it: without the lookup the sliver re-inflates the tile, which keeps primary focus but resets FocusableWrapper's chrome to unfocused. close #1792 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
fdd4c661fe |
fix(player): carry a picked subtitle language across episodes with sparse tags
The cross-item subtitle intent required declared languages on both sides, and a null on either side counted as a contradiction. Any untagged track - common when a title like "Swedish" is the only signal - declined on every episode advance, fell to the server's per-item priority, and turned the viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard gates). A unique real title match now vouches for a row when language evidence is missing on either side. Declared languages that disagree still decline no matter what the title says, forced-class parity is untouched, codec and external parity only break ties within the title-matched set, and a residual tie declines rather than guesses, so the wrong-track class of #1716 stays closed. A decline is also no longer laundered into a viewer decision: the resolver keeps the unserved preference on the selection, the open flow hands it to the track manager instead of a navigation-priority off (late native tracks may carry the container tags the server rows lack), the next episode boundary re-carries it instead of hardening it into an explicit off, and progress reports withhold the -1 subtitle index that would otherwise come back as the item's server-side default forever. A pick the screen could not map to a source row (no subtitle catalog, or an identity-matcher miss) previously never reached the committed session selection at all, so the next episode carried the stale off while the picked track was visibly on screen. Such picks now commit the raw native track without source ids and demote to a semantic intent at the boundary. close #1785 |
||
|
|
439ae1d733 |
perf(detail): paint a show before its on-deck episode is looked up
Jellyfin has no equivalent of Plex's bundled `?includeOnDeck=1`, so a show detail open chained `/Shows/NextUp` behind the item fetch and the screen sat on a spinner for both round trips. The second one is not needed to paint: everything except the play button's episode label comes from the item. `fetchItemWithOnDeck` now takes an `onItemReady` callback and invokes it as soon as the item is known, when that is strictly before on-deck settles. Plex returns both together and never invokes it. Phone and desktop only. TV keeps its own reveal gate — `_isTvDetailReadyToReveal` holds the foreground at opacity 0 until extras, related hubs, seasons and the first episode page have all loaded, and those still run after the on-deck lookup settles, so TV sees no change. Both halves are pinned by tests. Measured on a remote Jellyfin server, 15 interleaved show-detail opens per version: time to content 1264ms -> 1042ms (-18%), with the rest of the load unchanged. Seasons and extras deliberately still start after the whole lookup settles. Starting them at the early paint measured worse (time to settled +21%) because they contend with the on-deck request instead of overlapping it — the same reason `/Shows/NextUp` is not fired in parallel with the item fetch. That trade-off is also why TV was left alone rather than being unblocked by moving those loads earlier. Two ordering hazards the early paint introduces, both covered by `media_detail_screen_test.dart`: - The early call must not write on-deck. `_loadFullMetadata` runs again after playback, and clearing there would blank the play button for the length of the round trip. `onDeckSettled` marks the authoritative write, so a reload that finds the series finished still clears it. - A settled empty on-deck must not drop the episode-derived fallback that `_ensureFallbackOnDeckEpisode` supplies. close #1784 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
860ce1e11a |
feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.
Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.
The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.
close #1667
|
||
|
|
2b4875d389 |
fix(player): keep hidden and cycled subtitles off in the next episode
Episode navigation carries the subtitle choice this screen has committed, so a way of turning subtitles off that the screen never sees is undone by the next episode. ExoPlayer has no renderer-level visibility switch, so the player's hide toggle is emulated by deselecting the track. That emulation lasted until the next selection: the automatic pass after an episode change put subtitles straight back on screen while the toggle still read "hidden", and un-hiding then restored a track id belonging to the episode that had already ended. Hiding is now sticky across media opens the way mpv's global sub-visibility is, selections made while hidden become what un-hiding restores, and the toggle no longer refuses to restore because the hidden track reads as Off. Cycling subtitles over the native track list — downloads, and items whose server exposes no subtitle rows — went straight to the track manager, which owns the player selection and the server write-back but not the committed choice. The screen records the cycled track now. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
a9f0532f5f |
fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is 36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16 disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on API 35+ the window is edge-to-edge from the first frame and MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation. MainScreen's phone layout hides that. It supplies a bottomNavigationBar and never sets extendBody, so Flutter's Scaffold strips padding.bottom from the body MediaQuery and every tab is already safe. Routes pushed on the profile navigator are full-screen siblings of MainScreen with no bottom bar, so they receive the untouched inset and nothing consumes it - the last settings card and the final log lines render under the back, home, and recents buttons. Three shared hosts own most of those routes, so the inset is consumed there: FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing SliverSystemBottomInset, and the four screens that build their own Scaffold around a CustomScrollView append it directly. The new widget codifies the convention this repository had already written down but open-coded - insets baked into the scroll content rather than a SafeArea around the scroll view - so content still paints under the bar while the scroll extent grows enough to bring the last row above it. It reads padding from its own context and collapses to zero height wherever the inset is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside MainScreen's tab bodies. No platform branching, and it stacks additively with the music detail screens' existing mini-player spacers, which is correct because the mini-player itself floats above the navigation bar on a pushed route. Scroll views that are not sliver lists take the inset in their own padding: the companion remote's ListView, the auth screen's scroll container, and the two SliverFillRemaining sign-in forms, whose children size themselves from the extent remaining before them and so cannot be helped by a trailing sliver. The logs empty state is left alone for the same reason inverted - it already fills the viewport, and a trailing inset would only add scroll slack. Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation: Settings, Logs, and Video Playback all end clear of the bar. close #1766 |
||
|
|
fb0613e3db |
feat(player): toggle playback on a two-finger tap without raising the chrome
A touch viewer had to raise the chrome to pause, which dims the picture and covers the subtitle line they were trying to finish reading. A two-finger tap now toggles playback with the chrome left down, so the frame that pauses is the frame that was on screen. It fires the moment the chord resolves, in every player state. The two-finger double tap no longer resets the video zoom. Keeping it would mean holding this toggle back for the double-tap window before acting, and pausing late is pausing on the wrong frame. Zoom reset stays in the video settings sheet, its presets and the keyboard shortcut, and pinching back to 100% now snaps exactly within three percent so touch has a one-gesture path too. Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert under screen lock, in PiP and while the content strip is open; the zoom reset previously fired straight through a locked screen. close #1505 |
||
|
|
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 |
||
|
|
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 |
||
|
|
d83d0790ba |
fix(exoplayer): match side-loaded subtitles after media3 rewrites track ids
Plex sidecar subtitles are attached as MediaItem.SubtitleConfiguration and tagged `external_<n>`, then recovered from the Format id the track selector reports. Since media3 1.3.0, DefaultMediaSourceFactory always merges side-loaded subtitles with the primary source and MergingMediaPeriod rewrites every child format id to "<periodIndex>:<originalId>", so the tag arrives as "1:external_0" - measured on device - or "0:1:external_0" behind the container-sidecar merge. The prefix test therefore never matched and every sidecar reached Dart as an embedded track with no URI. A Plex sidecar's only identity is its stream key, which the app carries in that URI, so both matchers failed on it: a server-selected sidecar could never resolve and left subtitle selection pending, and a manually chosen one could not be mapped back to a stream id to write to the server. The already-attached branch of addSubtitleTrack compared the raw id too, so re-selecting a loaded sidecar silently did nothing. Route every write and readback of the tag through ExternalSubtitleIds, which matches the final id segment, and cover it with an instrumentation test that side-loads a subtitle through the real media3 media-source factory. Also stop claiming a saved track selection when no server stream was identified - there is no local store, so that path silently dropped the user's choice. close #1713 |
||
|
|
bbed260169 |
fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The chrome controller is born visible, and its auto-hide clock cannot arm until the first frame lands, so the controls did not merely appear early: they appeared exactly when the picture did, and then sat over the opening five seconds of every movie and episode. The timeline is gated behind the first frame, so the bar materialised on top of the video rather than over the loading spinner, which is what makes it read as a pop-up rather than as chrome that was already there. The route now opens with no chrome on TV. Nothing is lost: the loading spinner and buffering overlay are their own overlays, the screen focus node owns back, and the first D-pad press raises the controls the way it already does after every auto-hide. Pointer and touch platforms keep the chrome, where the viewer's hand is on the surface and the title and back affordance belong over the spinner. Initial presentation now follows initial visibility. They were separate: seeding only visibility would leave the route claiming its chrome was still presented, so PlayerNavigationCoordinator would read back as "hide the chrome", hide() would no-op against chrome that was never up, and the press would be swallowed instead of leaving the player. Controls that mount with the chrome already down now claim focus themselves. Focus normally reaches them through the hide transition, and their own autofocus cannot win it back because the screen node took it during the loading phase. Left alone, the screen node kept primary focus and its self-heal raised the entire OSD on the first D-pad press, which put the chrome straight back over the picture and bypassed the transient seek and transport indicators. Both player spinners now carry a label. They were bare progress indicators, so a screen reader announced nothing at all while the picture was coming up, and the TV Maestro flows had no way left to tell a loading player from a playing one once the Pause button stopped appearing on its own. The two TV flows are repaired to match. They waited on that button, and now wait for the labelled spinner to clear, which cannot happen before the media is opened. 05 additionally reaches Search by D-pad rather than a percentage coordinate, because a tap flips InputModeTracker to pointer mode and collapses the rail it is aiming at, and it gates on the play-next prompt's own Cancel action: "Next Episode" is also the credits skip button, so the old assertion could pass without the prompt ever opening. close #1765 |
||
|
|
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 |
||
|
|
1d9ffb7427 |
fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue Watching and the library rail still surfaced their contents in the Search tab. Thread the profile's hidden keys from SearchScreen through to the aggregation, and drop matching items between the fan-out and the ranking pass so hidden hits cannot spend the result limit and shrink what is shown. Items the backend cannot attribute to a library, such as Plex shared and external media, are kept. The screen re-runs the visible query when a library is hidden or unhidden while results are on screen. Its listener is attached only after the provider has hydrated, so the initial load notification cannot race the first query into running twice. Plex search rows now go through the library-aware tagger, so a response that names its section only via librarySectionKey or targetLibrarySectionID is still attributable, and therefore filterable. Jellyfin search results carry no library id at all: the mapper's ParentLibraryId is not a Jellyfin field, and ParentId resolves to a season or physical folder rather than a CollectionFolder. Filtering there needs server-side ParentId scoping and is left for a follow-up. close #1770 |
||
|
|
bac2a0d201 |
fix(player): keep Delete and Home editing text in player sheets
Bare Backspace and Home are player navigation keys, but they are also caret editing keys. The player screen's Focus wraps its OverlaySheetHost, so it saw them before the subtitle-search field could act: the press was consumed on key-down, DefaultTextEditingShortcuts never turned it into a deletion, and the back pipeline hid the chrome and then left the player. A focused text editor now takes both keys back, but only for physical keyboard presses — a synthesized dpad/gamepad press has no caret, and browserHome has no editing role at all. The screen also resolved its overlay-sheet controller from the State's own context, which sits above the host it was querying, so the lookup always returned null and Back skipped the sheet stage entirely. Resolve it from a context below the host instead, matching NowPlayingScreen. close #1741 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
2cb2c3eb95 |
feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor took `rating` and fell back to `audienceRating` only when it was null, so a Plex movie carrying four attributed scores surfaced one, and which one was whatever the server happened to put in the scalar slot. #1755 asked for a setting to choose the source; showing all of them answers it without one. The data was already on the wire and being thrown away. `/library/metadata/ {id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, but PlexMetadataDto declared no field for it, so json_serializable dropped the key. The identical parse already existed in plex_catalog_source for the Explore tab and had simply never been wired to library items. Model the scores as a list rather than widening the scalar pair. The neutral MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage and audienceRatingImage, which the list subsumes — Plex sends those images on listings too, so the same field covers both response shapes and no caller narrows to a backend type to read a score any more. CatalogRatingSource is promoted to lib/media as MediaRatingSource instead of growing a second near-identical type beside it, and plex_catalog_source's _ratingsFor becomes the shared plexRatingSources so one implementation serves both paths. There is no persistence to migrate: MediaItem.toJson has no production caller, the offline path re-parses raw Plex JSON through the same mapper, and Plex's audienceRating sort is server-supplied data, not a model read. Cards and the dashboard still show fewer scores than detail screens, and that part is a real Plex limit rather than a shortcut. Section listings send only the scalar pair; includeRatings, includeElements=Rating, includeFields=Rating, includeChildren and includeExtras were each probed against a live server and none surfaced the array, while includeGuids=1 demonstrably does add Guid[] — the probe works, the parameter does not exist. Hydrating every card would be one request per row, so listings render whatever their own response carried, which is one or two attributed scores rather than the single one they showed before. Jellyfin has no per-source array at all: the server collapses whatever its fetchers found into CommunityRating and CriticRating. CommunityRating's provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or a local NFO, last writer wins — so it stays the generic `audience` source with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a 0-100 percent and is divided by ten explicitly rather than folded by magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin reuses CommunityRating for the EXIF 0-5 star. The badges share one slot on every surface. On the phone hero the scores go in a single pill because that chip row is a height-clipped Wrap and a chip per source would push year, certification and runtime out of the visible band on short heroes; on the TV detail line and the dashboard spotlight the group occupies the one metadata slot so bullet separators do not multiply. The group announces itself as a single semantics node naming each source, because a bare row of four percentages tells a screen reader nothing about which score is which. rating_utils drops parseRatingImage and isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper — and the source-key resolver and label map, previously private to the Explore detail screen, become the shared pair both screens use. The label strings move from explore.ratingSource to common.ratingSource accordingly, which costs no translations because every non-English value was empty; running clean_translations also scaffolds startup.quitPlezy and startup.restartRequiredBody, which were already drifted. Verified against the live server the probes came from: a detail response now yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the production mapper and badge resolver, and the listing response for the same title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures. Coverage adds mapper ordering, dedupe against the array's repeat of the scalar, out-of-range rejection, the Jellyfin scale and photo guard, the CatalogItem conversion that feeds Explore's dashboard hubs, and the three render surfaces including the semantics announcement. close #1755 |
||
|
|
395798f28e |
fix(player): stop handing ExoPlayer the demuxer's buffer budget on Auto
On Auto, Dart derives a buffer size for mpv's demuxer from the device heap and sets it as `demuxer-max-bytes`. The Android player forwarded that same number to `DefaultLoadControl.setTargetBufferBytes`, so ExoPlayer's sample allocator was sized by a tier table written for a different consumer: 64MB on any device whose large heap is 512MB or less, which every Shield is. `targetBufferBytes` is a byte cap, so the media it represents collapses as bitrate rises — 64MB is 53s of a 10 Mbit/s stream but 5.2s of a 103 Mbit/s UHD remux. With `prioritizeTimeOverSizeThresholds` false the cap is hard: `shouldContinueLoading` returns false the moment the allocator reaches it no matter how little media that is, and `shouldStartPlayback` reports READY off the same byte term. Read-ahead that short starves the audio sink in bursts, and on a passthrough route that is enough to keep the AudioTrack from ever starting — the track initializes, accepts one access unit and never renders a frame. Because an enabled audio renderer owns the MediaClock, the whole player freezes and the black-screen watchdog then blames the video decoder and drops the session to mpv. Size the LoadControl target natively instead, from what actually bounds `DefaultAllocator`: the Java heap. `min(media3's own default for a video+audio selection, largeMemoryClass/4, availMem/4)` with a 32MB floor, the lowest tier that has already shipped. The quarter matches the threshold the Buffer Size setting already warns at, and the media3 default is a ceiling — this is not "buffer more than upstream", it is "stop buffering less". Deliberately not bitrate-aware, because the LoadControl is built during initialize, before any media is opened. `bufferSizeAuto` carries the distinction over the channel; `bufferSizeBytes` still travels with it because the plugin's mpv fallback replays it as a real demuxer property, and an explicit Buffer Size choice is still honoured verbatim. Confirmed against the hardware in the 2.9.1 passthrough report. That reporter's own log is a natural A/B: three runs at 64MB fail with `0 frames rendered after 8002ms`, spanning both DV conversion modes and both tunneling states, while the single run after he manually selected 128MB logs `Position advancing` and renders. Reproduced on the same Shield model with codec and bitrate held fixed and only the cap varied — 6s of audio demand stalls at 64MiB and plays at 128MiB, 4 of 4 predictions, with read-ahead measured off an injected DefaultAllocator at 65 664 and 131 776 KiB. That device reports `dalvik.vm.heapsize` 512m, so the heap term binds first at every free-memory level in his log and Auto now derives exactly the 128MB he had to pick by hand; the shipped path logs `Buffer: 128MB limit (auto, heap=512MB, available=568MB)` where it previously logged 64MB. |
||
|
|
3f49bcabf8 |
fix(prefs): replace the desktop preference store atomically
Upstream shared_preferences_windows and _linux write the whole preference document with a bare `writeAsStringSync`. That opens with the default `FileMode.write`, which truncates the live file before writing it, so every single preference write has a window in which the only copy on disk is empty or half-written. A crash, power loss, forced reboot or antivirus interception inside that window leaves a document that fails to parse on every subsequent launch — and the store holds the credential-vault key, so the loss is not recoverable by rewriting it. This is the corruption class behind #1732; the recovery path already landed is a band-aid over it. Vendor both packages under packages/ — the convention saf_util and wakelock_plus already follow — and stage, flush, then rename over the target. The flush has to precede the rename or it could publish contents that were never committed, the same corruption by another route. Staging uses one fixed sibling name rather than a stamped one, because the file is a plaintext copy of the vault key, tracker refresh tokens and Seerr cookies; it is created in the target's own directory so rename stays on one volume and the mode matches what the canonical file would have had, and a stale one is swept once the canonical document has been read cleanly. Both deltas are marked in-source and in provenance.json with the refresh contract. Atomicity is proven, not asserted. A hard link to the store observes the old document after a write, which only holds when the directory entry was replaced — truncate-in-place would have rewritten the shared inode, and that test does fail against unpatched upstream. Upstream's own suites still pass unchanged in both packages and now run in CI, so the patch keeps the contract it inherited. Windows `MoveFileExW` replacement semantics cannot be proven on a POSIX runner or a memory file system, so they get their own test on the existing windows-latest job, including replacement while a reader holds the file open — antivirus and Search Indexer both do. |
||
|
|
9ecf8db90f |
fix(startup): stop offering Retry after a repair that needs a restart
A seed-and-restart repair writes the salvaged credentials straight to disk and leaves this process's store closed, because the plugin still holds the bad document in memory. repairCorruptStore says so plainly — "nothing may write a preference before that restart … the caller keeps the app on the failure screen precisely so nothing does" — but the caller did not. Clearing the repairing flag re-enabled Retry, and pressing it reopened onto the stale map, whose first write would flush it back over the seed and orphan every ciphertext token in the database. The repair hook returned a bare bool, which cannot express the difference between "retry now" and "never retry in this process", so replace it with StartupRepairResult. The restart case latches terminal state on the bootstrap, withdraws Retry and Repair rather than grey them out — a disabled control still invites another press — and says what to do instead, which nothing did: repairNeedsRestart was a dialog title with no body anywhere. Desktop gets a Quit button through the existing AppExitService seam; Copy and Upload stay live everywhere, because a stuck user still needs the diagnostic out. close #1732 |
||
|
|
3ae7aa554b |
fix(prefs): recover a preference store whose bytes are not valid UTF-8
`File.readAsString` reports a UTF-8 decode failure as a FileSystemException, not a FormatException, so three guards written for that case never ran. The preflight's `on FormatException` branch was unreachable and its `on FileSystemException` sibling waved the document through; the plugin then threw the same FileSystemException, which failed the FormatException/TypeError test that decides repairability; and quarantine's lossy-decode fallback sat dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray 0x80 — therefore reached the user as a failure screen with no Repair button and no way forward at all. Read bytes and decode explicitly instead, at both sites. Classification moves into describeStoreDamage, so a failure that surfaces after the preflight passed is judged by re-reading the file rather than by the error's type: a denied or locked store is indistinguishable from a decode failure by type or message, and offering a destructive repair for a permissions problem would reset every setting and risk the vault key over something a chmod fixes. isCorruptStoreError went with it, having no remaining callers. A repair that quarantines the store and then cannot reopen it no longer strands the process either. The repaired future was built straight from the cache loader, bypassing the self-healing reset sharedCache installs, so a failed reopen parked a rejected future in _cacheFuture and every later attempt replayed that stale error — with the damaged file already moved aside, so a restart would have booted cleanly. CorruptPreferenceStoreException now carries reopenSafe and a derived, content-free shape: byte length, whether it decoded, whether every byte is zero. #1732 arrived as "FormatException at offset 0" and nothing else, which cannot separate an all-zero file from a non-JSON first character from bytes that are not UTF-8; these can, and never quote the document. Cover the loop against the real desktop backend rather than a fake. shared_preferences_linux is pure Dart, byte-identical to the Windows implementation, and exposes fs/pathProvider, so pointing it at a temp directory exercises the genuine read, parse, cache and write path on any host — the join between preflight, classification and reopen where every one of these defects lived, and which had no coverage at all. |
||
|
|
3509f4b989 |
docs: vendor the Microsoft Store badge as a PNG
GitHub sized the hotlinked SVG from its 161x44 intrinsic box rather than the img height attribute, so the badge rendered short beside the other three. rsvg-convert at exactly 4x intrinsic keeps the aspect ratio bit-identical and the rounded corners transparent, matching the neighbouring badge assets. All four now render 60px tall, and the README no longer hotlinks any image. |
||
|
|
8a46df2850 |
fix(player): keep live TV on its retry ladder when a stream 404s
The 404 branch added in 16668be5 ran ahead of the live-TV fallback chain, so a transient live 404 — an HLS segment rolled off the playlist, or a transcode session restarting under us — showed "file unavailable" and killed a stream the bounded ladder would have recovered. Only on-demand playback can read a 404 as terminal, where it really does mean the file is unreadable. 500 stays terminal for both, since a limit rejection is not something a retry clears. The dispatch lived in a private extension on the screen state, where no test could reach the decision. Extract it as resolvePlaybackFailureAction next to runLiveStreamRetry, which already sets that precedent, and cover both the live and on-demand paths plus the ladder's rungs. |
||
|
|
56ad48824b |
fix(jellyfin): pin MediaSourceId on every static stream URL
Jellyfin has no DirectStreamUrl field — MediaSourceInfo carries only
TranscodingUrl, and a DirectPlay decision returns no URL at all, leaving the
client to build /Videos/{id}/stream itself. The branch reading
DirectStreamUrl was therefore dead against every Jellyfin version, along with
the 'DirectStream' play method and the doc comment promising both.
The static URL also dropped MediaSourceId whenever the item had a single
source whose Id equalled the item id — an ordinary episode. The streaming
endpoint resolves a blank MediaSourceId to its own first sorted source
(VideoFile first, then widest video), so the omission silently streamed a
different file as soon as the item gained an alternate version. Forward the
id the negotiation settled on, as jellyfin-web, Findroid, and Streamyfin all
do unconditionally.
Every "pinned" fixture used a source id that differed from the item id, so no
test exercised the shape that dropped the param; add one that does.
|
||
|
|
4fe4f7b1d7 |
fix(mpv): stop loading the ytdl hook for media-server streams
Every URL the player opens is a media-server stream or a local file, so mpv's bundled ytdl_hook has nothing to resolve. It still ran an on_load hook per open and, whenever an open failed, spawned yt-dlp with the full stream URL in its argv — access token included, readable through /proc on Linux. It also added ~700ms to every failed open and buried the real "[stream] Failed to open" line under three ytdl_hook errors. mpv decides whether to load the builtin script inside mpv_initialize, so this has to be an option set beforehand rather than a property set from Dart. Verified against mpv 0.41: --ytdl=yes logs "Loading lua script @ytdl_hook.lua", --ytdl=no never loads it. Apple is deliberately excluded: the bundled libmpv is built without Lua, so the option does not exist there and setting it would only print an mpv error on every player init. |
||
|
|
86c8011b72 |
fix(player): tell the user when the server cannot read the media file
A 404 on the media stream means the server resolved the item but could not open the file behind it — moved, deleted, or on storage that went away. Jellyfin maps the resulting FileNotFoundException to 404, and PlaybackInfo never stats the file, so negotiation succeeds and only the stream request fails. Playback then died with a snackbar reading "Failed to open [REDACTED_URL]" before popping the route, which tells the user nothing and leaves nothing useful in a bug report. Generalize the HTTP-500 log probe into PlayerError.httpStatusFromLog and latch every status in fatalPlaybackHttpStatuses. Each latches on its own so the 503 that stream-lavf-o deliberately retries cannot mask the fatal status behind it. A 404 now raises a dedicated modal naming the cause and the fix. On Android a 404 previously failed the "Response code: 500" string test and fell through to the ExoPlayer→MPV fallback, showing "switching to compatible player" before failing again on the same request. Read the real status off HttpDataSource.InvalidResponseCodeException instead and skip the fallback: an HTTP status is not a codec problem. |
||
|
|
6f9edd3e93 |
fix(startup): make the deferred crash report survive its races
The persist-then-flush model had four ways to lose or corrupt the record it exists to protect. A no-op hub — which is what a failed or timed-out crash-reporting init leaves behind, because that phase is best effort — accepts an event and returns an empty id without throwing. "Did not throw" was treated as delivery, so the record was marked reported and suppressed forever. Delivery now requires a non-empty Sentry id, and init completion is tracked explicitly rather than assumed. Opting out, and building without a DSN, are deliberate suppression rather than delivery failure: both mark the record resolved so it is not rediscovered every launch. Everything else stays pending, and consumption no longer deletes an unreported record — deleting it ended the only retry there was, which made "the next launch tries again" false. The write path is now a queue. Record writes were launched unawaited from the failure path, so a fast retry could flush before the file existed, consume before a late write landed, or run two writers against one file and let the older one finish last. markReported joins the same queue and compares record identity before rewriting, because reading and writing outside it let a concurrent record land in between and be overwritten by the record it had just superseded. Records carry an id so that comparison is meaningful. Consumption also waits on a registered flush, so the success path cannot delete the file mid-send. Also routes the tvOS recovery marker through the tolerant read. reconcile() runs inside AppDatabase.open, a fatal gate step, so a wrong-typed marker vetoed the launch outright on a first-class TV target. Both new guards have regression tests verified to fail without the fix. |
||
|
|
9555937873 |
fix(startup): defer crash reports until the reporter exists
Reporting the failure inline was wrong for the phase that matters most. The gate opens preferences before SentryFlutter.init, so a corrupt or unreadable store — the likeliest cause of #1732 — was captured by a NoOpHub and silently discarded, which is exactly the telemetry gap the previous commit claimed to close. Initialising the reporter earlier is not an option either: `_beforeSend` reads the crash-reporting opt-out from settings, so events raised before settings load would bypass a user's choice. Every failure is now persisted first and flushed once the reporter is up with settings loaded, which in practice is the user's own retry seconds later in the same process. Records carry a `reported` flag so a send happens exactly once, and a failed send leaves the flag clear so the next launch tries again. The flush reads without consuming, so the record still reaches Settings > Logs. Also routes the tvOS recovery marker through the tolerant read: `reconcile()` runs inside `AppDatabase.open`, a fatal gate step, so a wrong-typed marker vetoed startup outright on a first-class TV target despite the new default-instead-of-veto behaviour. An unreadable marker tells us nothing, which is the same position as an absent one. |
||
|
|
66549e3a67 |
fix(prefs): route every credential read through the tolerant path
The wrong-type recovery only covered reads that went through a BaseSharedPreferencesService instance. The three stores that hold credentials read the shared cache directly, so a mistyped value there still threw a raw TypeError or, for Seerr, was swallowed by a catch-all and reported as "no session" — the registry documented protection it did not actually provide. readPreferenceTolerantly now takes the cache, so CredentialVault, TrackerAccountStore and SeerrSessionStore get the same classification as the settings layer. CredentialVault's post-write re-read moves outside its catch: a wrong-typed value written by another isolate was swallowed there, and the process then returned a key that never durably landed, making every ciphertext written under it unreadable on the next launch. Those stores are consulted long after startup, where a throw is an unhandled provider error rather than a repair prompt, so SettingsService initialization now walks the cached key set once and reads every sensitive key. That puts the failure inside a fatal gate step while the store is still open and a surgical single-key repair is possible. The remaining direct reads in settings and storage are routed too; the only ones left are the library-density dual-type migration, which probes both types deliberately, and an untyped switch that is type-safe by construction. |
||
|
|
7f0cad339c |
fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization gate, and that gate discarded the only evidence of its own failure. It caught the error, logged nothing but `error.runtimeType`, rendered an icon plus the word "Error" plus Retry, and never reported the error because catching it kept the crash reporter from ever seeing it. There is no log file on any platform, the buffer is in memory only, a double-clicked Windows release build has no console, and the log viewer lives in Settings, behind the gate that just failed. #1732 is the result: a Windows 11 user whose app will not boot and who cannot produce a single byte of diagnostic detail. The gate now names its phases. Each step is wrapped so a throw carries the phase it came from, replacing a `Future.wait` that discarded every error but the first and could not attribute it to any of four concurrent steps. The failure screen renders the phase, the exception type, the message and an expandable stack, plus copy and upload actions that reuse the existing log-relay flow. The record is persisted next to the database so the next successful launch can surface it in Settings > Logs, and it is reported to the crash reporter explicitly. Only preferences and the database still gate the launch. Window chrome, locale, crash-reporting init, TV/performance detection, the image-cache budget and download storage are best-effort and time-bounded, so a stalled platform thread degrades instead of holding the splash forever. Sentry no longer receives the startup work as its `appRunner`: that made a startup failure indistinguishable from a Sentry failure, and the guard would then have re-run migrations and the database open a second time. The two remaining fatal steps become recoverable. Preference reads tolerate a value whose stored type no longer matches, dropping the key and defaulting instead of failing the boot. A store that cannot be parsed is detected before either desktop plugin backend can memoise it, which is what makes an in-process repair possible at all. Repair is never automatic: it states what it will cost, salvages the credential-vault key and every tracker and Seerr session it can validate out of the damaged bytes, reseeds them, and moves the original aside rather than deleting it. Servers and profiles survive a salvaged key because their tokens are ciphertext in the database; tracker and Seerr sessions are plaintext preference entries, so the copy says they may still need reconnecting. Nothing derived from the store reaches a diagnostic. `FormatException` prints an excerpt of whatever it failed to parse, and during startup that document holds the vault key, refresh tokens and session cookies while the redaction manager still has nothing registered, so the wrapper keeps only the cause's type and offset and the record is an allowlist of already-redacted fields. The quarantined copy is labelled as containing credentials, is never offered for upload, and can be deleted from the dialog. Also self-heals orphaned WAL/SHM sidecars on desktop rather than only tvOS, makes every `createTable` migration step idempotent, keeps MSVC link by-products out of the Windows bundle, and asserts bundle contents in CI. Refs #1732 |
||
|
|
7c515bf8fa |
feat(website): serve Windows from the Microsoft Store and tag store campaigns
The Windows button downloaded plezy-windows-installer.exe from the latest release. It now opens the Store listing, which brings Store-managed updates. The endpoint redirects to ms-windows-store://, so the button only resolves on Windows; macOS and Linux keep their direct release downloads. Store links carry campaign parameters (ct=Landing, utm_campaign=landing, cid=landing) so landing-page traffic separates from the README's in each store's own reporting. Play reports utm_source and utm_campaign, so no utm_medium is sent. Structured data keeps untagged canonical URLs: schema.org offers are consumed by search engines, and a rich-result click is not landing-page traffic. |
||
|
|
f30e2c621a |
docs: refresh readme features and download channels
The features section last changed substantively in
|
||
|
|
d55b875855 |
fix(tvos): raise the system keyboard on arrival, not on every focus
Apple TV single-line fields moved to the engine's UITextField proxy in
2.10.0 (
|
||
|
|
944a8d89f5 |
feat(windows): package for the Microsoft Store as an MSIX bundle
The Store's unpackaged EXE path would require Authenticode-signing the installer and every PE file inside it. MSIX submissions are re-signed by the Store instead, so this route needs no code-signing certificate. build-msix.ps1 mirrors build-installer.ps1 and consumes the same per-architecture build artifacts, leaving the installer, portable archives and WinSparkle appcast untouched. One template generates the manifest for both architectures, carrying the identity reserved in Partner Center. check_windows_msix.py recomputes the package family name from the publisher DN, so a mistyped identity fails CI rather than a submission, and it parses the script rather than running it because root CI is Linux. Qualified logo assets are indexed into resources.pri; without the altform-unplated variants the shell draws the taskbar icon on an accent-coloured plate. PlatformDetector.isPackagedInstall gates the in-app updater and the Liberapay tile, which the read-only package directory and Store commerce policy respectively rule out. Gating at runtime keeps one Windows build feeding both the installer and the Store package. |
||
|
|
834895486b |
style: apply dart format to six drifted sources
Formatting was clean through |
||
|
|
4c8272d5b1 |
refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet. |
||
|
|
5a25c1f9cc |
feat(simkl): report playback progress while it happens
Simkl only heard about an item once playback crossed the media server's watched threshold, so stopping partway recorded nothing at all: no resumable position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the player lifecycle instead, carrying the measured progress. Seeks report nothing, as Simkl asks. The terminal stop owns watched state for in-player playback, so real-time trackers are excluded from the threshold markWatched fan-out and one watch never produces two writes. Progress is reported as measured — it doubles as the user's resume position — so when a server threshold configured below Simkl's own 80% rule would leave the watch unrecorded, the tracker records it through /sync/history rather than inflating progress. Manual, container, offline-replay and external-player marks keep using /sync/history. Only /scrobble/stop accepts a 409, which is the sole action documented to return one. Reports go out one at a time because Simkl serialises scrobble writes per user and fails queued ones with a 400; overflow sheds the oldest non-terminal report so an episode swap cannot drop the previous item's stop. A playback session is pinned to the account bound when it began and every send re-checks that binding, so a profile switch or a disconnect/reconnect can neither redirect a queued report nor misfile the watched fallback. Also close the paths that lost the terminal report entirely: app exit flushes it instead of dropping it, the desktop window button goes through the app shutdown rather than exit(0), a detached VOD player reports a stop, and a finished item reports completion at EOF instead of waiting for teardown. A session that opened at 0% is still closed on stop, or Simkl keeps showing the item as playing until its runtime elapses. close #1719 |
||
|
|
88ffe0806d |
test(automotive): assert the picture-in-picture vetoes on every host
|
||
|
|
0a6865fa18 |
fix(macos): unbound the AVFoundation AO's PCM lookahead
MPVKit 1.0.15 bounded how far ahead ao_avfoundation enqueues PCM on macOS — about 450ms of queue against the renderer's own ~1.7s — and disarms the feed between refills, re-arming from a half-bound timer. 2.10 is the first release to carry it: the AO pin went 1.0.12 to 1.0.16 over that release. #1711 reports macOS audio skipping roughly every half second on 2.10 that 2.9.1 does not have, and that bound is the only change to this path in the window, so restore the renderer-owned depth 2.9.1 shipped. The option documents 0 as exactly that. The AO itself stays. allowedAudioSpatializationFormats is a property of AVSampleBufferAudioRenderer, and the compressed E-AC3 JOC sink lives there too, while ao_coreaudio drives the HAL device and exposes no spatialization control at all — CoreAudio is the fallback, not an alternative. The cost is the latency the bound was added to remove: mpv multiplies --volume into the samples as it hands them over, so a volume change stays inaudible until the renderer queue drains. That is 2.9.1's behaviour, and the fix for it belongs to the AO's gain domain rather than to how far ahead it may buffer. Verified against the pinned MPVKit 1.0.16 libmpv on macOS: both option writes are accepted, playback lands on ao_avfoundation and advances at 0.997x real time. Runner's native suite passes. |
||
|
|
27b994422c |
feat(player): optionally follow the server's per-episode track selections
With the new playback setting enabled, episode advance carries no audio or subtitle preference at all, so both resolve from the streams selected on the server for each individual episode. This serves setups that curate selections server-side (e.g. Plex Auto Languages) and is independent of "Remember track selections", which keeps gating only the write-back of manual changes. close #1717 |
||
|
|
daab4f1e24 |
fix(player): preserve the forced-subtitle class across episode boundaries
Plex treats a subtitle stream as forced when its title says "Forced" even with the API flag unset. Every forced comparison now uses that effective forced-ness on both sides: the match scorer, the low-metadata hard gate, the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation. Carrying a track choice into the next episode no longer reuses the same-item identity matchers. A sealed SubtitlePreference (off / track reference / semantic intent) replaces the id-'navigation' pseudo-track through the whole preference channel, and cross-item intents hard-require language and forced-class parity. When the next episode has no track of the same class, the intent declines and selection falls through to the server's own per-episode choice instead of latching onto a full track by position and persisting that mistake back to the server. Intents wait for pending native tracks under the same catalog-completeness rule as source ids, so an early decline cannot retire the selection listener before the real track arrives. Ref #1716 |
||
|
|
f13f5af6e2 |
fix(images): scale artwork budgets to the physical display
Every artwork budget in the image pipeline was tuned for 1080p surfaces: the transcode request clamp (1920x1080), the per-type decode caps (poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV image-cache bytes. Those numbers are exact on phones and on the many TV boxes that composite the app at 1080p, but a TV compositing at 4K renders every capped image below its slot and GPU-upscales the result: hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x, shelf posters by ~1.13x - the softness reported against the official Plex client in #1697, and the class #860's min-2x-DPR fix could not reach. DevicePerformance now latches a display budget factor - the display's shortest physical axis over 1080, capped at 2x - whenever the image cache budget is applied (startup, post-mount, effects-setting changes). The transcode clamp, the full-tier decode caps and the TV cache bytes all scale by it, so a 4K surface fetches and decodes 4K backdrops and proportionally larger cards. The reduced tier stays pinned to 1.0, and sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art (~33MB per decode) cannot starve mid-RAM boxes; latching once per session keeps transcode URLs - and with them the disk cache keys - stable across rotation and rebuilds. Whether a given TV composites at 1080p or 4K decides whether any of this can help, and logs never recorded it: the startup banner and the log-upload header now carry a display line (physical, logical, DPR, latched budget) so uploaded logs answer that question directly. The two pre-existing Windows-host test failures (automotive auto-PiP gate, backdrop temp-dir teardown lock) reproduce unchanged on the base commit. |
||
|
|
1bf7aac75b |
fix(explore): match Plex Discover titles to the library again
Two defects sank Explore's Plex integration. Discover started rejecting X-Plex-Container-Size=500 with a 400, so the watchlist membership snapshot never loaded: hearts stayed unknown and toggles dead. The snapshot now pages at 100, and getWatchlist refetches a rejected page in chunks of the row fetch's field-proven 25, so the next cap drift degrades gracefully instead of failing and callers' offset math survives either way. Worse, every Plex catalog item reached the library matcher carrying only its Discover rating key: listings were fetched without includeGuids, so the lookup rested entirely on exact plex:// guid equality between two metadata universes (Discover duplicate entries break it, notoriously for anime), and the title fallback can never confirm a candidate without external ids to intersect - "Not in your library" for owned titles the MAL provider matched fine. Discover listings now request Guids, the detail screen re-runs the matcher when enrichment gains id forms (generation-guarded so the slower bare lookup cannot overwrite the richer verdict), the matcher keys its memo by id fingerprint so the poor form's cached negative cannot answer for the rich one, and the Plex client stops burning title requests that external-id verification is guaranteed to reject. Discover requests are now logged like every other API surface; this bug shipped blind because they were not. close #1715 |
||
|
|
53288116fe |
fix(explore): size catalog detail relations, ratings and facts to their content
Four sections of the catalog detail screen spent more room than their data justified. Franchise relations drew one hub shelf per label. Real payloads make that absurd: MAL returns twelve relations for Attack on Titan across six labels, and "Side story" and "Sequel" each hold exactly one title, so each spent a header, a scroll row and one card. Flatten the labelled groups into one "Related titles" section of compact rows — poster thumb, label, title and year — that flow into columns on wide viewports. D-pad moves through the grid by index and still hands off to the cast strip above and the recommendations shelf below, which keeps its shelf because taste-based recommendations are meant to be browsed. Drop the MAL picture gallery. It was a horizontal strip of unfocusable poster variants of the title you are already looking at, and it cost a page-height of scroll; the `pictures` field comes back out of the detail request with it. Draw attributed scores behind their own brand mark where the source has one, the way the media detail screen already does: Rotten Tomatoes fresh/rotten and upright/spilled, IMDb and TMDB, each on the scale that source publishes. Sources with no mark (critic, audience, tracker scores) keep their written label. Plex's own badge state is derived from the 60% tomatometer threshold it encodes in `image.rating.ripe`. Flow the definition rows — original title, studios, country, budget, box office, crew — into two or three columns once the window is wide enough. A 1440-wide window drew a 140-pixel label, a short value and 1,000 pixels of nothing per fact. Verified against live MAL and Plex Discover payloads on macOS: the Attack on Titan page drops from 4,082 to 2,115 logical pixels, Dune: Part Two from 1,282 to 1,154. |
||
|
|
644c782edd |
fix(player): tick the frame-clock keep-alive only on Linux
The 1x1 keep-alive repaint loop was extended to Windows in
|
||
|
|
27acbaf435 |
feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six catalog sources found the rest was lost at two boundaries — the wire-to-DTO mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the grid card fell through every branch of buildMetadataSubtitle to the year-only case, while the list card used by search already composed certification, runtime and rating from fields the synthesized MediaItem already held. Extend CatalogItem with the neutral facts every provider had been dropping: attributed rating sources, leaderboard ranks that keep their season window, audience counters that keep their timeframe, broadcast slots, next-episode air times, server availability and request state, exact release dates, alternate titles, format, source material, studios, countries, languages, credits, tags, links, artwork variants, play state, gallery art and background prose. Replace fetchCast and fetchRelated with one fetchDetail returning the enriched item, its cast, its recommendations and labelled franchise relations without adding a request: sources needing two calls keep two and run them concurrently with isolated failures. Map those fields in all six sources, widening only field selections that cost no extra round trip — MAL's fields list, AniList's selection set and a bounded row cast that lets detail skip its character call, Trakt's guest stars, Seerr's language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub artwork widens only on TV, where the spotlight is its only consumer, because it doubles the payload. Render them: a rating-first caption and bounded badges on the shelf card, labelled sections on the detail screen, provider hub styles and result counts on shelves, and logo, banner and accent art in the TV spotlight. Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7. |
||
|
|
0ef71e6489 |
feat(file-info): detail every version, file and stream the server reports
The sheet collapsed an item to `Media.first` / `MediaSources.first` and
rendered a fixed set of rows, so split files, extra versions, per-track
properties, HDR classification and Dolby Vision were all invisible.
Model the payload the way both servers shape it — versions own parts,
parts own streams — and project every property either backend populates
onto `MediaStreamDetails`. The field set comes from sweeping both test
servers in full through the clients' own request shapes (Plex 6842
Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763
streams / 61224 attachments), so file presence, Dolby Vision layers,
dynamic range, sample rate, spatial audio, sidecar provenance, embedded
attachments, rotation and the lyric stream type all survive. A coverage
test fails when a server key is neither carried, folded into a sibling,
nor excluded with a reason.
File Info also stopped trusting the shared `/library/metadata/{id}`
cache row: `getPlaybackExtras` writes it without `includeStreams` /
`checkFiles`, so the sheet could render with no stream table at all.
Detect that shape and refetch once under the request context captured
before the cache read, so the outgoing token and the cache namespace
stay on one profile.
Rework the layout to match: a summary chip row, then flat tonal cards
with a two-column field grid that collapses to one column on narrow
viewports, per-stream cards with flag chips, and a copyable monospace
path row. The card fill is a tonal step off the text colour rather than
the `bg` token, which is one shade from the sheet surface on OLED.
|
||
|
|
69ceea9466 |
fix(android): grant shelf artwork to every installed launcher
The Watch Next poster art moved to a local content:// URI in 2.10.0, gated twice on the package returned by resolveActivity(MAIN+HOME, MATCH_DEFAULT_ONLY): once as the grantUriPermission target, once as a caller-identity check inside SystemShelfArtworkProvider.openFile. Any launcher that is not the resolved default HOME activity was denied on every image and drew its broken-image placeholder instead. Fire OS pins its own launcher and silently reverts a third-party default, so Projectivy could never satisfy either gate; a device with several launchers and no chosen default resolves to the resolver activity and granted nobody at all. Discover consumers with queryIntentActivities(MAIN+HOME, MATCH_ALL) so every installed launcher is granted, drop the hand-rolled identity check, and make the provider non-exported so the framework enforces the per-URI grants that are now the only access path. Because those grants became load-bearing, grantReadAccess reports failure per poster and the sync rolls back rather than committing a row no launcher can open. close #1706 |
||
|
|
f02924b9a5 |
fix(player): require a fresh double tap for every mobile skip-zone seek
The skip badge doubled as an armed state: while it was up, any single tap in the same-direction zone seeked again. The badge is also raised by keyboard, D-pad, media-transport and live seeks, so one remote press armed one-tap seeking on the touch surface with no double tap at all. It stayed armed for 1200 ms plus the fade and renewed on every tap, leaving the side zones - 35% of the width each, over 70% of the height - unable to raise the chrome. Pair taps off the pending single-tap timer rather than differencing DateTime.now(). The window is then one deadline that a clock adjustment cannot stretch, suppressing touch taps disarms a half-finished pair, and _lastSkipTapTime belongs to the desktop double-click paths alone. Consecutive completed skips still accumulate into one running badge total. |
||
|
|
f3795d49eb |
feat(player): answer transport keys with transient indicators, not the chrome
Pressing pause or seeking while the player's on-screen controls were hidden raised the entire OSD, covering the subtitles the viewer was rewinding to read. Transport keys now answer with a transient indicator and leave the chrome down; Select, D-pad Center and a centre tap remain the deliberate way to bring the controls back. Play/pause confirms with an icon-only translucent disc at the centre of the frame, 72px around a 44px glyph, which grows and fades in, holds half a second at rest, then runs the same motion in reverse. Seeking shows the amount plus a single chevron on the same line at the edge it travels toward, with no backdrop at all: anything large enough to read as a surface is large enough to cover picture and subtitles, so legibility comes from shadows instead. Only the chevron moves, and it eases outward across most of its cycle and returns briefly, holding a visible opacity floor rather than blinking out. Type is scaled per platform, since a television is read from across the room. The existing text pill stays for genuine notices - rate changes, chapter titles, zoom, errors - because an earlier centred pill overlapped ASS \an8 subtitle placement, which is the readability complaint this feedback exists to answer. Every relative seek entry point now shares one coalescing primitive. The keyboard shortcuts fell through to KeyboardShortcutsService and previously reported nothing, and both they and the remote's chapter fallback rebased each press off player.state.position, so a burst against a slow backend pinned every request near one step while the indicator climbed to a total that was never committed. A released key commits its pending target immediately and resets the acceleration tier, including on live TV where seeks bypass the accumulator. A chapter seek with nowhere to go, past the last chapter or already at the start, no longer announces a jump it does not perform. Rewind-on-resume follows the resolved intent rather than the current state, so a directed pause on an already-paused video neither resumes nor rewinds. Indicators carry their own liveRegion semantics nodes: their labels previously merged into the full-screen "show playback controls" target, corrupting its accessible name, and they keep announcing "Paused"/"Playing" and the seek amount from icon-only visuals. close #1676 |
||
|
|
0eee9f688d |
fix(explore): match sequel catalog entries to their parent library show
MAL/AniList season entries never matched the library show they belong to. Both backends use the catalog title as a server-side filter before verifying external ids, and a title like "Mushoku Tensei: Jobless Reincarnation Season 2" cannot reach a show stored as "Mushoku Tensei: Jobless Reincarnation". Measured against a 267-show Plex library, 3 of 113 mapped sequel entries matched. The reverse lookup now takes two ordered title candidates instead of one: the entry's own title and its season-stripped form, with typographic punctuation normalised because both servers miss on a curly apostrophe. That matches 77 of 113. Widening it further to romaji/native/synonym variants reached only 81, so the cap stays at two rather than spending up to five more requests per lookup that finds nothing. A sequel's year is its own season's, not the parent show's, so the +/-1 year window is dropped for one - it would exclude the very show being looked for. That also keeps a miss at the two requests the single-title lookup already spent. A Plex Discover item additionally skips the title search entirely by filtering on the plex:// guid its own rating key already is, which costs no extra request and needs no cloud lookup. A season 2+ entry only matches when the server really has that season, which costs one children fetch on a match. Only a season both TVDB and TMDB agree on is gated: which provider a library numbers its seasons by is a server setting no dataset supplies and none of it is inferable from the ids an item exposes, so a disagreeing reference is left ungated rather than gated on a guess. The match cache is keyed per source and per entry rather than by canonical id, which every season of a series shares: all five Mushoku Tensei entries collapse to imdb:tt13293588, so one season-gated result would have poisoned the rest. Entries whose Fribb row carries no provider id at all remain unmatched. That is an upstream mapping gap, not something to guess around with extra lookups. close #1704 |
||
|
|
1165998dae |
fix(windows): elevate the installer when the install directory is read-only
PrivilegesRequired=lowest makes Inno Setup "always run in non administrative install mode" — the launching token is irrelevant. So a copy that ended up in C:\Program Files, which the destination page still lets an elevated wizard run pick, is registered under HKCU while living somewhere an ordinary process cannot write. UsePreviousAppDir then aims every later run straight back at that directory. WinSparkle launches the downloaded installer with plain ShellExecuteEx and no verb, so nothing along the in-app update path ever asks for elevation: the silent installer starts, cannot replace a single file, and the only way out was to quit Plezy, fetch the installer by hand and pick "Run as administrator". Inno's own PrivilegesRequiredOverridesAllowed plus UsePreviousPrivileges does not help here, because it reads the recorded install mode — which is exactly the non-administrative one that cannot write. Decide on write access instead. InitializeSetup probes the registered install directory and, when it is not writable, relaunches setup through ShellExec 'runas' pinned to that directory with /ALLUSERS, so the update lands in place instead of forking a second per-user copy. The relaunch carries a guard parameter and drops any conflicting mode override, and a refused UAC prompt now explains itself and points at the releases page rather than failing mutely. A machine-wide install that takes over a per-user directory also clears the stale uninstall entry and Start Menu group that would otherwise list Plezy twice in Apps & Features. Fresh installs are unchanged: still per-user, still no prompt. Only commandline is added to PrivilegesRequiredOverridesAllowed, since allowing dialog would make a silent install with no previous copy stop for the install-mode question — which is how winget installs. The script carried two near-identical copies of the whole .iss, one per architecture shape, so both would have needed this code. Collapse them into one template parameterised by architecture, add -EmitScriptOnly to generate the .iss without 7-Zip or Inno Setup, and guard the contract with check_windows_installer.py so the elevation path, the single-source AppId and the winget marker cannot rot. close #1705 |
||
|
|
726dfc6507 |
fix(android): finish the item when ExoPlayer never ends playback
media3 reports STUCK_PLAYING_NOT_ENDING when the player sits in STATE_READY past the declared duration with no renderer ending. On a tunneled MTK decoder the clock ran a full minute past the last frame behind a black screen, so the item never completed: no Play Next, no auto-play, and a "playing" timeline the server kept extrapolating past the item duration. Treat that report as the end of the file when the rendered-frame counter has stopped as well, which separates a finished file from a container that under-declares its duration and is still painting. The terminal event is shaped like the STATE_ENDED one and pins the timeline at the duration first, so the completion flow cannot mistake it for a stream that died mid-file. Shorten the detection window to media3's stuck-playing default, and clamp a backend hand-off to just inside the media so a fallback can no longer resume MPV past the last frame and park there without reporting it. close #1673 |
||
|
|
41ffaa7f2b |
fix(automotive): stop playback while a vehicle restricts the app
Plezy declares appCategory="video", so on Android Automotive OS it is a parked app bound by car app quality DD-2/DD-3: audio must stop when the vehicle starts driving and must not be resumable while driving. Two paths kept audio alive. Music playback ran under a mediaPlayback foreground service whose lifecycle observer was registered for Apple TV only, so it never paused when Android backgrounded the app. Video pausing hung off AppLifecycleState.hidden, which Flutter only synthesizes once Android delivers onStop; a car without the Automotive compatibility mode delivers onPause alone, which maps to AppLifecycleState.inactive and the player ignored. Gate every path that can start audio on a new lifecycle predicate, automotivePlaybackAllowed, which permits playback on a car only while the app is resumed and fails closed on an unknown lifecycle state. That covers explicit play, gapless arming and track transitions, live retry and channel switch, frame-rate-match resume, VOD/live startup, and the queue navigation commands of the OS media session, plus a last-resort pause for when the platform player resumes itself on native audio-focus regain. Playback authority on the media-session router is deliberately left alone: the router consumes a denied event, so gating it would swallow PauseEvent and leave the OS unable to stop audio. Reacting to lifecycle callbacks is the mechanism the platform documents as sufficient, so no android.car dependency is added. The music queue no longer requests POST_NOTIFICATIONS on a car, where the foreground service and its notification never start: there is nothing to authorize, and the prompt would take focus and make the gate discard the first play intent. Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV verdict, so a rotary-only head unit no longer inherits the leanback experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE, which cars lack, so the app's UI cannot stay on screen while driving, and nothing forces a preferred orientation on a fixed-orientation display. |
||
|
|
8aa836d106 |
ci(android): gate name-based reachability on an R8-minified variant
R8 only ever ran on `release`, so every automated gate in this repository exercised code the shipped APK does not contain. Reflective lookups, JNI callbacks and native library loading can all break under shrinking while `flutter test`, the Robolectric suites and `connectedDebugAndroidTest` stay green — which is exactly how #1703 shipped, with the bundled FFmpeg audio renderer shrunk out of release builds for TrueHD and DTS-HD. Add a `minified` build type that inherits release's shrinker configuration but stays debuggable and debug-signed, so it is an ordinary test artifact and never a publishable one. Three integration details took a run each to find: the Flutter plugin copies app build types into every plugin module, so library-level shrinking deleted the plugin entry points that only GeneratedPluginRegistrant references; the harness must not be shrunk or the runner disappears; and androidx.test has to survive in the app under test, or the runner cannot link its own supertype and the run reports zero tests instead of failing. Instrumentation still defaults to `debug`, because only one build type can host androidTest and the existing playback suites drive media3 builder APIs the app never calls, which R8 shrinks legitimately. The new reachability test opts into the minified variant instead and touches no builder API, so the only keeps it depends on are the ones under test. Emptying proguard-rules.pro was verified to fail it. |
||
|
|
a183c17c3b |
fix(android): derive the mpv fallback passthrough list from the audio route
Audio passthrough defaults on for Android TV, scoped to ExoPlayer because mpv force-passes through every codec named in audio-spdif and has no decode fallback. That scoping did not survive the ExoPlayer to mpv handoff: PlayerAndroid queued the raw ac3,eac3,dts,dts-hd,truehd list as a pending mpv property and prepareMpvFallback replayed it verbatim, so a sink that bitstreams only Dolby formats was told to force TrueHD and DTS-HD anyway. mpv selected spdif_truehd, the audio output never initialised, and playback froze at its start position while still showing a first frame — the stop timeline reported the position it opened with. Treat passthrough as a request and resolve the codec list against the route when mpv actually starts, so an HDMI or AVR change between ExoPlayer startup and the handoff cannot replay codecs from the old sink. Gate each codec on the exact advertised encoding rather than media3's passthrough probe: that probe answers DTS-HD by downgrading to the DTS core, and mpv reads "dts,dts-hd" as "dts-hd" alone, so accepting the downgrade would name DTS-HD MA to a core-only receiver and lose DTS too. |