21cf1ff8d422f94395ff2b9ef9b82c70da23a7eb
2105
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
8879941d29 |
fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on Windows and Linux whenever HTPC-style player navigation was off, so it dropped the window out of fullscreen regardless of who put it there. For anyone running with "start in fullscreen" (or who had toggled fullscreen from the browse UI), backing out of a movie left the app windowed, with "exit fullscreen on player close" switched off. Track fullscreen ownership instead: FullscreenStateManager now exposes a scope that the player opens in initState and closes in dispose, and setFullscreen — the single funnel every desktop platform reports through (window_manager on Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) — records whether the fullscreen currently active was entered inside that scope. Escape only exits fullscreen the player itself entered; otherwise it is plain Back. The scope is depth-counted so the next-episode swap, where the incoming screen's initState runs before the outgoing screen's dispose, carries ownership across rather than resetting it. Nothing changes for a user who fullscreens from inside the player: Escape still exits fullscreen first, then acts as Back. The fullscreen toggle button and its shortcut are untouched, as is exitFullscreenOnPlayerClose. Fixes #1624. |
||
|
|
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 |
||
|
|
dcc06768e0 |
fix(profiles): label a Plex Home parent connection as an account
A Plex Home profile's chip rendered the parent connection's displayLabel, which for a Plex account is the account owner's username. The owner's name appeared directly beneath the Home user's own, reading as the wrong user being signed in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fdzda9t7kQtq7LoQ2v5nVF |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |