Commit Graph
334 Commits
Author SHA1 Message Date
edde746 9db6f54b7a feat(plex): make the covered-source direct play optional
A quality preset the source already fits under direct plays the file
since #2152, but a transcode above the source bitrate is sometimes the
point: a device that cannot decode the source (10-bit H.264, AV1, 4:4:4)
wants the server's highest-quality encode, and the shortcut silently
handed it the very file it cannot play smoothly.

A new Quality switch, "Play Smaller Videos at Original Quality" (on by
default), gates the shortcut in PlexClient._presetNeedsTranscode; off
restores the pre-#2152 behavior where any non-original preset always
transcodes. Plex-only by design: MediaBrowser servers make the
equivalent direct-play-vs-transcode call server-side.

close #2193
2026-08-31 11:32:07 +02:00
edde746 89d54d8f1d feat(watch-together): let the host hand the room to another participant
close #2183

Reusing a room means whoever enters first becomes the host, and moving the
session to someone else's library meant everyone leaving and re-entering in
the right order. The host can now promote any connected guest from the
participant list (Watch Together screen and the in-player session sheet),
with playback, control mode, and reconnect identities carried across.

The relay owns the swap: a new `transferHost` message validates the sender
is the live host and the target a connected modern-protocol guest, then
swaps `HostPeerID` and the reconnect verifiers (each peer keeps its own
token), persists the room, and broadcasts `hostChanged` to every peer.

Clients flip roles in place: the controller swaps its role engine while
keeping the session, message queue, and player attachment. A promoted guest
seeds the coordinator with the room's last known intent (a paused room
stays paused) and the known-peer roster so the fresh epoch re-gates
instead of solo-starting; a demoted host falls back to a reconciler with a
fresh clock sync and asks the new authority for state. Guests re-pin the
host identity, reset their sequence, and re-converge their clocks.

A rejected transfer (`not_host` / `peer_not_found`) surfaces as a toast
instead of tearing the session down. Pre-transfer app builds in the room
ignore `hostChanged` and stop following the room at the next transfer;
targets on an old sync protocol are not offered the action.
2026-08-31 08:55:45 +02:00
edde746 44773fd742 feat(player): remember the swipe brightness level between playbacks
The brightness set with the left-edge swipe was lost the moment playback
ended, so every session started back at the system level. A new
"Remember Brightness Level" toggle under Settings > Playback > Gestures
persists the level a swipe settles on and reapplies it when the next
playback starts (and when the app resumes mid-session). Leaving the
player still restores the pre-playback brightness, so the rest of the
app is unaffected. Default off, preserving current behavior.

close #2178
2026-08-31 06:43:47 +02:00
edde746 7cfb890046 fix(i18n): translate the Live TV guide search and TV options labels
The guide-search sheet and the TV rail's options button fell back to
English in every non-English locale: their six keys existed only as
empty strings in the sibling translation files.

Fill all 126 empty targets across the 21 locales and regenerate the
slang bindings. searchNoResults keeps its ${query} placeholder and each
locale's quote convention, and channelsSection uses the broadcast sense
of "channel" rather than the audio-channel word already used by
fileInfo.channels.
2026-08-30 08:48:32 +02:00
edde746 45e8da1610 feat(music): restore the last music session paused on launch
Quitting the app lost the music session: on the next launch the queue,
current song, and playhead were gone. The session now persists per
profile (new MusicSessions Drift table, schema v22) through throttled
write-through during playback, and the next launch rehydrates it as a
parked-paused queue — no audio core and no source resolution until the
first play, which opens the track at the saved offset. Ending the
session (stop, swipe-dismiss, video claim) clears the snapshot, and a
new "Remember music session" playback setting (default on) gates the
restore.

close #2148
2026-08-29 19:11:58 +02:00
edde746 ffc95d6b2c feat(tv): mark whole seasons watched from the show detail rail
On TV the show detail screen exposed season actions nowhere: marking a
season watched meant toggling every episode one by one, and the action
row's menu only covered the whole show.

Each season row on the TV detail rail now carries a full-size Options
card that opens the same season context menu the mobile season tabs
offer (mark watched/unwatched, download, delete, add to). The card
rests off-screen left of the first episode — the row is a
center-anchored scroll view and the card lives in the negative-offset
region before the anchor — so the resting layout and default focus are
unchanged and normal playback gains no extra steps; pressing LEFT from
the first episode slides it in. The trailing View All / retry / loading
slots become matching full-size cards instead of compact pills.

close #2156
2026-08-29 13:56:24 +02:00
edde746 a3cbf323b3 feat(livetv): search the guide for channels and programs
Live TV had no search: finding a channel or a program meant scrolling
the guide row by row, which is close to unusable on TV with a few
hundred IPTV channels, and the global search tab covers neither
channels nor EPG data.

A search action on the Live TV tab bar (shown on every tab, since the
D-pad route through the chips selects each tab it crosses) opens a
sheet that filters channels by name, call sign, and number, and the
next 24 hours of programs by title and series name, from one
fetchSchedule fan-out per open. Selecting a result jumps the guide to
it: the favorites filter is dropped when it would hide the target row,
the time window is re-anchored when the airing is outside the visible
six hours, and D-pad focus lands on the channel cell or program block.
While the sheet is open, companion-remote search queries land in it
instead of the global Search tab (same save/restore idiom as the video
player overrides). Jumps requested while a guide load is in flight are
stashed and replayed when the load commits.

Based on #1526 by @l3gitpanda, rebased over the guide virtualization
and source-group work, with the schedule fan-out moved onto
forEachLiveTvServer and the jump focus routed through the guide's
focus-snapshot publisher so the landed cell actually renders focused.

close #2168
2026-08-29 01:54:21 +02:00
edde746 463ad6b933 fix(i18n): translate settings and account preferences into all locales
94 strings added since the settings rework were empty placeholders in every
non-English locale, so those screens fell back to blanks. Fill all 1974
missing values across 21 locales, reuse each file's existing terminology,
and translate the few verbatim-English leftovers (es rankPopular, fr tags,
it dynamicRange, pl stop, nb tag). Regenerated Slang output.
2026-08-28 06:51:10 +02:00
edde746 a1d8ab5210 fix(plex): surface instant mix failures instead of silently doing nothing
Tapping Instant Mix on Plex often did nothing: the /playQueues POST used no
endpoint failover, createPlayQueue swallowed every error into null,
fetchInstantMix mapped that to an empty list, playInstantMix returned
silently on empty tracks, and the errors stream's only listener is the
now-playing screen, which is not mounted at tap time (#2141).

The play-queue POST now rides the shared endpoint failover (replaying it is
safe: an orphaned duplicate queue on the server is inert), createPlayQueue
and fetchInstantMix propagate typed errors, playInstantMix reports
started/empty/superseded and throws fetch failures, and the tap site shows
a translated snackbar for a failed or empty mix. A failed collection or
playlist queue launch now also reaches the real failure snackbar instead of
the misleading "no items" path.
2026-08-27 16:59:08 +02:00
edde746 a97be5823f feat(player): add play-next countdown, gesture toggles, and deinterlacing
Users could not adjust the five-second Play Next timer, switch off the
edge-swipe/pinch gestures they trigger by accident, deinterlace DVD-era
content, or keep always-on-top across episodes.

Play Next Countdown (0-30 s) joins Auto-Play & Skip; zero skips the
prompt and starts the next episode immediately, while the transient-
retry prompt keeps its fixed five-second spacing. Brightness swipe,
volume swipe, and pinch-to-zoom each get a switch in a mobile-only
Gestures group; a disabled edge also stops stealing the content-strip
drag. A Deinterlacing toggle (mpv-only — ExoPlayer has no filter chain)
sets mpv's deinterlace=auto at player init. The player's always-on-top
toggle now persists via a pref that is re-applied when the next player
opens, including autoplay episode transitions; the window flag is still
dropped when the player closes.

Also resolves the reorg TODO markers per triage: hold-to-speed lock
(#1046) will be gesture behavior rather than a setting, metadata
language (#1668) and delete re-authentication (#1924) are rejected, and
always-on-top is player-persisted state, not a Window setting (#1386).

close #1827
close #1810
close #2149
close #931
2026-08-27 16:42:38 +02:00
edde746 c9de78697b feat(settings): reorganize settings into general, appearance, and playback homes
Settings were grouped by screen history rather than by what they govern:
the profile-selection prompt, startup section, force-TV mode, and app
language sat under Appearance, Discord Rich Presence and the companion
remote host hid inside Playback > Behavior, and maximum volume lived
under Seek & Timing.

Introduce a General screen (language, startup, desktop window), regroup
Appearance into display/library/home/navigation/Live TV, split the
Playback monolith into engine, video & display, audio, quality,
subtitles, seek, auto-play & skip, and behavior groups, surface
auto-play next episode in settings, rename the Keyboard Shortcuts
section to Controls (now also hosting the companion remote host), move
Discord Rich Presence to Services > Integrations, and move the
performance-overlay switch to Advanced. Pref keys are unchanged, so
stored values, export/import, and reset behavior are unaffected.

TODO markers record the agreed homes for upcoming settings (#1998,
#2064, #1641, #1769, #2149, #1827, #2138, #1810, #1046, #1924, #1413,
#1668, #1386, #1795).
2026-08-27 16:02:10 +02:00
edde746 9ac8002b53 feat(player): apply separate default quality on cellular connections
On mobile data the single default streaming quality either burns
through the user's data plan or permanently caps quality at home.
Playback start now applies a dedicated cellular default when the
device is on a cellular-only connection, read from the app's existing
single connectivity subscription. The new setting defaults to
following the general default, ships in Settings > Playback for
phones/tablets only, and explicit per-play quality picks still win.

'Metered connection' had been spelled out independently in the offline
provider, the download WiFi-only gate and the sync-rule cooldown.
Rather than add a fourth copy, all four now share
ConnectivityLinkType. Network *presence* is deliberately left alone:
its callers disagree on whether an empty snapshot counts as
connected, so folding that in would change behavior.

showSelectionDialog now returns the picked DialogOption instead of its
raw value so the tile's null-valued 'Same as Default Quality' option
stays distinguishable from dismissing the dialog.

close #2147
2026-08-27 14:14:01 +02:00
edde746 94565b4ee0 feat(library): make grid spacing between posters configurable
The library grid packs posters edge to edge. The grid delegate's cross-
and main-axis spacing were hardcoded to zero, so the only visible gap
came from each card's own 3px padding, and Library Density changed
poster size rather than the space between posters. The result reads
denser than Plex, Jellyfin, or any commercial client, both horizontally
between posters and vertically between a poster and its title.

Add a Grid Spacing setting - Tight, Normal, Spacious (0/6/12px) - below
Library Density. Tight is the default and reproduces the current layout
exactly, so no existing grid moves on update.

The gutter lands in MediaGridDelegate.spacingFor(), already the single
funnel for non-full-bleed grid spacing, so every grid surface picks it
up: library browse, collections, playlists, downloads, and the detail
screens. Square music grids keep their 8px floor. The pref is watched
once in MediaCardSliverLayout - the only MediaGridGeometry.resolve()
call site - so grids re-layout live instead of on restart.

Grid cells grow the poster-to-title gap to 2/4/6px as well; the
Expanded poster absorbs the delta, so the cell keeps its aspect ratio.
Fixed-height hub-row cards keep the historical 2px because their text
band cannot absorb more, and hub-row cell packing opts out of the
setting entirely so shelves stay byte-identical across it.

Full-bleed TV grids are untouched - they already carry an 8-18px
scale-derived gutter - and are opt-in behind tvFullCardLayout, so the
default TV library grid follows the setting like every other surface.

Verified on macOS across all three steps: gutters and title gap grow,
grids re-pack live, and the segmented control reflects and writes the
value.

close #1597
close #2083
2026-08-27 09:21:48 +02:00
edde746 8f893e7a67 fix(player): demux Android direct play with media3 again
The in-app FFmpeg container demuxer classified every DTS variant as
plain DTS (profile-blind MIME mapping at the demux boundary), so
DTS-HD MA lost its lossless identity downstream, and it ignored
container display-aspect-ratio overrides. media3's extractors get both
right.

Delete the FFmpeg demuxer (JNI, extractor, and its setting); ExoPlayer
direct play demuxes with media3's DefaultExtractorsFactory again, and
mpv - the Android default - reads container display dimensions itself.

close #2124
close #2115
2026-08-26 18:59:28 +02:00
edde746 a1c98b9d27 feat(player): render Android mpv video on the MediaCodec plane
Android mpv drew every frame through vo=gpu: an ImageReader/GLES copy
pinned to 8-bit RGB0 with a 100 ms timed wait per frame, which
truncated 10-bit and HDR output, raced acquireLatestImage, and
performed badly on low-end TVs.

Video now scans out on a SurfaceFlinger video plane through the fork's
vo=mediacodec: decoder buffers are queued to the surface at their
target PTS (av_mediacodec_release_buffer_at_time), so 10-bit and HDR
dataspaces reach the display untouched and frame pacing no longer
depends on GL vsync. Subtitles and OSD render on a sibling transparent
surface presented for the same PTS, frame-locked by construction.

The plane takes decoder buffers only, so software-decoded video stays
on a GL vo, with a chain-failure watchdog re-initializing the output
when the plane refuses a stream mid-session. The GL fallback is vo=gpu
- gpu-next breaks the Tegra GLES linker (#2010) - and GpuVoPolicy
selects gpu-next only when Dolby Vision RPU reshaping needs
libplacebo. AV1 film grain is applied in the decoder
(vd-lavc-film-grain=cpu) because the GL raster grain fallback is not
available on this path.

Pins libmpv-android v1.1.3, which carries the fork patches this path
rides on: the vo itself, BT2020-PQ EGL window surfaces, GLES direct
rendering treated as slow (H.264 Hi10P software decode on Tegra went
from 0.3x realtime with wrong colors to 1.0x with correct 10-bit
output), and multichannel IEC61937 for ao_audiotrack.

Verified on Shield Pro (Tegra/GLES), Pixel 7 (Mali), Box R 4K Plus
(Amlogic armv7), and Galaxy Z Fold3 (Adreno): 520 play/teardown cycles
and 3 hours of continuous 4K HDR playback; HDR engagement confirmed
via SurfaceFlinger dataspace BT2020_ITU_PQ.
2026-08-26 18:59:19 +02:00
edde746andGitHub ae555ed5d7 feat(settings): account preferences, stored on the server instead of the device (#2112)
Preferences that belong to the media-server account had nowhere to live in Plezy. Jellyfin keeps a user's audio/subtitle language, subtitle mode and several library options in `UserConfiguration`; plex.tv keeps the same language choices plus watched indicators and review visibility on the account. Plezy read four of those fields for auto-track selection and could never show or change any of them.

Adds an Account preferences section under Connections. One account edits in place; several show a picker first, scoped to the active profile's own connections so a managed Plex Home user never edits the owner's record. Every row is gated on what the backend can actually store.

It also moves "rewatching in Next Up" onto the account. There is no `UserConfiguration` field for it, but the per-user `DisplayPreferences` store is keyed `(userId, displayPreferencesId, client)` with no device component, so the switch goes there and follows the account.

`AccountRef` keys by account, not `clientScopeId`: MediaBrowser is `{machineId}/{userId}`, Plex is (account, Home user). Writes are patch-shaped because both backends replace whole objects — Jellyfin's `POST /Users/{id}/Configuration` and its `DisplayPreferences` row both reset omitted fields, so each write re-reads, merges only the patched keys, and posts back. Plex takes its changes as query parameters with an empty body, and its `experience` blob and the PMS `/accounts/1` mirror are deliberately untouched.

`AccountPreferencesController` owns a single repository above the profile session, so changing a language in settings reaches the next playback without a restart. Emby is gated out of rewatching through `MediaBrowserDialect.supportsNextUpRewatching`.

close #1910
2026-08-25 08:42:48 +02:00
edde746 779c532fc7 chore(i18n): regenerate german strings after the premiereDate update
de.i18n.json changed in #2106 without a codegen run, so scripts/codegen.sh
--check failed on strings_de.g.dart. No other locale is affected.
2026-08-25 03:11:11 +02:00
edde746 595cd9fe24 feat(seerr): sign in with quick connect and accept schemeless urls
Linking a Seerr instance had two rough edges, both on the Jellyfin path.
The URL step prepended https:// to anything without a scheme, so a
plain-HTTP instance on the LAN — 192.168.1.5:5055, or a bare host on
Seerr's default port — failed with "could not reach" unless the user knew
to type the scheme. And the only way to sign in with a Jellyfin account
was typing a username and password, which on a TV remote is misery.

Schemeless input now expands into candidates that are probed together,
but TLS wins by construction: a plaintext success is held while any https
candidate is still in flight and is accepted only once they have all
failed. The sign-in that follows posts a password to whichever URL wins
here, so a slow-but-working https endpoint has to beat a fast plaintext
one, and the wait is already bounded by the probe timeout. When nothing
answers, the failure that reached a server outranks a transport error, so
"this instance has not completed first-run setup" is not masked by "could
not reach https://..." from a candidate the user never typed.

Quick Connect goes through Seerr's own proxy routes (3.4+): initiate,
then check polled at Seerr's own 2s cadence through the shared
pollWithBackoff — a 404 mid-poll means the secret is gone and is terminal
— then authenticate, which mints the session cookie through the existing
sign-in path. The secret rides in a query string, so it is registered for
log redaction the way the Jellyfin flow registers its own. An instance
that predates the routes 404s the initiate and is told apart from a
generic rejection.

SeerrAuthMethod.quickConnect deliberately stores no secret: silent
re-auth is impossible by construction, so an expired cookie lands in the
existing "no stored credentials" arm, unlinks the session, and the
connect flow asks for a fresh code. The affordance is limited to the
Jellyfin credential form, since Seerr rejects Quick Connect for Emby, and
is not auto-started on TV the way the MediaBrowser form is:
/settings/public exposes no "Quick Connect enabled" flag to gate that on.

The waiting panel and its attempt/cancel bookkeeping moved out of
AddJellyfinScreen into QuickConnectCodePanel and QuickConnectFlowMixin so
both screens share one implementation, and the post-frame focus helper
both forms use moved to AsyncFormStateMixin.
2026-08-25 03:07:14 +02:00
BockiandGitHub bd2d19a1c1 Update de.i18n.json (#2106) 2026-08-25 01:21:35 +02:00
edde746 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.
2026-08-24 05:23:05 +02:00
edde746 6da1f83891 feat(player): switch the display to the video's native resolution on Android TV
Sub-4K content on a 4K TV was always upscaled by the playback device, so the TV's own (usually better) upscaler never received the native signal and could not apply resolution-specific processing.

Adds a Match Content Resolution setting on Android TV: playback picks the smallest display mode that still contains the video — never downscaling it — and rate-matches within that resolution when frame rate matching is also enabled. A resolution-only switch keeps the refresh rate as close to the current one as possible, and the settle watchdog now verifies the requested mode id so such switches still trigger the Android MPV decoder refresh. Transcodes get their target from decoded dimensions on the post-first-frame path. Mode selection is extracted into a pure, JVM-tested DisplayModeSelector.

close #2073
2026-08-24 02:10:35 +02:00
edde746 a11da3fec8 feat(livetv): record live TV on Jellyfin and Emby via the timer APIs
Jellyfin and Emby users had no way to record live TV from Plezy at all: no
record button in the EPG guide's program menu, and nothing in the recordings
UI, because every recording surface was gated behind a Plex-only DVR adapter.

The recording surfaces are now backed by a MediaBrowser DVR adapter that
synthesizes the neutral (Plex-shaped) payload models from the timer APIs:

- Template comes from `GET /LiveTv/Timers/Defaults?programId=`, offering a
  "Record Episode" entry and, for series airings, "Record Series". The
  defaults DTO travels JSON-encoded in the template's opaque `parameters`
  and setting ids are DTO field names, so the existing template-driven
  record-options sheet renders and round-trips them unchanged.
- Create POSTs the mutated defaults whole to `/LiveTv/Timers` or
  `/LiveTv/SeriesTimers`; a duplicate one-off create answers 400, which the
  adapter rethrows as `RecordingConflictException` so the sheet can show
  "Already scheduled" without a backend check.
- Scheduled recordings read `/LiveTv/Timers` minus `Cancelled`/`Completed`
  tombstones, rules read `/LiveTv/SeriesTimers` with their child timers
  nested. Rule keys carry a `timer:`/`series:` prefix so cancel, edit and
  delete dispatch to the right timer space inside the adapter.

Guide programs now carry their recording state: the program id doubles as the
record seed, and `TimerId`/`SeriesTimerId` become the rule keys that drive the
guide's scheduled dot and the Manage action. The series key is stamped only
when an airing actually records, so an episode a series rule skips does not
show a false indicator.

Three neutrality fixes on the shared UI: the record-options sheet only demands
a target library when the template declares one (MediaBrowser records into its
own configured folder), and "Re-evaluate rules" is hidden unless a connected
DVR supports it, since only Plex has that endpoint.

`fetchDvrs` deliberately stays empty so the synthesized per-server Live TV
identity that channel fetches, favorites and playback key off is preserved.

Verified end-to-end against a disposable jellyfin/jellyfin:10.11.11 container
with an M3U tuner and XMLTV guide, driven through the real client: template,
create, duplicate conflict, cancel, series create with child grabs, edit
round-trip and delete.

close #1645
2026-08-24 00:08:11 +02:00
edde746 99c21ce6ae fix(remote): stop companion pairing hanging when a host address is unreachable
Tapping a discovered desktop in the phone's companion remote did nothing:
the device list emptied to "No devices found on your network" and no
connection was ever made. Manual IP:port entry silently did nothing too.

A host advertises every local IPv4 address, so a PC with virtual adapters
(WSL, Hyper-V) broadcasts addresses the phone cannot route to. The client
races them all, and cleanup of the losing candidates awaited
`sink.close()` on channels that never connected. That future never
completes in web_socket_channel: the close future waits on the local
stream's listener, which is only attached on the connect-success path. So
the race never finished, the winner was never joined, and the flow just
stopped with no error.

Cleanup now tracks whether a candidate ever connected: unconnected probes
get a deferred close armed on `ready` settling instead of a blocking
await, and they skip the terminal drain since they hold no host admission
slot. The managed join gets a real connect timeout that surfaces as a
typed timeout error, and disconnect during a pending connect closes the
socket once the connect settles rather than blocking on it.

Two paper cuts around the same flow: a failed attempt now restarts
discovery instead of stranding the cleared list on "No devices found",
and the desktop's server card shows its listen addresses so manual entry
is not guesswork.

close #2077
2026-08-24 00:08:10 +02:00
edde746 c201bc2f03 feat(player): default every container to the ffmpeg demuxer
The Auto demuxer mode routed only media3's weak container families
(AVI, ASF/WMV, MPEG-PS, Matroska/WebM) through FFmpeg and kept MP4/TS
on media3's extractors, with a second any-container FFmpeg instance
behind media3's list. With the goal of fully switching to the ffmpeg
demuxer, the split served no purpose and the catch-all could never add
coverage once the primary accepts everything.

Collapse the preference to two modes: FFmpeg (default), which demuxes
every progressive container ahead of media3's list, and media3 only as
the user-facing escape hatch in case a file misbehaves. media3's
extractors stay behind FFmpeg, so anything FFmpeg cannot sniff still
reaches them. The catch-all role and the Auto container list are
removed; unknown persisted wire values (including the retired "auto")
resolve to FFmpeg.

Verified on a Pixel 7: MP4, TS, and MKV all demux through FFmpeg under
the default ("sniff accepted mov,mp4 / mpegts / matroska,webm") with
forward and backward seeks landing; JVM suites and analyzer pass.
2026-08-23 01:42:36 +02:00
edde746 75c095a5b2 feat(player): demux direct-played containers with ffmpeg on Android
Direct play on Android kept accumulating container patches: AVI with
XviD packed-bitstream timestamps and missing VOL csd played broken,
ASF/WMV and MPEG-PS/VOB had no media3 extractor at all, and MKV needed
a custom extractor stack for zlib-compressed subtitles, LOAS/LATM
audio, cueless seeking, and font attachments.

Demux progressive containers with libavformat behind media3's
extractor API. An AVIO bridge serves libavformat from the
ExtractorInput: every position divergence defers into a RESULT_SEEK
round trip, header reads replay from a block cache while
avformat_open_input restarts, and avformat_seek_file executes seeks
with bounded loader round trips. Packets feed media3's TrackOutputs,
so decoders, passthrough carriers, Dolby Vision RPU/EL conversion
(dvh1 codecs string surfaced from DOVI side data), and the subtitle
pipeline are untouched: embedded fonts feed AssHandler over JNI, ASS
reaches libass as per-sample dialogue, SRT/VTT render as cues, VobSub
maps through media3's VobsubParser, and LOAS/LATM AAC unwraps via
LatmTrackOutput.

Under the default Auto preference FFmpeg demuxes AVI, ASF/WMV,
MPEG-PS, and Matroska/WebM and sits behind media3's list as an
any-container fallback; MP4/TS keep media3's extractors. A playback
setting exposes auto / FFmpeg first / media3 only, and the JNI
load-failure path falls back to stock media3. The custom Matroska
extractor stack and its reflection keeps are retired.

Verified on Pixel 7 (API 36), Box R 4K Plus TV (API 34), and SHIELD
TV (API 30): instrumentation playback suites, the minified R8
reachability gate, and an on-device container matrix with forward and
backward seek landings plus visual subtitle checks.

close #2052
2026-08-23 01:22:24 +02:00
edde746 a029b1a7c8 fix(seerr): decode media status per product and model failed/completed request states
Jellyseerr shifts MediaStatus codes 6/7 relative to Overseerr (6=blocklisted, 7=deleted vs 6=deleted), and the shared unconditional mapping misread both. The product is now detected from the presence of mediaServerType in /settings/public, persisted on the session, and used to resolve raw wire codes; blocklisted titles are shown as non-requestable.

Request statuses failed(4) and completed(5) previously decoded as pending, blocking re-requests and mislabeling failures; both are now modeled and active requests are defined positively as pending or approved. Seerr also marks a request Failed on arr-push failure while leaving the media status Processing, and precedence checked processing first, so failed titles rendered as Processing and the request sheet kept blocking re-requests; a failed request with no live pending/approved request now wins over stale pipeline status in both the catalog state and the sheet's blocked labels.

Reauthentication completing from a stale session snapshot wholesale-replaced the session and could downgrade a concurrently detected product discriminator back to unknown permanently (the settings cache never reapplies it); adoption now merges the known product.
2026-08-21 19:23:42 +02:00
edde746 810ef002ad fix(player): show source track audio in stats during passthrough
When mpv bitstreams (audio-spdif), audio-params describe the IEC 61937
carrier - 192 kHz "stereo" for E-AC-3 - so the performance overlay told
users their 5.1 track was playing as 2ch stereo. Every recent "EAC3
downgraded to PCM" report in #1300 reads back this string while the
receiver route stays healthy.

Show the source track's channel layout and sample rate instead, plus an
explicit Passthrough row naming the bitstreamed codec, on both the
mpv-channel and Android mpv-fallback stats paths.
2026-08-19 20:14:48 +02:00
edde746 986237491f fix(i18n): fill missing translations and remove unused keys 2026-08-18 08:41:13 +02:00
edde746 35dce90ea9 feat(player): persist player-sheet changes at a configurable scope
Changing playback speed, shader preset, aspect ratio, or a sync offset in
the player always rewrote the global default, so a show watched at 1.5x
forced 1.5x onto movies, an Anime4K pick followed the user into
live-action libraries, and a file-specific subtitle offset leaked onto the
next title.

Each of those settings now has a scope under Settings > Video Playback >
Remember Player Changes: don't save, everywhere (default, pre-existing
behavior), per library, or per show/movie. Player writes route through the
configured scope; playback start and in-place item changes resolve the
value for the current item, falling back to the global default. Items
without the needed identity (live TV placeholders, Jellyfin/Emby downloads
without a stamped library) keep the global behavior. Resolution consults
only the configured scope, so entries saved under a previous scope become
inert instead of shadowing the new one.

close #1984
close #1322
close #1616
2026-08-18 06:09:11 +02:00
edde746 cff6666219 feat(music): group artist discography into albums, singles & EPs, live, and compilations
Artist pages showed one flat album grid, unlike Plex's own clients.
Plex listing rows never carry Format/Subformat tags (even with
resolveTags=1), so fetchArtistDiscography follows the unchanged album
listing with one batched /library/metadata/{ids} request - whose rows
do include the tags - and classifies each album individually
(EP/Single, then live, then compilation, case-insensitively). A failed
tag fetch degrades to the flat list, and single-album artists skip the
lookup entirely. Jellyfin/Emby carry no album taxonomy on the wire and
return a single albums group.

The artist screen renders one titled section per non-empty group
(single-group artists keep the flat grid), with the grid focus index
space offset per section so D-pad traversal crosses section
boundaries.
2026-08-17 22:44:12 +02:00
edde746 25b2939f0a fix(player): stop same-day Specials from hijacking up next
close #1952

Shows whose Season 0 holds aftershow featurettes (e.g. House of the
Dragon "Inside the Episode") share air dates with the episodes they
accompany, so the air-date interleave from #1416 queued them between
regular episodes: playing S03E04 offered S00E76/S00E84 as up next.

Air date alone cannot separate canon Specials from featurettes, so
Specials placement is now a three-way preference, defaulting to
"follow server order":

- respectServer: Plex keeps its server-built /allLeaves queue (aired
  order, Specials interleaved, as before); the Jellyfin queue now
  preserves the /Shows/{id}/Episodes response order, which is
  Jellyfin's native watch order — Specials placed only via explicit
  AirsBefore* metadata per the server-wide DisplaySpecialsWithinSeasons
  setting (the endpoint ignores SortBy except Random). Client-side
  selections with no server order to respect (offline next/prev,
  download "next N", offline OnDeck) fall back to Specials-last.
- airDate: the #1416 aired interleave on every surface.
- specialsLast: Specials strictly after the regular seasons (#1414);
  Plex builds its show queue from /children.
2026-08-16 15:31:47 +02:00
edde746 be863a0c1c fix(linux): remove the Flutter-texture fallback permanently
The display-agnostic texture renderer restored by 9cdfe759 is deleted
again, this time for good: it is a second, SDR-only rendering stack
(isolated EGL context on Flutter's display plus EGL-image handoff) kept
alive solely to host sessions that cannot bring up the Wayland plane -
X11/XWayland or a failed plane bootstrap - and it was the source of the
native lifecycle and EGL state-churn fixes of the 9f2e0507 era. Linux
video now requires a Wayland compositor; a session that cannot host the
plane fails initialization with VIDEO_PLANE_UNSUPPORTED instead of
silently rendering through the second stack.

Reverts the restore commit's machinery: mpv_texture.cc/.h and
mpv_gpu_bootstrap.cc/.h deleted, the plugin's texture-registrar,
bootstrap, and waitForVideoReady paths removed, MpvPlayer's texture-mode
render-context API dropped, and the Dart-side renderMode setting, its
settings tile, translation keys, and the Player textureId member
withdrawn. The #1874 HDR diagnostic work is unaffected.
2026-08-15 01:20:44 +02:00
edde746 9cdfe7591e feat(linux): restore the Flutter-texture path as the SDR fallback
2.13.0 deleted the display-agnostic EGL/Flutter-texture renderer and made
the native Wayland plane the only path, hard-rejecting every session that
cannot host one - X11, XWayland (SteamOS Gaming Mode runs native apps
through Gamescope's XWayland), or a plane whose EGL bootstrap failed. This
restores the 2.11.0 texture path from git history as the fallback:

- Re-add mpv_texture.cc/.h and mpv_gpu_bootstrap.cc/.h (2.11.0 verbatim):
  an FlTextureGL whose populate renders mpv into an offscreen FBO sampled
  by Flutter via an EGL image.
- MpvPlayer gains the texture-mode render-context API (InitRenderContext,
  HasRenderContext, GetEglDisplay/Context, Render(w,h,fbo)) beside the
  plane's InitRenderContextForSurface/RenderToSurface. The isolated ES 2.0
  context on Flutter's display and the X11 display param are exactly the
  2.11.0 configuration hardware decode demonstrably worked in.
- initialize now tries the plane first and falls back to the texture path
  when it cannot be brought up, returning the texture id (Dart's 2.11.0
  'result is int' contract) with waitForVideoReady gating playback until
  the GPU bootstrap settles. Both paths share one mpv core, so the
  plane/texture decision precedes render-context creation.
- hdr-enabled/hdr-tone-mapping are intercepted in texture mode (no plane,
  no HDR); the HDR toggle hides itself via isHDRSupported.
- New Linux setting 'Video rendering mode' (Automatic / Texture) forces
  the fallback - the user-visible workaround for plane-only trouble and
  for the hwdec interop regression, plus translations in all locales.

SDR only on the fallback, matching 2.11.0; the plane path is unchanged.
2026-08-14 18:16:32 +02:00
edde746 14cae85931 fix(player): never fail playback on preference writes; keep vo authoritative
mpv 0.40's OPT_COLOR parser rejects anything but #RRGGBB/#AARRGGBB, so a
stored subtitle colour that does not parse made mpv refuse the write with
MPV_ERROR_PROPERTY_FORMAT — and the bare await in _runPlayerInitializationAttempt
turned that into the initialization error screen on every open. Subtitle
styling, volume-max, and the pre-open defaults (start/pause/sid) are now
sanitized (colours canonicalized to hex with fallback to the default) and
non-fatal, matching the existing hdr-enabled tolerance policy: a refused
preference write must never become "this session cannot play video".

The user mpv.conf editor is applied as runtime mpv_set_property writes, and
vo/gpu-context/gpu-api were not withheld, so a vo=gpu-next line re-created
mpv's output as a separate uncontrollable window and orphaned the embedded
render context. Add the VO family to the Linux-owned property set with a
key-aware skip log, a native reject for vo != libmpv on the video core (the
render API is OpenGL-only; gpu-next is windowed by construction), and a hint
in the mpv.conf editor explaining why, with translations across all locales.
2026-08-14 18:07:16 +02:00
edde746 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
2026-08-10 15:32:43 +02:00
edde746 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.
2026-08-10 08:48:13 +02:00
edde746 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.
2026-08-10 08:33:11 +02:00
edde746 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
2026-08-09 17:14:48 +02:00
edde746 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
2026-08-09 17:14:47 +02:00
edde746 8e5279a487 feat(subtitles): optionally anchor text subtitles to the screen bottom
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android +
ExoPlayer only, default off). When enabled, the text SubtitleView is
sized to the full container instead of the letterboxed video rect, so
SRT/VTT/mov_text cues render in the black bars below widescreen video
and font size and the position setting become relative to the physical
screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged;
mpv already places plaintext subtitles in the margins by default.

close #1730
2026-08-09 17:14:34 +02:00
edde746 e0a364e26a chore(tvos): remove the Atmos output diagnostics 2026-08-09 11:52:48 +02:00
edde746 ff461d9f71 fix(livetv): explain a guide emptied by the favorites filter
With "Default to Favorite Channels" enabled and no favorites stored —
or only favorites left over from a since-rebuilt lineup — the favorites
filter reduced the guide to zero channels and GuideTab rendered just
the timeline bar: no rows, no message, no sign a filter was active.
Users read it as Live TV being broken; the Aug 8 report in #887 shows
44 channels and 447 grid programs loading in the log while the
screenshot shows a blank guide and 0 favorite channels.

When the filter removes every loaded channel, the guide tab now shows
an empty state naming the cause with a "Show All Channels" action that
clears the filter. The action stays D-pad reachable: the tab-bar focus
handoff falls through to the action's focus node while the empty state
replaces GuideTab, and activating it hands focus back to the restored
guide content. Favorites that match no loaded channel get the same
treatment as an empty favorites list.

Verified: flutter test test/screens/livetv/, analyzer parity,
clean_translations --check --strict, and slang codegen freshness.

Refs #887.
2026-08-09 11:12:08 +02:00
edde746 f4ce60611b fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.

Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:

Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.

Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.

The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.

Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00
edde746 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
2026-08-08 12:06:32 +02:00
edde746 24a041977b fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.

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

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

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

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

Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
2026-08-08 00:37:17 +02:00
edde746 feb34caeb7 fix(i18n): shorten nav labels and complete translations in all locales
Nav bar labels that overflow their tab slot on phones are shortened to
idiomatic short forms: fr (Bibliothèque, Téléchargement, Recherche),
ru/bg/it (Live TV), pl (Home).

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

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

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

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

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

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

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

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

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

close #1816
2026-08-07 08:43:47 +02:00
edde746 961e9c0326 feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
2026-08-06 03:45:09 +02:00
edde746 f93952ba6f fix(android): stop tunneling 24p video on the Fire TV Stick 4K
Tunneled playback on an AFTMM judders continuously through 23.976p direct play.
The #1802 reporter isolated it: turning off Tunneled Playback with every other
setting unchanged makes it smooth, and their log shows tunneling active for the
whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing.

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

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

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

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

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

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

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

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

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

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

close #1781
2026-08-06 03:45:08 +02:00