a910aa95cb3d5eeacfc402bfbf412a514f3207aa
845
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3703ac47c3 |
refactor(explore): share the catalog enum-to-label mappings
The catalog detail screen and the card badges each kept their own enum-to-i18n switches; the genuinely shared mappings (season name, rank scope, request state, status/format/relation labels) now live in catalog_labels.dart, while badge precedence and the deliberately different availability/4k wording stay local with comments naming the divergence. |
||
|
|
2e1a7130fc |
refactor(downloads): one auto-remove-watched core, trimmed offline-watch provider, shared focus-restore mixin
The auto-remove-watched-downloads rule existed twice (DownloadProvider's sweep and OfflineWatchProvider's single-item copy) — the kind/completed/delete/title core now lives once on DownloadProvider, with the watched judgment staying per caller because the offline path fires before metadata can reflect a local mark. OfflineWatchProvider drops five members with no production callers (isSyncing, getPendingSyncCount, isWatched, getViewOffset, getEpisodesWithWatchStatus) plus the sync-service listener that only served them. The downloads screen's three verbatim 'suppressAutoFocus flipped — focus the first item' didUpdateWidget blocks are now one mixin. |
||
|
|
68c328b469 |
feat(search): label results with their source server and library
One server with overlapping libraries (movies in both an HD and a 4K library, shows duplicated per language) returns search results that cannot be told apart, forcing users to open each copy to find the right one (#1970). Each search row now carries a source line under the summary: backend icon, server name, and library name, shown whenever the owning server has more than one library. Plex rows name their section inline, with sectionKey-only rows back-filled from the loaded libraries; Jellyfin and Emby send no library field on search hits, so search always runs the per-library scoped fan-out that previously only served hidden-library exclusion and stamps every hit with its library. The Jellyfin detail lookup adds a best-effort /Items/{id}/Ancestors stamp so full metadata stays attributed, row refreshes merge instead of dropping the stamp, and the mapper no longer misreads SeriesStudio or ParentId as library identity. Episode rows trade one summary line for the source line so the wide-thumb row keeps its height, and the badge paints slightly below the line-box center to sit on the text's optical middle. close #1970 |
||
|
|
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. |
||
|
|
096e285ab0 |
fix(ui): center sheet back button hover circle on the arrow
Hovering the back button in bottom sheet headers drew the circular highlight 12px right of the arrow: the 48px hit target was positioned from the stack's padded origin while the glyph sits flush at the leading content edge. Move the horizontal padding onto the header row and position the target so the InkResponse box centers on the glyph. |
||
|
|
58b510a6c4 |
fix(music): handle hardware media keys while the app is foreground on Android
Media buttons on HID remotes (USB/Bluetooth keyboards, common on Android TV) are delivered as key events to the focused window instead of the MediaSession, so they only worked while the app was backgrounded. A global handler now routes play/pause, next/previous, stop, and fast-forward/rewind to the live music session anywhere in the app and consumes the key burst, so a press can neither leak to Android's fallback MediaSession dispatch nor start a focused library item. Same lifecycle as the OS media session; video playback never coexists with it because claiming video disposes the music session first. close #1948 |
||
|
|
2ff3b350ae |
feat(player): give sync delay sliders 50ms steps over a ±10s range
The sync delay slider spanned ±60s with 100ms steps, making fine adjustment between e.g. 100ms and 200ms impractical on touch and mouse. The slider now covers ±10s at 50ms per step (taps and D-pad included), while the +/- step buttons still reach the ±60s absolute limit via long-press, so existing large saved offsets keep working and display their true value. close #1907 |
||
|
|
d623c2164f |
fix(player): exit on phone back even with the chrome up (#1938)
|
||
|
|
5f6d9dc610 |
fix(navigation): keep the sidebar Libraries section collapsed across restarts
Collapsing Libraries in the sidebar only lasted until the app closed. The next launch always came back expanded, which buries the rest of the nav menu for anyone running a lot of libraries. The expansion flag was plain widget state on SideNavigationRailState. It survived tab navigation only because MainScreen pins the rail with a GlobalKey, and died on relaunch, profile switch or a layout change. It now lives in the librariesSectionExpanded preference, so the rail reads and writes the persisted value and follows a settings reset or import too. close #1896 |
||
|
|
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 |
||
|
|
8cdf04122f |
fix(tv): pause on the first Select press after skipping the intro
After activating Skip Intro with the remote, the next Select only raised the player controls and pausing took a second press. The skip button autofocuses on TV, and every path that hides it dropped focus out of the controls subtree - the screen node reclaimed the remote and its self-heal consumed the next key to raise the chrome instead of toggling playback. Hand the remote back to the player surface whenever the skip button disappears while focused: after a skip seek, when the playhead leaves the marker window, and when the auto-dismiss timer hides the button. The credits-at-end path is exempt because the play-next flow requests its own focus. close #1890 |
||
|
|
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
|
||
|
|
bcd6fe9906 |
feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry HDR at all, and which forced a whole-window Flutter recomposite for every video frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with mpv rendering into an EGL window surface on it through the libmpv render API. The subsurface is desynchronized, so video and UI now present independently. With the plane in place HDR follows: the surface is described to the compositor through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG, BT.2020 - carrying whatever HDR10 static metadata the stream actually declares. The description and the buffer it describes land on the same commit, staged and validated before mpv is switched, so a PQ frame is never presented labelled sRGB. A five-second watchdog bounds the one wait a compositor could otherwise leave hanging. A session that cannot host the plane - X11, or a compositor without wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the reason: the texture path is gone, and refusing by name beats degrading to something the user cannot see. An SDR output, a missing capability or an 8-bit config keep the plane and simply leave it undescribed. The output's colour state is trusted only when it has been earned. Every landed property step records itself as it lands; a reset or sequence that cannot finish downgrades its result to unknown and marks the applied-output cache untrusted until a clean apply earns it back. A plane whose output state cannot be named is quarantined - hidden, its description withdrawn - and the quarantine is recorded state: an unrelated visibility change cannot put a mislabelled plane back on screen, and only a commit that resolves to a nameable outcome lifts it. A rect collapsing to zero detaches the buffer exactly as hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the next layout pass retries for free, and a refused tone-mapping pick tells the user instead of dying in a log. NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window configs, so the plane takes half-float as the tier between 10-bit unorm and 8-bit, declares the whole surface opaque so the compositor never reads the alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie. Whether the output is in HDR is read from luminance headroom above its own reference white rather than from the preferred transfer function, which current KWin no longer answers PQ for; the margin is half a stop, because KWin reports an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090 (driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs. Who tone-maps is a user choice. The default is the compositor: photographed on a 400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and separated where the player leg flattens them, because the player path drives mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the renderer, not the wiring. The decision itself - what the source carries, what the output supports, what to tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of Wayland and GTK so its luminance validation can be tested without a display server. Sending an incoherent luminance set is a protocol error that disconnects the client, so the rules are worth a unit test. The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL: the plane links them directly and bundle-libs.sh deliberately never bundles them, since they are coupled to the running compositor and GPU driver. lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware - it drives one clip with scripted mpv properties and reports the colour state mpv actually settled on. Nothing imports it, so it is tree-shaken out of the app. Verified on a Steam Deck against an external 400-nit HDR display: the compositor reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall - closer to the reference HDR player than to its own SDR fallback. |
||
|
|
f1d4be70e2 |
feat(trackers): show a QR code in every tracker sign-in dialog and fit them on TV screens
Every tracker auth dialog (Trakt/Simkl/MDBList device-code and MAL/AniList OAuth proxy) now shares the same PendingAuthDialog affordances: a QR code for the sign-in URL, a large copyable URL with the scheme stripped, the browser launch button (hidden on Apple TV, which has no browser), and the polling spinner. On wide viewports (TV logical 960x540, desktop, phone landscape) the QR pane sits beside the instructions so the dialog no longer clips on tvOS, and the content is scrollable as an overflow safety net. Device activation codes scale down instead of wrapping. |
||
|
|
716fe9b51b |
feat(watchlist): add watchlist toggle to library context menus
Watchlist membership was only reachable from Explore cards and the detail screen's action row, which drops the bookmark first on narrow screens with no fallback in the overflow menu. Add an entry to MediaContextMenu for movies and shows whenever a connected catalog source can hold the item, covering card long-press everywhere and the detail screen's overflow. External-id resolution is session-cached per item on CatalogSourcesProvider and shared with the detail screen. A cold cache labels the entry "Add to Watchlist" and always adds (idempotent), so a press can never turn into a surprise removal; "Remove" is offered once cached membership proves it. Several capable sources open the same per-source chooser the detail screen uses. close #1822 |
||
|
|
9d13584c00 |
feat(settings): add appearance toggle to hide the Explore tab
The Explore tab appears for every Plex-backed profile because the Plex Discover catalog source connects implicitly, with no way to opt out short of a Jellyfin-only profile. Add a Show Explore Tab switch to Appearance > Navigation that hides the tab in the bottom navigation and side rail. UI-only: catalog sources stay connected so watchlist surfaces keep working while the tab is hidden. close #1844 |
||
|
|
f4ce60611b |
fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since |
||
|
|
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 |
||
|
|
f0debe2c32 |
feat(ui): show a system-format clock on TV home and in the player
The clock renders through the existing formatClockTime helper driven by MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour setting instead of introducing an app preference. It re-arms a one-shot timer onto each wall-clock minute boundary rather than polling, and resyncs on resume because a suspended process runs no timers. The player header is shared by the mobile and desktop/TV controls, so one insertion point covers every form factor: the player is fullscreen everywhere, so it never has an OS clock to defer to. Home is the exception and only gets one on TV, where a leanback app hides the system clock; a phone status bar and a desktop menu bar already show the time. |
||
|
|
24a041977b |
fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a one-item player queue or a two-track picker filled ~75% of a desktop window with empty space. BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus Flexible(child:), and each sheet body shrink-wraps its own scrollable. The scaffold's shrinkWrap flag is gone: its old true branch put the child on an unbounded axis, where an over-tall list overflowed instead of clamping and scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px to 118px for one chapter and the two-column track sheet from 750px to 154px for one audio and one subtitle track, both still clamping at the cap. Add SheetSplitColumns for the three side-by-side sheet layouts. A bare VerticalDivider has no intrinsic height, so it inflated those rows to the cap on its own; the rule now paints from a Positioned.fill that cannot size the Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics. Because sheets are bottom-anchored, a content-driven height moves the sheet's top edge and everything above the change point. Three surfaces opt out for that reason and say so at the call site: SubtitleSearchSheet and its language picker keep filling, since both refilter under an autofocused field; FiltersBottomSheet holds the outgoing page's height through its loading transient; and RatingBottomSheet no longer hides MAL/AniList rows asynchronously, which used to slide live rating controls down two rows several hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet boundary rather than editing a widget with 33 filling call sites. The host gains an AnimatedSize keyed per sheet session so nested pushes ease while a replacing show adopts its own height, a 720px absolute height ceiling on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold so short sheets neither close on a nudge nor become undismissable. Add videoControls.noAudioDevicesAvailable so the audio output page shows a placeholder instead of a bare header while devices load. |
||
|
|
e3703892b3 |
fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and dropped focus onto Play/Pause, even with Video Player Navigation off. Two independent paths did it. InputModeTracker promoted on any key satisfying isNavigationKey, a set that unioned activation, dismissal and the menu key with the arrows and consulted no setting at all; separately the surface's Select handler always asked the chrome for focus. Escape had the same effect, which on desktop reads as the mouse cursor vanishing mid-playback. Both now ask one predicate. eventRequestsFocusNavigation decides whether the app switches to keyboard mode and whether a key may hand focus to the chrome, so the two cannot disagree and focus can never land on a control while focus chrome is still suppressed. Activation and dismissal act on what already has focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an arrow that will really traverse answer yes. The one input the predicate cannot read off the event, whether the focused feature owns arrow keys, rides on the node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet, prompt and OSD button stays an ordinary traversal target with nothing to re-enable. playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace five hand-copied pref-or-isTV expressions and a screen-level cache that disagreed with the live getter after a toggle. Services whose input is synthesized past HardwareKeyboard announce themselves through InputModeTracker.reportNonPointerInput rather than two static callbacks and three copies of a highlight-strategy write. That registration is now identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker after the incoming one initialised and cleared both callbacks, so gamepad and companion remote input had stopped switching to keyboard mode entirely. Falling out of the same rule: a companion heartbeat no longer flips an idle desktop host into keyboard mode, analog-stick drift promotes only past the deadzone that actually navigates, Enter keeps toggling playback once the chrome is up, Tab both reaches and traverses the OSD, and the player surface claims the remote from mount rather than only when the chrome starts hidden, so the first key on a desktop route is a playback shortcut instead of the screen node's chrome-raising self-heal. isNavigationKey becomes isReservedControlKey, since its real meaning is a shell key rather than a text character and the old name is what invited the conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it. |
||
|
|
4816e3928f |
fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819 |
||
|
|
660e375248 |
feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s
ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or less free, with no way to raise it. On hardware where mpv cannot render at all that ceiling is the whole buffer budget. Playback Buffer offers Auto, Large and Extra Large. The durations are taken from jellyfin-androidtv and jellyfin-android so the same words mean the same thing across Jellyfin clients; Auto keeps the memory-tiered values that shipped. Named tiers rather than a duration because a duration would be a promise the load control cannot keep: prioritizeTimeOverSizeThresholds is disabled, so targetBufferBytes stops the loader even below minBufferMs and the byte cap binds first above roughly 23 Mbit/s. The tier crosses the method channel as a string and resolves in the core, where an unrecognised name falls back to Auto. LoadControlPolicy clamps the resulting pair: media3 validates the ordering with Guava Preconditions, an unconditional throw R8 does not elide, so a bad pair would be an IllegalArgumentException out of player construction rather than a bad buffer. The two play-start thresholds stay fixed even though the Jellyfin tiers move them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the stall watchdog indict a player that is obeying its own load control. That leaves the byte target as a second, often smaller ceiling, and nothing surfaced either. The resolved values now reach getStats, and the overlay's Buffer section gains a Cache Limit row reading "120s / 128MB" next to the buffered-ahead duration, so a tier that appears to do nothing on a high-bitrate file explains itself. close #1816 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
7f0cad339c |
fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization gate, and that gate discarded the only evidence of its own failure. It caught the error, logged nothing but `error.runtimeType`, rendered an icon plus the word "Error" plus Retry, and never reported the error because catching it kept the crash reporter from ever seeing it. There is no log file on any platform, the buffer is in memory only, a double-clicked Windows release build has no console, and the log viewer lives in Settings, behind the gate that just failed. #1732 is the result: a Windows 11 user whose app will not boot and who cannot produce a single byte of diagnostic detail. The gate now names its phases. Each step is wrapped so a throw carries the phase it came from, replacing a `Future.wait` that discarded every error but the first and could not attribute it to any of four concurrent steps. The failure screen renders the phase, the exception type, the message and an expandable stack, plus copy and upload actions that reuse the existing log-relay flow. The record is persisted next to the database so the next successful launch can surface it in Settings > Logs, and it is reported to the crash reporter explicitly. Only preferences and the database still gate the launch. Window chrome, locale, crash-reporting init, TV/performance detection, the image-cache budget and download storage are best-effort and time-bounded, so a stalled platform thread degrades instead of holding the splash forever. Sentry no longer receives the startup work as its `appRunner`: that made a startup failure indistinguishable from a Sentry failure, and the guard would then have re-run migrations and the database open a second time. The two remaining fatal steps become recoverable. Preference reads tolerate a value whose stored type no longer matches, dropping the key and defaulting instead of failing the boot. A store that cannot be parsed is detected before either desktop plugin backend can memoise it, which is what makes an in-process repair possible at all. Repair is never automatic: it states what it will cost, salvages the credential-vault key and every tracker and Seerr session it can validate out of the damaged bytes, reseeds them, and moves the original aside rather than deleting it. Servers and profiles survive a salvaged key because their tokens are ciphertext in the database; tracker and Seerr sessions are plaintext preference entries, so the copy says they may still need reconnecting. Nothing derived from the store reaches a diagnostic. `FormatException` prints an excerpt of whatever it failed to parse, and during startup that document holds the vault key, refresh tokens and session cookies while the redaction manager still has nothing registered, so the wrapper keeps only the cause's type and offset and the record is an allowlist of already-redacted fields. The quarantined copy is labelled as containing credentials, is never offered for upload, and can be deleted from the dialog. Also self-heals orphaned WAL/SHM sidecars on desktop rather than only tvOS, makes every `createTable` migration step idempotent, keeps MSVC link by-products out of the Windows bundle, and asserts bundle contents in CI. Refs #1732 |
||
|
|
53288116fe |
fix(explore): size catalog detail relations, ratings and facts to their content
Four sections of the catalog detail screen spent more room than their data justified. Franchise relations drew one hub shelf per label. Real payloads make that absurd: MAL returns twelve relations for Attack on Titan across six labels, and "Side story" and "Sequel" each hold exactly one title, so each spent a header, a scroll row and one card. Flatten the labelled groups into one "Related titles" section of compact rows — poster thumb, label, title and year — that flow into columns on wide viewports. D-pad moves through the grid by index and still hands off to the cast strip above and the recommendations shelf below, which keeps its shelf because taste-based recommendations are meant to be browsed. Drop the MAL picture gallery. It was a horizontal strip of unfocusable poster variants of the title you are already looking at, and it cost a page-height of scroll; the `pictures` field comes back out of the detail request with it. Draw attributed scores behind their own brand mark where the source has one, the way the media detail screen already does: Rotten Tomatoes fresh/rotten and upright/spilled, IMDb and TMDB, each on the scale that source publishes. Sources with no mark (critic, audience, tracker scores) keep their written label. Plex's own badge state is derived from the 60% tomatometer threshold it encodes in `image.rating.ripe`. Flow the definition rows — original title, studios, country, budget, box office, crew — into two or three columns once the window is wide enough. A 1440-wide window drew a 140-pixel label, a short value and 1,000 pixels of nothing per fact. Verified against live MAL and Plex Discover payloads on macOS: the Attack on Titan page drops from 4,082 to 2,115 logical pixels, Dune: Part Two from 1,282 to 1,154. |
||
|
|
644c782edd |
fix(player): tick the frame-clock keep-alive only on Linux
The 1x1 keep-alive repaint loop was extended to Windows in
|
||
|
|
27acbaf435 |
feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six catalog sources found the rest was lost at two boundaries — the wire-to-DTO mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the grid card fell through every branch of buildMetadataSubtitle to the year-only case, while the list card used by search already composed certification, runtime and rating from fields the synthesized MediaItem already held. Extend CatalogItem with the neutral facts every provider had been dropping: attributed rating sources, leaderboard ranks that keep their season window, audience counters that keep their timeframe, broadcast slots, next-episode air times, server availability and request state, exact release dates, alternate titles, format, source material, studios, countries, languages, credits, tags, links, artwork variants, play state, gallery art and background prose. Replace fetchCast and fetchRelated with one fetchDetail returning the enriched item, its cast, its recommendations and labelled franchise relations without adding a request: sources needing two calls keep two and run them concurrently with isolated failures. Map those fields in all six sources, widening only field selections that cost no extra round trip — MAL's fields list, AniList's selection set and a bounded row cast that lets detail skip its character call, Trakt's guest stars, Seerr's language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub artwork widens only on TV, where the spotlight is its only consumer, because it doubles the payload. Render them: a rating-first caption and bounded badges on the shelf card, labelled sections on the detail screen, provider hub styles and result counts on shelves, and logo, banner and accent art in the TV spotlight. Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7. |
||
|
|
0ef71e6489 |
feat(file-info): detail every version, file and stream the server reports
The sheet collapsed an item to `Media.first` / `MediaSources.first` and
rendered a fixed set of rows, so split files, extra versions, per-track
properties, HDR classification and Dolby Vision were all invisible.
Model the payload the way both servers shape it — versions own parts,
parts own streams — and project every property either backend populates
onto `MediaStreamDetails`. The field set comes from sweeping both test
servers in full through the clients' own request shapes (Plex 6842
Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763
streams / 61224 attachments), so file presence, Dolby Vision layers,
dynamic range, sample rate, spatial audio, sidecar provenance, embedded
attachments, rotation and the lyric stream type all survive. A coverage
test fails when a server key is neither carried, folded into a sibling,
nor excluded with a reason.
File Info also stopped trusting the shared `/library/metadata/{id}`
cache row: `getPlaybackExtras` writes it without `includeStreams` /
`checkFiles`, so the sheet could render with no stream table at all.
Detect that shape and refetch once under the request context captured
before the cache read, so the outgoing token and the cache namespace
stay on one profile.
Rework the layout to match: a summary chip row, then flat tonal cards
with a two-column field grid that collapses to one column on narrow
viewports, per-stream cards with flag chips, and a copyable monospace
path row. The card fill is a tonal step off the text colour rather than
the `bg` token, which is one shade from the sheet surface on OLED.
|