0fbf2e205c8e8d797ed6f3da45db62e28f1d283f
411
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
84bc4b2618 |
fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in sixteen more. The English came from sites that produce their copy away from the widget that renders it, which is what the structural hardcoded-string check cannot see: picture-in-picture refused with a raw literal instead of the pipErrors.notSupported key that already existed; the two Jellyfin/Emby auth throws missing display: rendered their developer message on the add-server form; ServerParsingException.toString() fed its English into the localized "Failed to load servers" wrapper; Watch Together interpolated the whole PeerError, so a failed create read "Failed to create session: PeerError(PeerErrorType.timeout): Timed out creating session" and join printed its prefix twice; the hub and playlist continuation footers rendered exception.toString(); shader rows showed an English title over an already translated subtitle; the player queue fell back to the raw Dart enum name; a Plex home user with no title showed "Unknown"; a failed player start showed "Exception: Failed to initialize player"; and the tvOS top-shelf header was hardcoded in an extension that has no Flutter engine. The blanks came from three recent features that added English keys without translations. clean_translations.py filled all 21 siblings with empty strings, so the Android TV resolution switch, every Jellyfin/Emby recording-rule field, the demuxer row, the Companion Remote address caption and the Seerr blocklist pill rendered nothing at all. Two fixes are structural rather than key swaps. ContinuationStatusSliver now takes an errorContext and calls a new non-logging localizedLoadErrorText, so no future throw can leak through it. lib/mpv stays free of user-facing copy: it raises a PlayerInitializationException sentinel and a PlayerError.playerInitFailed cause tag that the player screen resolves to localized text. The tvOS section title travels in the shelf payload, additively, so an older cache still renders. |
||
|
|
a6cca37174 |
fix(ui): keep pages scrollable when the wheel lands on a hub row
On Linux the Home page, a library's Recommended tab and movie/show detail pages could not be scrolled at all, while Browse, Collections, Playlists, search, downloads and settings scrolled normally. Those three screens are exactly the ones whose viewport is covered by horizontal hub rows. A wheel, trackball, trackpoint or button-scroll event expresses one-axis intent, but the host reports both axes in the same event: on Linux only touchpad-sourced scrolling becomes a pan/zoom sequence that resolves in the gesture arena, and everything else arrives as one PointerScrollEvent carrying whatever dx the device produced. A Scrollable claims a scroll signal as soon as the delta along its own axis is non-zero, and the deepest claimant wins the PointerSignalResolver, so a tenth of a pixel of dx handed the whole event to the horizontal row and the page behind it never moved. Collapse a scroll signal onto its dominant axis in the binding, before hit testing - the last point where the delta can still be corrected, because a Scrollable has already registered with the resolver by the time it sees the event. Pan/zoom events are left alone, so trackpad panning keeps both axes, and a horizontal wheel or tilt still scrolls the row it points at. close #2081 |
||
|
|
c4059d0ead |
fix(startup): stop Cronet and Plex Home from blocking time-to-interactive
Two cold-start findings from the same pass. They share a call site in `MainScreen`'s post-frame block, so they land together. ## Cronet was 33% of time-to-interactive `createPlatformClient()` built the shared `CronetEngine` inline, so whichever consumer happened to create the first HTTP client paid for it — and that landed between `database_ready` and `credentials_loaded`, i.e. squarely on the path to the first usable screen. Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by temporarily forcing the existing `_cronetBroken` fallback so no engine is ever built: | phase | engine built inline | engine never built | |---|---|---| | database_ready | +455 | +456 | | credentials_loaded | +1171 | +703 | | binding_settled | +1331 | +827 | | main_screen | +1394 | +932 | So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule loadModule2NoCrashUtils` then `HttpFlagsLoader` reading `com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider *enumeration*, not selection — `CronetEngine.Builder(Context)` calls `isEnabled()` on every registered provider, and `PlayServicesCronetProvider` answers that by installing the Play services Dynamite module. `play-services-cronet` arrives transitively through `media3-datasource-cronet`, and `package:cronet_http` offers no way to choose a provider, so the only lever available in Dart is *when* the cost is paid. Android's `createPlatformClient()` now returns a client that resolves its delegate per request: the tuned IOClient that already backstops a broken Cronet until the shared engine exists, Cronet afterwards. Per-request matters — a `MediaServerHttpClient` builds its client in a constructor initializer and lives for the process, so deciding once at construction would have pinned primary media-server traffic to HTTP/1.1 forever, which is a silent steady-state regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine from `MainScreen`'s post-frame block. Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines (`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build now runs from +1023 to +1419, entirely after the first screen, and produces no Choreographer or Davey report — the UI is static waiting on hub content there, so there are no frames to drop. ## Plex Home refresh raced the offline decision `PlexHomeService.start()` conflated disk hydration with going live: it decoded the cached `plex_home_users_*` entries *and* subscribed to connection changes, installed a refresh timer and fired `_refreshAll()`. It was invoked straight from a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi these devices typically have — it started requests that would time out during the exact window the startup gate needs. Its immediate neighbour `ActiveProfileBinder` is explicitly not auto-started for this reason and says so in a comment; the same argument applied here and had simply not been followed. `start()` is now the live/network entry point and `hydrate()` is the disk-only half, coalesced and lifecycle-guarded like `start()` already was. The provider `create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow picker hydrates, because it reads `current` immediately and is reachable while offline. Only `MainScreen` goes live, gated on `!_isOffline`, with `_handleOfflineStatusChanged` picking it up if the session later regains network — otherwise an airplane-mode launch would never refresh Plex Home again. Hydration still `_emit()`s, so `stream`'s replay contract holds even when the network side never starts, which is what keeps a late listener behind a `combineLatest` off a permanent spinner. |
||
|
|
3f55cc461e |
fix(player): show audio codec and bitrate in the performance overlay on ExoPlayer
The Android performance overlay showed only sample rate and channels for EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide (hence AAC working), and the bitrate from Format.bitrate, which Matroska carries only when the muxer wrote BPS statistics tags. Fall back to the already-transmitted sample MIME type for the codec name (audio and video), and measure the audio bitrate in the FFmpeg demuxer from packet sizes over their pts span — the same source mpv uses for its audio-bitrate property — when the container declares none. close #2063 |
||
|
|
e5dc9c3f89 |
fix(networking): surface cancelled responses as cancellation and stop crediting a failed Plex hub fetch
A response cancelled before or during body consumption completed as a successful empty body, so cancellation-triggered aggregation could overwrite valid state and a cancelled download could commit an empty file. The managed client now delivers an abort error, and a request whose own abort has fired classifies secondary teardown errors as cancelled (timeouts keep their type). A Plex server whose /hubs leg failed was also counted as succeeded when only the optional music leg returned, letting Discover replace good hubs with a music-only result. |
||
|
|
fcb167e661 |
fix(library): size hub detail posters like home rows and library grids
Home hub rows showed ~3 large posters per row while the hub's "see all" page packed 5 small ones on the same 360dp phone. Hub detail was the only surface on the padding-aware target-count formula, which resolves ceil(lerp(5, 2, f)) columns regardless of screen width (and 9+ columns on TV). Drop the flag so hub detail shares the fixed-extent formula with every other grid, and delete the now-unused getMaxCrossAxisExtentWithPadding and its usePaddingAware plumbing. close #2039 |
||
|
|
2f47ed23cd |
chore: dedupe resolution display labels and fold single-use helpers
Two private formatters mapped the canonical server resolution label to display text (media_quality_labels, media_version) with edge-case drift, and several single-consumer helpers added indirection without callers: - add shared resolutionDisplayLabel in resolution_label.dart and migrate MediaVersion.displayLabel and the quality-label builder to it - drop the resolutionLabelFromHeight compat re-export from jellyfin_mappers (no remaining consumers) and its stale doc note - delete TraktCatalogSource.membershipKeysFor, byte-identical to the CatalogWatchlistMachinery default - derive the rating sheet backend label from MediaBrowserDialect.productName instead of a hardcoded switch - inline EndpointFailoverManager into failover_http_client.dart, its only importer, and remove endpoint_failover_interceptor.dart |
||
|
|
20896a3d48 |
fix(player): keep the wake-time profile picker responsive on Android TV
Sleeping the device mid-video with "Ask for profile on app open" enabled left the app wedged on an unresponsive Choose Profile screen (Nvidia Shield). The picker is pushed on the root navigator, but the player's focus self-heal only consulted its own nested profile-session route, so it yanked D-pad focus back behind the picker whenever it lost it. Route currency now walks every enclosing navigator (isRouteChainCurrent) before the player reclaims focus, primes loading-phase navigation focus, or claims the surface on window focus. The resume-time prompt is also skipped entirely while a video player is active: waking mid-stream resumes the stream instead of stacking the picker over the session. close #2034 |
||
|
|
3fb90a4948 |
fix(images): fetch full-resolution tile artwork on low-end hardware
Posters and episode thumbnails looked noticeably soft on Fire TV sticks and similar boxes: the auto-detected reduced tier capped artwork DPR at 1.5 and tightened tile decode caps, so every tile was fetched at 56% of its pixels and upscaled twice on large 4K panels. Drop the reduced-tier DPR cap and the poster/square/thumb decode caps so tiles fetch and decode at full TV density on every tier; raise the reduced image-cache budget to the 64MB TV baseline to absorb the larger tiles. Backdrops keep their scrim-masked ~720p reduced caps, and the display budget factor stays pinned to 1.0 there, so the low-RAM art ceiling is unchanged. close #2020 |
||
|
|
304d8a5e33 |
fix(plex): keep relay out of the phase-1 race, preferred persistence, and pinned sessions
A relay connection can win the phase-1 race (plex.tv edge answers fast), gets saved as the preferred endpoint, and the cached URL then wins the deterministic head-start probe at every bind — pinning future sessions to relay's 2 Mbps cap even after direct connectivity returns, surfacing as HTTP 500s on Original quality. A session that lands on relay legitimately (direct down at connect time) stays there all evening: re-optimization only fired on connectivity events. Relay is now a fallback tier in the race: all probes still start at once, but a relay success is held until every direct candidate has failed, and a cached relay URL gets no head start. A single persist gate refuses relay at all four endpoint-save sites, classified against both the live server and the connect-time capture so rotated relay URIs cannot slip through. While the active endpoint is relay, a bounded-backoff re-probe (30s/60s/ 120s) re-races the candidates so a returning direct endpoint promotes away without a restart. |
||
|
|
8461e6ba5f |
feat(detail): split play button with version picker segment when multiple versions exist
There was no way to tell from the detail screen that a movie or episode has multiple versions (theatrical/extended cuts, 1080p/4K encodes); the only entry point was the hidden Play Version item in the overflow menu. The Play button now becomes a Material 3 Expressive split button when the item carries more than one version and its server is reachable: the main segment keeps plain Play (which already resumes the remembered version), and a narrower chevron segment runs the existing Play Version flow (version picker, quality picker when the backend can transcode, preference save). The flow itself is extracted from the context menu into a shared promptAndPlayVersion helper, and FocusableActionBar gains per-action spacingBefore so the joined pair can sit tighter than the rest of the row. Transcode-only quality picking stays in the overflow menu so the chevron keeps signaling a real version choice. close #1881 |
||
|
|
1bdeca3b5f |
fix(libraries): give square music grids an inter-card gutter
Square-card (music) grids had zero delegate spacing - the only separation was each card's internal 3px padding, and square shapes are excluded from the full-card spacing path, so playlist and album tiles sat nearly edge to edge. Square grids now get an 8px cross/main gutter (automotive keeps its larger spacing); poster, list, and full-card layouts are unchanged. |
||
|
|
5fe1e6dc65 |
refactor(ui): delete unwired log output, dead notifier filters, and never-passed widget params
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates. |
||
|
|
78fa2c29e3 |
refactor(input): compile the disabled diagnostics and screenshot-blur subsystems out of normal builds
TextInputDiagnostics.enabled and kBlurArtwork were mutable/const-false flags nothing ever set, yet the diagnostics side eagerly built log strings on the gamepad and key-simulator hot paths on every event. Both flags are now const bool.fromEnvironment (PLEZY_TEXT_INPUT_DIAGNOSTICS / PLEZY_BLUR_ARTWORK) so the branches const-fold away, every eager interpolation sits behind a guard, and screenshot blurring stays one dart-define away. The vowel-rotation title obfuscation had no consumer need beyond artwork blur and is gone. |
||
|
|
f5c7b95b02 |
refactor(tv): read TV detection through the PlatformDetector facade everywhere
TV state had two interchangeable entry points — PlatformDetector.isTV/isAppleTV and the raw TvDetectionService.*Sync accessors — with call sites split arbitrarily between them. App code now goes through the facade; the raw accessors document that they exist for the facade and tests. |
||
|
|
4c2f533eef |
refactor(music): share the playlist play/download/delete flows between the context menu and the detail screens
The audio-playlist play flow, the playlist download flow (including its inline MediaItem synthesis), the delete-with-confirm flow, and the fetch-artist-then-navigate flow each existed twice — once in MediaContextMenu and once in the playlist/album/now-playing screens, with 'Match PlaylistDetailScreen' comments standing in for shared code. They now live in music_navigation.dart and download_utils.dart with per-caller strings preserved; the menu's collection/playlist download handlers fold into one parameterized handler, its duplicated sync/download menu tree is built once, and the generic picker dialogs move to collection_picker_dialog.dart (pure move). |
||
|
|
8033a308ce |
refactor(connection): drop dead status/kind enums, share the Plex account build pipeline
Connection.status was write-only health state (only the Jellyfin refresh wrote it, nothing read it — MultiServerManager owns real server status), ConnectionKind duplicated MediaBackend line for line, the token-to-PlexAccountConnection pipeline existed three times (sign-in, dev-token seed, legacy migration) with drifting label/dedup policy, and JellyfinConnectionAuthService.validate/refresh/signOut had no production callers. Connection.kind is now MediaBackend (persisted 'plex'/'jellyfin'/'emby' strings are identical, no migration); buildPlexAccountConnection in plex_account_setup.dart owns identity resolution with the three callers keeping only their genuine deltas; the test-only auth trio and its jellyfinSignOut timeout constant are gone. |
||
|
|
01f04eda42 |
fix(ui): show loading feedback while play actions fetch
Several Play entry points gave no feedback while their network round trips ran: a show or season Play button silently awaited a seasons fetch (with a 10 s completer window) and a first-episode fetch, and Plex collection/playlist launches showed no spinner at all. Where a spinner did exist, the first request was gated on the dialog's first frame instead of running concurrently with it. Extract executeWithLoading's dialog plumbing into ScopedLoadingDialogController (mount-aware dismissal, idempotent, never pops a foreign route) and reuse it for the show/season Play path. Start the launcher operation before awaiting the dialog frame so the first round trip overlaps the render, and show the loading indicator for Plex list launches too. |
||
|
|
0f016e4e13 |
fix(player): start the playback fetch before player construction
The playback data resolve — the one network request that must land before open() — was kicked off only after the SharedPreferences load, the Windows display-mode sync, the music-session teardown in claimVideo(), Player construction, and (Android/Exo) a native setLogLevel, so none of that setup overlapped the round trip. Move the kickoff to immediately after the settings reads; the resolve depends only on settings and provider lookups, so display sync, player construction, and the whole mpv property chain now hide behind the network latency instead of preceding it. Also remove redundant work elsewhere on the start path: memoize PlayerAndroid.getHeapSize (asked again on every open for an immutable device value), skip the ten subtitle-style settings reads on mpv backends where setSubtitleStyle is a no-op, and stop navigateToVideoPlayer awaiting SettingsService.getInstance twice per launch — the external-player read now sits behind the supportsExternalPlayers guard. |
||
|
|
e1b488f860 |
fix(lifecycle): force-close IOClients stuck in unabortable TCP connects
tvOS logs flood with "HTTP client drain timed out" / "close deferred" warning pairs after every Plex endpoint race, and each sweep leaks the losing probes' sockets for the OS connect timeout (~75 s of SYN retries on Darwin). A request stuck in TCP connect cannot be aborted: package:http's IOClient only registers the abort handler once openUrl completes, so the graceful drain never finishes and ManagedHttpClient defers the inner close that would have reclaimed the socket. Fix at both layers: every IOClient now gets an explicit HttpClient.connectionTimeout matching MediaServerTimeouts.connect, and ManagedHttpClient gains an opt-in forceCloseOnDrainTimeout escalation used by all dart:io-backed clients — close(force: true) promptly fails in-flight requests, unlike the native-callback clients (CupertinoClient) the deferral exists for. close #1972 |
||
|
|
9dd8aa4679 |
fix(macos): remove the audio passthrough option
Enabling audio passthrough on macOS 2.14.0 silenced every AC3/EAC3/DTS item: the macOS player now pins ao=coreaudio, where audio-spdif redirects to coreaudio_exclusive. That needs an IEC61937-capable device Mac setups essentially never have, and with a restricted ao list mpv has no PCM fallback, so the failed AO init stalls playback with no audio. The toggle never delivered real bitstreaming on macOS anyway (2.13 decoded through AVPlayer), so stop offering and applying it there. |
||
|
|
bb642fce03 |
fix(plex): send Live TV favorite updates as JSON so they persist again
Adding or removing a Plex Live TV favorite channel never persisted: the PUT
to epg.provider.plex.tv/settings/favoriteChannels answered 400. The dio ->
package:http migration (
|
||
|
|
ce151a91e8 |
fix(player): show AAC instead of mp4a.40.2 in ExoPlayer track labels
ExoPlayer reports RFC 6381 codec IDs (mp4a.40.2, ec-3, dtsc) where mpv reports ffmpeg names, so the audio track picker and performance overlay showed raw identifiers instead of friendly names on the ExoPlayer path. Normalize RFC 6381 audio IDs in CodecUtils.formatAudioCodec, route the performance overlay's audio codec through it, and cover the video IDs (hvc1/hev1, av01, vp09) that fell through the overlay's matcher. close #1899 |
||
|
|
a49f506a0b |
fix(tv): keep quality labels visible when rating badges crowd the detail line
On TV, a movie carrying a full set of attributed scores pushed the resolution and audio labels past the right edge of the detail metadata line, which silently clipped them. The line now sheds its least useful parts when it overflows -- surplus rating badges first, then the whole ratings slot, then quality labels -- instead of hard-clipping the tail. It also leads with the year instead of the redundant "Movie"/"TV Show" label, matching the desktop hero chip order, and the Discover spotlight line gets the same fitting. Branded badge icons are pinned to their SVG viewBox aspect so the fit is measured exactly. close #1893 |
||
|
|
3a704a2b9b |
fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the HLS start URL, waited for the readiness probe to touch the segment at T, and then had mpv seek to T anyway. mpv's stream probing always reads segment zero first, and a Plex segment request is a seek, so the transcoder was dragged through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured against PMS 1.43, a segment response that races such a restart can be left open with headers sent and no data or error, and ffmpeg's HLS segment reads have no default timeout, so playback buffered forever after the first frame (issue #1859). Starting the session plain and letting the player's start=T request the resume segment performs the one unavoidable transcoder seek. The offset request parameter, the readiness probe, and the probe-only getStatus HTTP helper are removed; live TV time-shift keeps its own offset path. Transcode opens now also set an explicit network-timeout with demuxer-level reconnect options: mpv's stream-layer reconnect settings never reach ffmpeg's HLS segment fetches, so a silently hung segment response now times out after 20s and is re-requested on a fresh connection instead of buffering indefinitely. Verified against a live PMS (resume plays from the requested position) and a stall harness (hung segment re-requested at 20s with no content skip). |
||
|
|
69fadc220d | chore: clean up code comments | ||
|
|
369c6279d6 |
fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:
A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.
English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.
Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.
Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.
Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.
All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.
scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.
One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.
close #1856
|
||
|
|
f4ce60611b |
fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since |
||
|
|
8740a19f36 |
feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision request never sent offset=, so any non-zero open - resuming a transcoded title, or switching from Direct Play to a transcoded quality mid-playback - opened a session whose produced window begins at the start of the file and seeked it. mpv immediately requests a segment the transcoder has not produced, PMS answers 404 for it and every subsequent segment, and playback buffers forever. Send offset=<seconds> (6dp) with the decision and start request - the view offset on initial open, the resolved resume position on every in-place reload - so the session begins producing at the position the player consumes first. The playlist timeline is unchanged: an offset session's media playlist still covers the full title from segment zero, so the player keeps opening with start: at the resume position and in-stream seeks work as before. Before a native player opens an offset playlist, waitForTranscodeReady walks the master playlist, the media playlist, and the segment containing the offset, because PMS can publish a manifest before that segment is fetchable and mpv treats the 404 as an HLS error. The probe is best-effort: it never fails an open, hands off immediately on HTTP 500 (on the response and exception paths alike) so the server-limit dialog stays prompt, stops on cancellation, skips itself when the playlist durations never reach the offset, and stays out of the endpoint-failover cascade. In-place reloads resolve the replacement source only after the old stop report has gone out, so Plex cannot use that stop to terminate the replacement transcode. close #1840 |
||
|
|
e6be5f9fef |
fix(player): surface a persistent HTTP 503 at open instead of retrying forever
ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a server that keeps refusing the stream at open time left a silent black screen: ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached the screen. A new open-phase watchdog arms on the first 503 seen before any frame renders and, after 20s without one, synthesizes a server-http-503 error that shows an actionable dialog. Mid-stream 503s and live TV keep their existing ride-out paths. close #1830 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
88ffe0806d |
test(automotive): assert the picture-in-picture vetoes on every host
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
53535e1678 |
fix(jellyfin): percent-encode the MediaBrowser auth header
Since real device names started reaching the header, an accented one made login impossible: dart:io refuses header values above 0x7F, and CFNetwork puts the raw code unit on the wire as a Latin-1 byte, which Kestrel rejects as a malformed request with 400 before Jellyfin routes POST /Users/AuthenticateByName. Encode every field the way the official Jellyfin SDK does; the server already reverses it with WebUtility.UrlDecode, so the wire value stays pure ASCII while the device list shows the real name. Quotes, commas and `=` no longer need stripping either. sanitizeHeaderValue, which still guards the Plex headers, now folds Latin letters to their base form instead of emitting bytes no transport accepts. close #1685 |
||
|
|
78eedd21d3 |
style: apply dart format to eight drifted sources
`dart format --set-exit-if-changed` over lib and test rewrites these. The analysis job never reached its formatting step, so the drift went unnoticed. No behaviour changes. |