Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 98bd085bff build(deps): bump actions/setup-java from 5.6.0 to 6.0.0
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.6.0 to 6.0.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/03ad4de0992f5dab5e18fcb136590ce7c4a0ac95...dd06d9cba3e5552c54d9f8ea23572deb30010f7c)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 12:23:37 +00:00
edde746 23f9afa5b1 fix(windows): strip HDR10+ metadata from the Adreno tone-map path
Disabling peak detection (efb0e885) did not stop the per-frame shader LUT regeneration on Snapdragon devices: libplacebo keys the tone-map LUT on the frame's raw HDR metadata, and mpv forwards HDR10+ per-scene values (scene_max, scene_avg, ootf) on every decoded frame, so the LUT - at tens of milliseconds per rebuild on Qualcomm's D3D11 driver - still regenerated continuously and playback kept stuttering and flickering (#2191).

Zero the dynamic metadata before the renderer with vf=format:hdr10plus=no on Qualcomm GPUs; tone mapping uses the static HDR10 mastering metadata and the LUT is generated once. Dolby Vision L1 metadata is deliberately not stripped: dovi=no would break profile-5 rendering. The applied HDR pipeline options are now reported into the app log on first file load so uploaded logs are self-diagnosing.
2026-09-01 09:02:59 +02:00
edde746 efb0e885ae fix(windows): disable dynamic HDR peak detection on Qualcomm GPUs
Playing HDR content tone-mapped to an SDR display on Windows-on-ARM stutters heavily and flickers in brightness: dynamic peak detection moves the tone-mapping parameters every frame, libplacebo regenerates its tone-map and gamut-map shader LUTs each time, and Qualcomm's Adreno D3D11 driver takes tens of milliseconds per regeneration - roughly 100 ms of work against a 42 ms frame budget at 24 fps.

Scan the DXGI adapter list for a Qualcomm vendor id and set hdr-compute-peak=no when one is present, keeping auto elsewhere; static metadata-driven tone mapping generates its LUTs once.

close #2191
2026-09-01 07:37:10 +02:00
edde746 5fb4c78385 fix(jellyfin): restore Live TV on ts-only tuners and honor the quality preset
Since 2.18.0 no Jellyfin Live TV channel plays: opening one fails with
"Could not start the live channel", and switching channel inside the player
does nothing at all. Negotiating video transcodes as fMP4 (#2131) left the
device profile with no MPEG-TS entry, and Jellyfin discards every non-ts
transcoding profile for a live source whose tuner sets
UseMostCompatibleTranscodingProfile - hardcoded for every HDHomeRun host, the
default for M3U ones - so the negotiation came back with no HLS URL at all.

The device profile now sends a ts profile alongside the fMP4 one, listed
second, the way jellyfin-web does. Both of its codec lists are strict subsets
of the fMP4 entry's and the server ranks profiles with a stable sort, so ts
can only win where fMP4 has been filtered out; VOD keeps negotiating fMP4.
flac and truehd are absent because TS cannot carry them, av1 because that is
the gap fMP4 exists to fill.

Live TV also stops being unconditionally transcode-only. It now reads the same
saved quality preset the library path does: Original asks for direct play and
sends no bitrate ceiling, so a channel the server can hand over untouched no
longer costs a GPU encode session per viewer, and a capped preset transcodes at
that ceiling. A direct-play session whose stream dies re-negotiates a forced
transcode instead of re-opening a URL the server will not serve.

Two smaller holes on the same path: a negotiation that bailed out left the live
stream AutoOpenLiveStream had already opened running, because no playback
session existed to ever stop-report it - a few failed tunes could saturate a
provider's connection budget - and a channel switch that failed to start showed
no message at all.

close #2198
2026-08-31 19:23:22 +02:00
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00
edde746 56312a9eb2 fix(ui): keep clear logos with significant color untouched on heroes
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.

The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
2026-08-31 18:51:46 +02:00
edde746 976bdbd021 fix(ui): keep hero titles and clear logos legible in light theme
On the light theme, the detail hero's fallback title was hard-coded
white over a scrim that washes artwork toward the near-white background,
and white-on-transparent clear logos vanished into the same wash on the
detail, home, and TV spotlight heroes.

The title fallback now defaults to the theme foreground with a
background-side halo, matching what the TV hero already did. Clear logos
run through the channel-logo tone remap: light-toned marks recolor
toward the theme foreground on light backdrops (network and downloaded
artwork alike), colored marks keep their pixels, and dark themes render
the original artwork. The backdrop-luminance gate is shared with the
Live TV guide as logoToneTargetFor.
2026-08-31 18:51:46 +02:00
edde746 274b31ebac fix(livetv): recolor light channel logos for legibility in light theme
White-on-transparent broadcast logos (CBS, FOX, the NBC wordmark) vanish
on the light theme's white guide cards, making channels hard to identify.

Channel logo decodes now classify the frame's tone once per image-cache
entry and bake light-neutral pixels toward the theme foreground:
monochrome white marks recolor fully, mixed marks (colored peacock plus
white wordmark) keep their colored pixels, and self-backed or dark
artwork decodes unchanged. Applied in the guide channel column, guide
search, and favorite reordering whenever the backdrop is light,
including the dark theme's inverted focus card; dark backdrops keep the
original artwork.

close #2197
2026-08-31 18:51:46 +02:00
edde746 e1ef52aba1 fix(android): fall back to software decoding for non-Main-profile AV1
4:4:4 (High profile) AV1 played as green or black video on Android TV
devices with AV1 hardware decoders: MediaCodec defines no profile
constants beyond Main, but decoders like the Fire Stick 4K Max (MTK)
and Amlogic C2 accept the stream at configure time and emit garbage or
nothing, so no error ever reached the player and no fallback ran.

libmpv v1.2.2 carries an FFmpeg patch that reads seq_profile from the
bitstream at av1_mediacodec init and refuses High/Professional streams,
letting mpv's hwdec probing fall through to dav1d software decoding.
Main-profile AV1 keeps hardware decoding.

close #2194
2026-08-31 13:29:13 +02:00
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 39ee089212 fix(audio): reload the mpv audio output when the HDMI route changes
TrueHD/Atmos passthrough with Frame Rate Matching enabled never engaged
the receiver on the mpv backend (Ugoos SK1 -> Denon AVR): the display
mode switch settles 2 s before the audio output opens, but some HDMI
chains are still renegotiating audio then, so the IEC 61937 track binds
a half-negotiated route and the sink never locks onto the MAT
bitstream - for the whole session. The same file with Frame Rate
Matching off, or on ExoPlayer, engages Atmos immediately: media3
rebuilds its sink when the audio device set changes, while mpv's
ao_audiotrack never revisited a track once created.

libmpv v1.2.1 polls AudioTrack.getRoutedDevice from the AO thread and
reloads the AO when the route changes - or vanishes and rebinds - under
a live IEC or raw passthrough track, reopening it against the settled
route. PCM tracks are exempt, and audiotrack-route-reload=no is the
escape hatch.

close #2190
2026-08-31 10:05:08 +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 4f4a7338e0 fix(search): tighten spacing around the kind filter chip strip
The chip strip sat 24px below the search field and 24px above the first
result. Drop the strip's own top padding (the field's bottom padding
already provides 16px) and halve the results sliver's top padding when
the strip is shown, so both gaps read as an even 16px.
2026-08-31 06:01:48 +02:00
edde746 10f9139ac6 fix(audio): bitstream Dolby/DTS as raw tracks on the mpv path
Audio Passthrough on 2.18.0 played EAC3 silently on a Shield in front of
a Dolby-Digital-only receiver: mpv's IEC 61937 AudioTrack bypasses the
platform's Dolby transcoder, so the pre-packed DD+ bitstream reached a
sink that cannot decode it, drained at full rate, and no failure was
observable app-side. Disabling passthrough lost surround instead, because
multichannel PCM collapses to stereo in the platform mixer before the
HDMI re-encode.

libmpv v1.2.0 unwraps the IEC bursts and feeds AC3, E-AC3 and the DTS
core to a raw ENCODING_AC3/E_AC3/DTS track - the transport ExoPlayer and
Kodi use - keeping the platform decoder/transcoder in the path, with the
IEC track as fallback and audiotrack-raw-passthrough=no as an escape
hatch. TrueHD and DTS-HD MA stay on the 8-channel IEC carrier.

The audio-spdif route probe now accepts a codec when the route takes its
raw track (the same direct-playback tiering media3 uses; encoding-only
below API 29) or one of the IEC shapes, matching the AO's transport
ladder. A dev-only PLEZY_LOCAL_MPV_AAR override allows testing locally
built fork AARs before a release is pinned.

close #2177
close #2179
2026-08-30 19:22:14 +02:00
edde746 3c1ab3cc6c fix(windows): bundle the Vulkan loader so libmpv's vulkan-1.dll import resolves
Since the libmpv bump to 20260809 (2.15.0), libmpv-2.dll dynamically links
the Khronos Vulkan loader instead of statically linking it. plezy.exe links
libmpv at load time, so on machines whose GPU driver does not install
vulkan-1.dll - Windows-on-ARM Adreno drivers, driverless x64 VMs - the
process failed before startup with "vulkan-1.dll was not found".

Download the LunarG-built loader (pinned, hash-verified) at configure time
and install it next to the executable with its license. Without a Vulkan
ICD the gpu-api=auto probe fails cleanly and falls back to D3D11, matching
the old static-loader behavior.

close #2110
2026-08-30 12:16:26 +02:00
github-actions[bot] c597390e1c chore: update cask to 2.18.0 2026-08-30 08:39:52 +00:00
edde746 2806115015 chore: bump version to 2.18.0 2026-08-30 09:28:50 +02:00
edde746 d9493c6157 feat(player): move every Android install to the mpv backend
Making mpv the Android default only moved installs that had never opened
the Player Backend setting. Anyone who had ever picked ExoPlayer kept it,
including the low-end TVs and Hi10P devices the new backend was built for.

The backend choice now lives under a new `android_use_exoplayer` key and
the old `use_exoplayer` one is dropped at startup, so every install starts
on mpv. ExoPlayer stays selectable in Settings > Playback > Player Backend,
and a pick made there writes the new key and sticks.
2026-08-30 09:22:31 +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 c6458295a1 fix(player): let the HDR exit settle before restoring the display mode
Stopping HDR playback with frame rate matching enabled left some TVs
black for up to 30 seconds. The teardown reset preferredDisplayModeId
while the display was still signaling HDR, folding the HDR exit and the
refresh-rate restore into one HDMI renegotiation that slow sink chains
take many seconds to complete (8.5 s on a Shield Pro with an HDR10
display; ~30 s on the reporter's DV AVR+TV chain). Sequenced, the HDR
infoframe clear is free and the SDR mode switch takes about a second.

FrameRateManager.clearVideoFrameRate now takes an hdrActive hint from
the core (mpv: the content-color-transfer decision; ExoPlayer: the
selected track's colorInfo transfer) and defers the restore by 400 ms so
the surface teardown commits the HDR exit first. The deferred restore
lives on its own main-looper handler because core dispose clears the
shared player handler wholesale, and a new setVideoFrameRate cancels it
so a fresh session's switch is never clobbered. SDR teardown behavior is
unchanged.

Verified on a Shield Pro (mpv backend, 1917 DV P8/TrueHD, Jellyfin):
link recovery after stop went from ~8.5 s to ~0.8 s, with the SMPTE 2086
clear now committing before the mode set.

close #2172
2026-08-30 08:25:18 +02:00
edde746 6e2dda092d feat(music): make the background fill the mini player's only progress display
The mini player drew a thin scrubbable progress bar along its top edge on
top of the ambient background fill. Drop the scrub lane: the fill is the
card's only progress display, and precise seeking stays on the
now-playing screen's seek bar. Horizontal drags across the card's top
edge swipe-dismiss the card again.
2026-08-29 19:12:03 +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 ad026cecc1 fix(player): keep hardware decoding after a transient VideoToolbox failure
Some HEVC files carry a frame Apple's hardware decoder refuses to decode
(kVTVideoDecoderBadDataErr on an otherwise conformant P-frame). mpv fell
back to software decoding for the rest of the file after the first error,
and 4K HEVC software decode drops frames every few minutes on Apple TV.

MPVKit b8b922ec carries an mpv vd_lavc patch that keeps the configured
fast fallback while probing but lets a hardware decoder that has already
delivered frames ride out a mid-stream error burst: the burst one
undecodable frame causes ends at the next keyframe by construction, so
fallback now requires errors to survive two keyframe intervals. Verified
against the reporter's sample: playback logs "Hardware decoding
recovered after 90 errored frames" and stays on VideoToolbox instead of
switching to software.

close #2167
2026-08-29 17:39:35 +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 2f2016fdbe feat(player): show the next episode's thumbnail in the play-next prompt
The end-of-episode autoplay prompt only named the next episode; Plex shows
a visual preview, which is what non-readers navigate by. The prompt card now
paints the episode's 16:9 still edge-to-edge behind the text under a bottom
gradient scrim, falling back to the text-only card when no thumbnail can be
served (no thumb, or no client and no downloaded artwork). Unwatched stills
respect the hide-spoilers blur, and the lookup only runs while the prompt is
visible to keep the playback-path build cheap.

close #2166
2026-08-28 18:43:26 +02:00
edde746 ff1064d35f fix(player): render HDR letterbox bars off the SDR window plane
HDR/Dolby Vision playback on Fire TV (and some Sony/Philips models)
shows gray letterbox bars instead of black on OLED panels: the bars
were the player container's window-plane background, and those device
compositors raise SDR graphics-plane black while the display is in
HDR/DV output mode. Black inside the video plane is unaffected (same
mechanism as ExoPlayer #8803 and Kodi #25300).

Add a fullscreen, buffer-less SurfaceView beneath the video surface in
the shared player container. Like any below-window SurfaceView it
registers its rect as a window transparent region, so the letterbox
area scans out as the SurfaceFlinger backdrop instead of window-plane
pixels. No buffer is ever posted: SurfaceFlinger skips buffer-less
layers, and drawing into one would put the bars back on an SDR layer.
Covers both the ExoPlayer and mpv backends.

close #2163
2026-08-28 11:24:37 +02:00
edde746 861111a191 feat(player): offer a 140% zoom preset
close #2158
2026-08-28 08:12:38 +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 1ee648f442 fix(plex): direct play a source the quality preset already covers
Any quality preset other than Original started a transcode even when the
file was already well under it: a 6.2 Mbps 1080p h264 source under the
"1080p 10 Mbps" preset came back from PMS as a 10.4 Mbps encode, so capping
quality on mobile data spent more data than leaving it uncapped, plus server
CPU nobody asked for (#2152).

A preset is a ceiling, so a version that fits under both its bitrate and its
resolution is now served by the file itself, on the same direct-play path
Original takes, and no transcode decision is sent at all. Height counts as
well as bitrate because the preset's label promises a resolution too, and a
device that asked for 1080p may not decode the 4K source a bitrate-only
comparison would hand it. An unreported bitrate or height leaves the
transcode standing.

The comparison has to happen in the client: PMS answers directPlay=1 with
"Direct play OK" whatever bitrate cap the request carries, measured on 1.43
with a 65 Mbps 4K source under a 10 Mbps cap, so its MDE cannot be asked to
enforce one. Plex Web reaches its own directPlay/directStream flags the same
way, folding the preset's bitrate into its client-side direct-play profile
first. Jellyfin already gets this right server-side.

close #2152
2026-08-27 20:37:56 +02:00
edde746 ed71b200b9 feat(music): make the mini player progress bar scrubbable
While music plays, the persistent mini player showed progress only as a
subtle background tint with no way to seek; the full seek bar sat one
navigation hop away on the now-playing screen (#2141).

The card's top edge now hosts a thin visible progress bar inside a taller
touch lane: tap seeks proportionally, dragging scrubs and commits one seek
on release, and hovering or dragging grows the bar and shows a thumb. Card
taps outside the lane still open the now-playing screen. Pointer-only by
design: the overlay never renders on TV and the keyboard focus chain leads
to the now-playing screen's focusable seek bar.

close #2141
2026-08-27 16:59:08 +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 a13febf67b fix(downloads): store music tracks as Music/{Artist}/{Album}/{NN - Title}
Downloaded tracks landed in the generic downloads/{serverId}/{ratingKey}/
layout as video.mp3 because the path builder only special-cases movies and
episodes, so a user-visible download folder showed opaque folders and
meaningless names (#2141).

Tracks now get Music/{Artist}/{Album}/{NN - Title}.{ext} in both file and
SAF modes, grouped by sanitized album-artist with Unknown Artist/Album
fallbacks and a disc prefix on multi-disc albums. Existing downloads keep
working: playback and deletion resolve the stored path per record, and the
empty-parent cleanup keeps shared album folders while a sibling track
remains.
2026-08-27 16:58:52 +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 942065e39d fix(plex): honor disabled credits detection when synthesizing chapter markers
When a Plex admin disables credits detection for a show or movie, PMS
strips detected credits markers from its responses, but Plezy's
chapter-title fallback resurrected the skip action from a credits-named
chapter — surfacing a "Next Episode" button (and auto-skip) during
credits the admin asked to leave alone.

Drop credits markers when the owning show/movie carries an explicit
enableCreditsMarkerGeneration=0. Movies read the attribute off the item
already fetched; episodes resolve the grandparent show through the
shared cache-first metadata row, so the lookup normally costs no extra
request and only runs when a credits marker is actually present. Absent
attribute (servers without the feature) and -1 (library default) keep
the fallback intact; lookup failures fail open. The offline cached
extras path applies the same rule from cached rows.

close #2137
2026-08-27 10:16:05 +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 0c5308cfa1 refactor(networking): drop Cronet and NSURLSession for the tuned dart:io client
Plezy carried three native HTTP clients on the assumption that they beat
Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS,
Windows, Linux and three Android devices, two of them do not.

Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN
body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on
sequential API calls. It also fails a 60-way fan-out outright with
net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and
cost 70-705 ms of CronetEngine.build on first use. Paying that build off the
critical path is the only reason AndroidPlatformHttpClient,
warmUpPlatformHttpClient and the per-request delegate swap existed; all three
go away with it.

CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs
60 req/s, and no reported issue ever justified it. tvOS already shipped the
dart:io client, so Apple platforms now agree with it.

WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io
equivalent, and it brings the system proxy and the Schannel trust store.

The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient
so generic tracker and auth clients stayed disposable, but every dart:io client
has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so
tuned and untuned already share shutdown semantics and the flag only cost
throughput: 12 connections per host with a 90s idle measured ~4x the dart:io
default on a 60-way fan-out on Linux and ~2x on Android.

media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's
CronetDataSource independently of package:cronet_http.

Refs #2140.
2026-08-27 09:21:48 +02:00
edde746 a18ecb5c4a test(watch-together): stop the relay setup tests racing a real handshake
Two relay setup tests failed on CI while the same commit had passed a
few minutes earlier. The harness rewrote every 10-second timer in the
zone to 10 ms, which also compressed the connect deadline of the
recovery that runs once setup retries are exhausted. On a loaded runner
that deadline expired after the relay had upgraded the socket but
before the client sent its first frame, so the recorded connection
carried no messages and shifted the expected sequence; when all three
recovery attempts were starved this way, the recovery never announced
at all.

A reply that never comes is safe to compress, a real loopback handshake
is not, and one zone hook matching on duration cannot tell them apart.
The service now exposes the setup acknowledgement, the release
handshake, and the release acknowledgement as separate budgets, each
still 10 seconds in production, and every test sets only the one it
means to expire. The blunt helper is gone; what remains collapses the
250 ms and 500 ms retry delays, which are waits rather than races.

Verified by reproducing both failure modes under CPU saturation, about
one run in ten, then 30 consecutive clean runs of the file under the
same load and a full suite at 6332 passing.
2026-08-27 09:21:48 +02:00
edde746 f7060478d1 fix(plex): offer User Rating and Plays sorts in home-video libraries
Browsing a Plex home-video / "Other Videos" library under "Show all"
offered no way to sort by your own rating. The sorts Plex advertises for
those sections only cover critic and audience ratings, which unmatched
files never carry, so nothing rating-based was usable.

Plex honors `sort=userRating:desc` and `sort=viewCount:desc` on those
sections without advertising them, and the client already appends both
for movie and TV libraries. Home-video sections are `type="movie"` on the
wire and only differ by `subtype="clip"`, which the library mapper folds
into MediaKind.clip for folder-first grouping and wide cards, so they
were failing the movie/show allow-list. Add clip to it.
2026-08-26 20:28:19 +02:00
edde746 3f03262833 fix(player): recover playback after a lost native-channel handoff
A player whose predecessor had not released the shared native channel
within three seconds skipped its own native dispose and chained its
release onto that predecessor. If the predecessor's teardown never
completed - one hung 4K session was enough - every later player waited
on the chain, failed to initialize, and playback stayed broken with
"Playback could not be started" until the app was killed.

Initialize and dispose now carry the creating instance's token. The
Android plugins remember which token created the current core and
acknowledge a dispose from any other token without touching the core,
so a dispose that lost the ownership race is provably stale and safe to
send. With that guard, a timed-out ownership wait force-disposes
instead of skipping, settles its release unchained, and frees the
channel for the next session; a dispose watchdog answers Dart even if
a native teardown hangs, leaking that one core instead of wedging all
future playback. Commands wait eight seconds (was three) so a slow but
healthy teardown delays the next session instead of failing it.

Verified on a Shield Pro: 38 back-to-back session races at 0.8-2.2s
gaps with zero failures and balanced teardowns, and a deep-link-over-
playback collision whose stale dispose is ignored, after which Back
returns to the still-playing session and Retry starts the new one.
Apple and desktop handlers ignore the token and keep the historical
skip semantics until they gain the guard.
2026-08-26 18:59:28 +02:00
edde746 3c6d398598 feat(player): default Android playback to the mpv backend
With video on the MediaCodec plane, real bitstream passthrough, media3
demuxing, and the GL fallback ladder in place, mpv measures at parity
or better than ExoPlayer on every device class tested (Tegra, Mali,
Amlogic armv7, Adreno; API 28-36; phone, TV, and foldable), including
the two historical Android mpv failure modes: low-end TV playback and
Hi10P.

Installs that never chose a backend move to mpv; a stored ExoPlayer
choice is preserved, and the Player Backend toggle stays user-visible
as the escape hatch. The automatic ExoPlayer-to-mpv runtime fallback
is unchanged.
2026-08-26 18:59:28 +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 465da3e82a feat(audio): bitstream passthrough by default on Android TV
mpv's ao_audiotrack hardcoded stereo IEC61937 frames, which cannot
carry TrueHD MAT or DTS-HD MA 8-channel bursts, and the app fed
audio-spdif an empty list in self-defense because mpv
force-passthroughs every codec named there with no decode fallback.
The mpv path therefore decoded everything, and passthrough had to be
enabled by hand on every install.

The Kotlin audio route probe (AudioOutputPolicy) now feeds mpv a
per-route audio-spdif list, the fork's multichannel IEC61937 patch
(pinned libmpv v1.1.3) provides the burst geometry, and E-AC3, TrueHD
and DTS-HD MA bitstream on the mpv path. Android TV installs with no
stored preference default to passthrough on, on both backends; an
explicit user choice is preserved.

Verified by route probing and mpv logs on Shield and Box R; no AVR was
in the loop, so receiver-side decode is unconfirmed.
2026-08-26 18:59:28 +02:00
edde746 04615ce2fc feat(player): HDR output for software-decoded video on Android
Software-decoded HDR always collapsed to SDR on Android: the EGL
window surface carried no colorspace, so PQ/HLG content that fell back
to software decode rendered through an SDR surface, and Android was
excluded from the hdr-enabled path outright.

The GL vo now requests a BT2020-PQ window surface (fork patch, pinned
libmpv v1.1.3) and Android joins the hdr-enabled path, so software
decode keeps HDR scanout and tone mapping happens only when the
display genuinely cannot show HDR.
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
edde746 85bf948004 fix(macos): keep text and icons legible by pinning the Skia renderer
On macOS 2.17.1 the UI started flashing garbage: text and Material icon
glyphs rendered as striped noise, and a keypress could flash a whole
glyph-atlas texture across the window. 2.17.0 on the same machine was fine.

2.17.1 moved to Flutter 3.47.1, which made Impeller the default macOS
renderer. Plezy is unusually exposed to that switch: MainFlutterWindow
clears the Flutter view to transparent so the mpv CAMetalLayer behind it
stays visible, so whatever Impeller leaves in the presented drawable is
blended onto the screen instead of being hidden under an opaque frame.

Opt out through the Info.plist switch the macOS embedder reads from the
main bundle at launch, so it applies to release builds too. That restores
the renderer every release up to 2.17.0 shipped; keep it until the engine
fixes for the 3.47 macOS Impeller regressions reach stable.

close #2132
2026-08-26 17:16:21 +02:00
edde746 c564635ad9 fix(jellyfin): advertise video codecs from a native hardware-decode probe
A device with no hardware HEVC decoder could still be handed an HEVC
transcode: the device profile advertised a fixed codec list that assumed
every device decodes everything. Prepending AV1 to reach the AV1 encoders
issue #2131 asks for would have made that worse - an Apple TV 4K and every
iPhone before the A17 Pro have no AV1 decoder at all.

Probe the platform instead. Android enumerates MediaCodecList for a
hardware decoder and iOS/tvOS ask VideoToolbox, both feeding one latched
VideoDecodeCapabilities that the device profile reads when it builds its
codec lists. Desktop deliberately answers nothing: a pre-Kaby-Lake Mac has
no hardware HEVC decoder and an M1 no AV1 one, yet both software-decode in
real time, so narrowing there would force transcodes for nothing. An
unanswered or failed probe advertises everything, so the list never
narrows on missing data.

The transcode target becomes av1,hevc,h264 filtered by the probe. Leading
with AV1 is safe because the server rotates codecs its admin has not
enabled ("Allow encoding in HEVC/AV1 format", both off by default) to the
back before picking one, so it costs nothing on a server that will not
emit AV1.

Audio now accepts everything the path can carry. The direct-play profile
drops its AudioCodec list entirely - an omitted list means "any codec" to
Jellyfin - so an audio stream can no longer be what blocks direct play.
The transcode target lists every codec Jellyfin can put in an fMP4
segment, so a video-only transcode copies DTS or TrueHD instead of
re-encoding it. Two limits bound that string: the server validates it
against ^[a-zA-Z0-9\-\._,|]{0,40}$ when it echoes the list into the
transcode URL, so alac does not fit and * is not a wildcard; and omitting
the key is not "accept everything" here the way it is for direct play,
because the server substitutes the source codec and then ships no audio at
all for a source fMP4 cannot carry.

close #2131
2026-08-26 16:53:14 +02:00
edde746 fce4193f7e fix(jellyfin): negotiate video transcodes as fMP4 HLS instead of MPEG-TS
MPEG-TS segments cannot carry AV1, so a Jellyfin server with an AV1
hardware encoder (Intel Arc and similar) could never pick AV1 as the
transcode output no matter what the codec list advertised. Switch the
video TranscodingProfile container from ts to mp4, matching
jellyfin-web's fMP4 HLS behavior; mpv consumes fMP4 HLS on every
platform and the Plex VOD target has shipped it since issue #1859.

close #2131
2026-08-26 14:41:37 +02:00
edde746 628fd21ec3 fix(windows): keep the video visible behind the OSD by pinning the Skia renderer
After 2.17.1 the video went fully black on Windows for as long as the player
UI was on screen and came back the instant it faded out. Audio was never
affected, mpv.conf made no difference, and 2.17.0 on the same machine and
driver was fine.

2.17.1 moved to Flutter 3.47.1, which made Impeller the default Windows
renderer. Plezy is unusually exposed to that switch: the patched engine
presents the UI on a topmost DirectComposition visual that DWM blends over the
mpv video child, so the video is only as visible as the presented frame's
per-pixel alpha says it is. When Impeller's GLES backend gets that alpha wrong,
the OSD's full-screen scrim presents opaque and hides the video outright;
unmounting the controls presents a transparent frame again, which is why the
picture tracked the UI exactly.

Opt out through the embedder Impeller switch that 3.47 added. Unlike
FLUTTER_ENGINE_SWITCHES it applies to release builds, and it restores the
renderer every release up to 2.17.0 shipped without touching the engine patches.

close #2127
2026-08-26 03:15:59 +02:00
edde746 d2c4e8345f fix(player): retry transient stream errors instead of falling back to MPV
During Android direct play, a single dropped connection or network blip
mid-stream kicked an otherwise healthy ExoPlayer session over to the MPV
fallback - on a Shield this showed as random backend switches minutes
into an episode (log pu4ad: "ffmpeg demuxer read failed: -5" with
contentIsMalformed=true, while MPV reopened the same URL fine).

Three defects lined up behind it, all in the 2.17.0 ffmpeg demux path:

- The AVIO read callback turned the input proxy's stored IOException
  (its -1 return) into a bare AVERROR(EIO) without marking javaError, so
  the extractor classified the failure as a malformed container. media3
  never retries a ParserException, so the designed ERR_JAVA ->
  IOException -> load-error-retry path was unreachable. The callback now
  latches javaError for a negative read and fails fast on every later
  read in the same native call, so matroska resync cannot clobber the
  stored message or skip past the failed range.
- A failed refill latches AVIOContext error/eof_reached and avio never
  drives the callback again, so even a correctly classified retry would
  re-fail on the stale error. nativeReadPacket and nativeSeek now clear
  the latch on entry; a genuine end of file (error == 0) is left alone.
- FfmpegRandomAccessSource kept a handle whose read had thrown, and its
  position matched the retried request, so the retry was handed the same
  dead handle. A failed read now drops the handle and the retry reopens.

A transient failure now surfaces as a retryable IOException, media3
reopens the source, and sample delivery resumes gaplessly; persistent
failures still exhaust the retry policy and reach the MPV fallback as
before.

close #2113
2026-08-25 11:15:43 +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
edde746 75279ca196 refactor(discovery): expand server url candidates through one shared helper
Turning what a user typed into URLs worth probing lived inside
JellyfinEndpointDiscovery, next to a private copy of the scheme-detection
regex that url_utils already had. A second backend needs the same rules
with a different default port, and the existing copy carried two latent
defects: input with no host came back unchanged, so a candidate that can
never resolve went to the prober, and a query string survived into the
base URL that request paths are appended to.

expandBaseUrlCandidates takes the ordered guesses a backend wants — a
scheme plus the port to try when the user typed none — and owns the rest.
A typed port beats a guessed one, so host:8096 collapses to one candidate
per scheme and the same guess list covers both bare hosts and hosts with
ports. Candidates are built field by field rather than through
Uri.replace, whose null query means "keep mine", so query and fragment
are dropped for real. Blank input and input with no host expand to
nothing.

JellyfinEndpointDiscovery passes its existing order, so its probe and
persistence behaviour is unchanged, and hasUrlScheme replaces the regex
copy.
2026-08-25 03:07:14 +02:00
BockiandGitHub bd2d19a1c1 Update de.i18n.json (#2106) 2026-08-25 01:21:35 +02:00
github-actions[bot] 66c6b137a5 chore: update cask to 2.17.1 2026-08-24 23:18:29 +00:00
edde746 805632efc1 chore: bump version to 2.17.1 2026-08-25 00:43:37 +02:00
edde746 fe9d9d5e7d test(player): cover saf reads and the demuxer's jni byte source under r8
The random-access demuxer added two surfaces with no gate behind them. A
download stored through SAF arrives as content://, where an index read has to
open a second descriptor while the loader holds one, and the AVIO callbacks
are reached only by name from C++, so R8 may rename them while every debug
check passes -- the shape #1703 shipped with.

FfmpegExtractorSeekTest now runs the cued fixture over a content:// URI
served by a test-only provider, and FfmpegDemuxerReachabilityTest drives
nativeOpen plus a seek through a media3-free byte source so it can run
minified. Wired into the existing R8 reachability target; verified it fails
when the FfmpegDemuxerJni$Input keep rule is removed.
2026-08-24 23:02:23 +02:00
edde746 1995fc6b75 refactor(player): demux through random-access IO instead of unwinding libavformat
Resuming or seeking a large MKV on the Android FFmpeg demux path buffered
for minutes with no error and no fallback. The cause was the seam, not the
container: libavformat's demuxers own their seeking — end-of-file index,
back to the header, binary search — while media3's Extractor is forward
only, so every backward jump had to abort the in-flight libavformat call
with a synthetic IO error and replay the whole open. matroskadec attempts
its deferred Cues parse exactly once per context, so that abort burned the
index for the session and every later seek fell back to libavformat's
linear generic scan, reading the file up to the target.

That protocol had produced a bug of this shape repeatedly (a NULL deref
resuming find_stream_info across an abort, a use-after-free on fallback
teardown, matroskadec resync skipping the keyframe cluster) and it was held
together by four empirical retry budgets whose exhaustion degraded silently
— which is why #2096 looked like an unbounded spinner instead of an error.

Give libavformat what it requires instead. FfmpegRandomAccessSource serves
any absolute position from a second DataSource built by the same factory
media3 uses, cloning the DataSpec media3 opened for the item so Cronet,
download caches, SAF and per-item request headers still apply. Reads that
the loader's ExtractorInput can serve still go through it, so sample
delivery keeps feeding media3's byte accounting, back-pressure and
load-error policy; the extractor nudges the loader to follow the demuxer
with one RESULT_SEEK, which is an optimization that can never stall
playback because the read succeeds either way.

Deleted with the protocol: the synthetic AVERROR_NEED_SEEK unwind, the
24 MB replay cache, the Cues priming seek and its read guard, the sticky
AVIO error clearing, and all four budgets (seekAttempts, stickyRecovers,
MAX_RECONCILES, MAX_OPEN_LOADER_ROUND_TRIPS). Opens run straight through,
seeks are one avformat_seek_file on the loader thread, and an IO failure is
an IOException that reaches media3's retry policy instead of a silent
degradation. The demuxer is now the only component that resolves seeks, so
the extractor's sample-derived seek index is gone too.

Verified on a SEI Robotics Android TV box against Jellyfin: the DV P8.1
28.4 GB MKV resume that started at a 4m28s spinner opens in 1.1 s and
resolves its seek in one call, a mid-playback scrub resolves in 1 ms from
the in-memory index, a 2 GB MKV resume opens in 0.5 s, fresh playback needs
no index read at all, and MP4/AVI still open and deliver. New coverage:
FfmpegRandomAccessSourceTest pins the read/reopen contract, and
FfmpegExtractorSeekTest asserts on-device that a cued seek lands on target
without walking the file and that a cueless file still reaches it.

close #2096
2026-08-24 23:02:23 +02:00
edde746 defb0f4983 fix(player): publish real dolby vision profiles from the ffmpeg demuxer
The demuxer JNI read AV_PKT_DATA_DOVI_CONF as the bit-packed dvcC/dvvC box
layout, but libavformat surfaces the unpacked
AVDOVIDecoderConfigurationRecord — one byte per field. Profile 8 level 6
streams therefore surfaced as "dvh1.04.00" plain HEVC (visible in #2096's
logs): DoviConvertingTrackOutput's profile 7 conversion could never engage,
and profile 8 was routed to the plain HEVC decoder instead of the Dolby
Vision decoder the media3 demux path selects.

Read the struct fields verbatim and mirror media3's DolbyVisionConfig in
Kotlin: recognized profiles publish video/dolby-vision with the
dvhe/dvav/dav1 RFC 6381 codecs string, unrecognized profiles keep the base
codec's format. A DV P8.1 MKV on the FFmpeg path now reports "dvhe.08.10",
mime video/dolby-vision, and takes the DV P8 passthrough path exactly like
the media3 demuxer.
2026-08-24 23:02:23 +02:00
edde746 371eab8637 fix(downloads): stream from the server when a downloaded SAF copy is unreachable
Playing a downloaded movie or episode failed outright once the storage
holding it was gone — an SD card ejected, a USB volume unplugged, the
files deleted from outside the app (issue #2101). The item still played
fine from the server, but Plezy never tried: it committed to the local
copy and surfaced mpv's open failure.

Resolution already refused a missing local copy and let the caller
stream instead, but only for filesystem paths. SAF `content://` URIs
skipped the check on the premise that they are always playable as
written. They are not: an unmounted volume answers
`FileNotFoundException: No root for <volume>`, and a revoked grant is
just as dead, while the row still reads `completed`.

Probe the stored document through SAF before preferring it, so an
unreachable copy resolves to nothing and every consumer degrades the
way a missing file already did — the player streams, and both
external-player entry points launch against the server instead of a
dead URI.

close #2101
2026-08-24 19:27:20 +02:00
edde746 f357be4077 fix(tvos): match Siri Remote navigation to measured native focus-engine physics
Siri Remote navigation felt sluggish and then over-sensitive next to
native tvOS apps (issue #2006): swipes were priced at a fixed travel
per step, a single flick could glide into a second focus step, hard
lifts coasted several extrapolated steps, and rail scrolls snapped in
65-250ms where native glides.

Retuned the whole path against two hardware instrumentation passes on
an Apple TV 4K: committed-move telemetry through an experimental
UIFocusItem bridge (branch feat/tvos-native-focus-bridge), then a
dedicated native probe app logging every touch sample, pan velocity,
engine hint, focus step, and scroll tick across 101 swipe sessions on
160/230/300pt tiles. What the data showed, now encoded:

- step pricing follows geometry: one step costs the focused item's
  extent along the swipe axis plus ~155pt (measured 314/391/410pt on
  160/230/300pt tiles), not a fixed distance. Thresholds derive per
  axis from the primary focus rect, normalized so a wide-flat control
  steps vertically once the finger covers its height. Locked-focus
  rows (hub rows, the TV browse rail) vend their selected card's rect
  through the new LockedFocusRowNode so the row-wide focus node's
  screen-sized rect never prices the step. Scopes, the player's
  catch-all surfaces, and unbuilt cards fall back to a fixed 400pt.
- a lift never coasts more than one step: sessions with lift
  velocities up to ~11400pt/s never produced a second coast step. The
  glide is gated on a sustained drag (two consecutive same-direction
  steps), cancelled by reversal pivots and new touches, so a discrete
  flick moves exactly one item.
- the native 'inertia' feel is the scroll animation, not focus
  physics: the engine's scrollable containers settle over ~450-900ms
  of ease-out. TV rail and hub-row navigation scrolls now retarget a
  500ms easeOutCubic animation per step, so drags and hold-repeats
  chain into one continuous glide that catches up on release.
2026-08-24 13:41:44 +02:00
edde746 0fdc22b4fa fix(linux): make the render-executor completion handoff visible to TSan
GLib's GMutex is a raw futex, so ThreadSanitizer cannot see the
happens-before edge g_main_context_invoke_full provides and fails the
thread lane on plane_render_executor_test. Publish the completion
payload through an explicit release/acquire pair, and suppress the
remaining reports that originate wholly inside libglib's own source
bookkeeping. Races on repository memory still fail the run; verified
0/100 failing TSan runs in an ubuntu:24.04 container (20/20 before).
2026-08-24 12:42:23 +02:00
edde746 fb85bb279a style(android): apply ktlint to the ffmpeg audio csd code
The Native Formatting lane rejects the unbraced multiline if/else in
FfmpegExtractor and the wrapped expression body in FfmpegAudioCsdTest.
2026-08-24 12:42:23 +02:00
edde746andGitHub cc4eff98c0 build: migrate to Flutter 3.47.1 (#2092) 2026-08-24 12:32:05 +02:00
edde746 0fbf2e205c fix(profiles): skip the resume profile prompt during a live companion-remote session
With "Ask for profile on app open" enabled, every screen-off or app
switch on a phone re-pushed the profile picker and PIN on resume, burying
a companion-remote session that survives backgrounding since the
reconnect cycle was made lifecycle-aware. Suppress the resume prompt
while a companion session is live, mirroring the active-playback
exemption — the session already belongs to the profile that started it.

close #2087
2026-08-24 10:06:00 +02:00
edde746 a9f2c1ff99 fix(player): shape ffmpeg demuxer audio extradata into media3 csd layouts
VP9/WebM-origin files stalled at 0ms in ExoPlayer on 2.17.0 and fell
back to MPV after ~40s. The ffmpeg demuxer published raw extradata as
the only initializationData entry; Android's Opus decoders consume
their first three input buffers as OpusHead/codec-delay/seek-pre-roll,
so they ate the first two real packets as configuration and silently
discarded every decoded sample, pinning the audio-driven clock at 0.
Vorbis (unsplit Xiph lacing) and FLAC (unmarked STREAMINFO) had the
same shape divergence.

Shape audio extradata into the layouts media3's own extractors emit
before publishing the track format.

close #2088
2026-08-24 09:36:10 +02:00
github-actions[bot] fe4e53f912 chore: update cask to 2.17.0 2026-08-24 05:28:32 +00:00
edde746 fed572d19e chore: bump version to 2.17.0 2026-08-24 06:16:29 +02:00
edde746 adef523894 chore(build): add a SwiftPM refresh script for stale MPVKit pins
After set_mpvkit_revision.sh moves the MPVKit pin, macOS builds die in the
SwiftPM integration migration with "could not find the commit <sha>": Flutter
runs the macOS Xcode steps with -skipPackageUpdates, and its one fetch-capable
step only ever runs for iOS, so the local mirror never learns the new revision.

scripts/refresh_apple_spm.sh runs a single -resolvePackageDependencies per
platform to update the mirror without touching the tracked locks, with --reset
for an internally inconsistent SourcePackages directory. The header of
set_mpvkit_revision.sh now points at it.
2026-08-24 06:12:06 +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 ead637cc92 ci(linux): build plane_render_executor_test so ctest can run it
Both Linux sanitizer jobs have failed since 974806fe. It registered
plane_render_executor_test with ctest, but the reliability job compiles an
explicit target list and the new target was never added to it, so ctest found
no executable and reported the test as Not Run.
2026-08-24 02:13:32 +02:00
edde746 eed6071b94 test(jellyfin): assert DateCreated on the music latest hub row fields
The cross-server aggregation suite still pinned the music Latest row's
`Fields` to the pre-DateCreated set, so the suite has failed on every commit
since 24d7681d added the field. That commit updated the assertion in
jellyfin_client_urls_test.dart but missed the second copy of the same
expectation here, leaving CI red rather than signalling a real regression.
2026-08-24 02:13:28 +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 8b5ee4f3dd fix(subtitles): apply downloaded subtitles instead of polling the stale metadata cache
In-player subtitle search always ended with "Subtitle downloaded, but it
could not be selected": the apply flow polls getVideoPlaybackData for the
new external stream, but the fresh-cache-first fast path (be9197ed) served
the pre-download /library/metadata row for every iteration — playback
start leaves a fresh row, and the first network poll re-stamps it, so the
new stream was never observed and the flow timed out.

Add forceRefresh to PlexClient.getVideoPlaybackData (mirroring
getPlaybackExtras) and pass it from the download poll loop. The forced
fetch still rewrites the cache row, so the subsequent source switch sees
the new track.
2026-08-24 01:52:13 +02:00
edde746 a6cca37174 fix(ui): keep pages scrollable when the wheel lands on a hub row
On Linux the Home page, a library's Recommended tab and movie/show detail
pages could not be scrolled at all, while Browse, Collections, Playlists,
search, downloads and settings scrolled normally. Those three screens are
exactly the ones whose viewport is covered by horizontal hub rows.

A wheel, trackball, trackpoint or button-scroll event expresses one-axis
intent, but the host reports both axes in the same event: on Linux only
touchpad-sourced scrolling becomes a pan/zoom sequence that resolves in the
gesture arena, and everything else arrives as one PointerScrollEvent
carrying whatever dx the device produced. A Scrollable claims a scroll
signal as soon as the delta along its own axis is non-zero, and the deepest
claimant wins the PointerSignalResolver, so a tenth of a pixel of dx handed
the whole event to the horizontal row and the page behind it never moved.

Collapse a scroll signal onto its dominant axis in the binding, before hit
testing - the last point where the delta can still be corrected, because a
Scrollable has already registered with the resolver by the time it sees the
event. Pan/zoom events are left alone, so trackpad panning keeps both axes,
and a horizontal wheel or tilt still scrolls the row it points at.

close #2081
2026-08-24 01:49:43 +02:00
edde746 fff1b3090d build(apple): pin MPVKit by commit instead of by version
MPVKit publishes binaries for every commit on its main branch now, so a
version tag is no longer the only resolvable thing. Pinning the revision
means a fork fix is consumable as soon as its binaries land, with no release
ceremony there and no version bump here.

Pinned at 0097fea015fe, which carries the PiP subtitle fix: the iPadOS
Picture-in-Picture window no longer resizes when SRT subtitles appear and
disappear. Nine pin sites move together -- three project.pbxproj
requirements plus the six SwiftPM locks that check_apple_spm_locks.py keeps
in agreement.

tvos/scripts/wire_mpv.rb no longer hardcodes the version it writes; it reads
the revision out of the tvOS project's committed lock, so re-wiring the
tvOS target cannot silently revert the pin. Its test asserts the shape and
that all nine sites name one commit, rather than a literal sha, so moving
the pin stays one command:

    scripts/set_mpvkit_revision.sh <40-char sha>

close #2078
2026-08-24 01:15:28 +02:00
edde746 c3305d2919 fix(tv): paint the floating sidebar panel over content
In TV mode the sidebar menu floated unreadably on top of the home rows,
the artwork and the settings list — two overlapping sets of text with no
panel behind either.

The rail's surface is transparent on TV so the full-bleed backdrop
continues behind a docked rail, which is safe because the shell displaces
content around it. 2.16.0 turned hover/touch expansion into an M3E modal
panel that overlays content at the collapsed offset instead of pushing
it, so there the rail painted nothing and content showed straight
through the menu.

Keep the transparency rule for every docked shape and paint the surface
whenever the rail floats, reusing the predicate that already gates the
overlay corner radius and edge shadow. The fade now shares the width
morph's duration and curve, so a shorter fade cannot strand a fully
grown, fully transparent panel over the content mid-collapse.

Reproducing needs Force TV mode plus a pointer, which is why this
surfaced on a SteamOS box driving a TV and never on Android TV's D-pad
path.

close #2079
2026-08-24 01:15:28 +02:00
edde746 b457290a6b fix(player): stop the Dolby passthrough buzz after 30-40 minutes via MPVKit 1.0.26
With Audio Passthrough on, (E-)AC-3 Direct Play on Apple TV degraded
into a repeating buzz after roughly half an hour and never recovered
(a 31.25 Hz comb: one stale 32 ms frame looping). During preroll the
system's Dolby pipeline probes the fake endless resource at a fixed
~183 MiB offset; the AO satisfied it with fabricated copies of the
latest access unit, and CoreMedia cached that range by stream offset
and planned its sequential reads around it. Once playback reached the
cached bytes (33 min at 768 kb/s, 40 min at 640 kb/s) it played the
fabricated frames, and its next read landed past the write head, where
the loader kept fabricating frames forever. Seeks rebuild the item at
offset zero, which is why exit/resume bought another 30-40 minutes.

MPVKit 1.0.26 refuses loader reads past the write head instead of
fabricating data; the pipeline falls back to a plain sequential read
from the write head and prerolls normally with nothing poisonous
cached.

Verified on an Apple TV 4K gen 3 with an HDMI capture rig: the 1.0.25
sink audibly and loggably degrades at stream byte 0x0B770000 (8.3 min
into a 3072 kb/s accelerant stream, 40 min at 640 kb/s), while the
1.0.26 sink crosses the same byte with continuous audio on both the
accelerated stream and a full 40-minute 640 kb/s Plex Direct Play soak
through Plezy itself.

close #1776
2026-08-24 01:15:28 +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 5b6016d88b fix(player): strip mpv.conf-style quotes from custom config values
A quoted value in the mpv.conf settings screen, copied verbatim from a
real mpv.conf (sub-font = 'NetflixSans-Bold'), reached mpv_set_property
with the quotes included: a string property silently selected a
nonexistent font family and fell back to the bundled font, and a
numeric property failed mpv's parse and was only logged. Either way the
line did nothing. mpv's own config-file parser strips one pair of
matching quotes around the whole value; parseMpvConfigText now does the
same before the startup pass applies the entries.

close #2025
2026-08-24 00:08:10 +02:00
edde746 6aaea847a7 fix(tv): stop dropping more than half the frames on D-pad browse
On a 32-bit Amlogic TV box, 57% of UI frames while moving focus along a rail
missed the 16.68 ms budget, and the median frame was already over it at 20.7 ms.
The GPU was idle throughout (raster p90 9.1 ms, zero frames over budget) -- all
of it was main-isolate work, split roughly evenly between layout and semantics.

Five separate causes, all measured:

- The horizontal rail used `itemExtentBuilder`, so `RenderSliverVariedExtentList`
  walked every preceding index on each realized child, in every layout pass, and
  re-resolved the trailing slot inside the closure. Pinning the trailing cell to
  the card extent makes the list uniform, which restores O(1) offset, index and
  max-extent math.
- The sidebar-expand tween wrapped the whole shell with a `LayoutBuilder` inside
  its builder, so ~15 ticks per focus flip rebuilt `SideNavigationRail` -- and its
  non-virtualized child list -- during the layout phase. The tween now wraps only
  the content `Positioned`, matching `SideNavigationBleedBuilder`.
- Each nav item crossfaded through two `Opacity` subtrees, i.e. 14-18 offscreen
  save layers per frame for the whole 250 ms morph. It now fades colour alpha on
  the leaf, the same substitution `AnimatedDimScrim` already documents.
- `HorizontalScrollWithArrows` kept a scroll listener on platforms where its arrow
  chrome is compiled out, so crossing either scroll boundary `setState`d a whole
  rail row of cards. Two full-row rebuild storms per traversal, for arrows that
  cannot appear.
- The rail's semantic proxy rebuilt its label and six closures on every
  `_RailFocusModel` notification, including vertical-scroll flips.

Also: one merged listener per `SettingsBuilder` instead of six, and the media card
semantic label is cached rather than rebuilt and then discarded by the rail's
`ExcludeSemantics`.

Measured on the device, median of three runs: frames over budget 120/212 -> 32/246,
total UI-thread work 4067 ms -> 1845 ms, layout p90 18.2 -> 8.1 ms, build p90
7.95 -> 2.42 ms. The sidebar scenario went from 121/148 frames over budget to
32/184.
2026-08-23 18:13:39 +02:00
edde746 a615ec6b97 test(plex): pin that hydration installs no live machinery
The offline guarantee rests on hydration being genuinely network-free, not just
on the call sites picking the right entry point. Assert it directly: a hydrated
service must not react to a connection row appearing afterwards — which is what
the boot-time legacy migration does — and `start()` must then pick that row up,
proving the watch belongs to the live side.

Device verification of the airplane-mode cold start is not included: it needs
physical access to re-enable the test box's network. This test covers the same
invariant deterministically.
2026-08-23 18:13:39 +02:00
edde746 c4059d0ead fix(startup): stop Cronet and Plex Home from blocking time-to-interactive
Two cold-start findings from the same pass. They share a call site in
`MainScreen`'s post-frame block, so they land together.

## Cronet was 33% of time-to-interactive

`createPlatformClient()` built the shared `CronetEngine` inline, so whichever
consumer happened to create the first HTTP client paid for it — and that landed
between `database_ready` and `credentials_loaded`, i.e. squarely on the path to
the first usable screen.

Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by
temporarily forcing the existing `_cronetBroken` fallback so no engine is ever
built:

| phase | engine built inline | engine never built |
|---|---|---|
| database_ready | +455 | +456 |
| credentials_loaded | +1171 | +703 |
| binding_settled | +1331 | +827 |
| main_screen | +1394 | +932 |

So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule
loadModule2NoCrashUtils` then `HttpFlagsLoader` reading
`com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider
*enumeration*, not selection — `CronetEngine.Builder(Context)` calls
`isEnabled()` on every registered provider, and `PlayServicesCronetProvider`
answers that by installing the Play services Dynamite module. `play-services-cronet`
arrives transitively through `media3-datasource-cronet`, and `package:cronet_http`
offers no way to choose a provider, so the only lever available in Dart is *when*
the cost is paid.

Android's `createPlatformClient()` now returns a client that resolves its
delegate per request: the tuned IOClient that already backstops a broken Cronet
until the shared engine exists, Cronet afterwards. Per-request matters — a
`MediaServerHttpClient` builds its client in a constructor initializer and lives
for the process, so deciding once at construction would have pinned primary
media-server traffic to HTTP/1.1 forever, which is a silent steady-state
regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine
from `MainScreen`'s post-frame block.

Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines
(`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build
now runs from +1023 to +1419, entirely after the first screen, and produces no
Choreographer or Davey report — the UI is static waiting on hub content there, so
there are no frames to drop.

## Plex Home refresh raced the offline decision

`PlexHomeService.start()` conflated disk hydration with going live: it decoded
the cached `plex_home_users_*` entries *and* subscribed to connection changes,
installed a refresh timer and fired `_refreshAll()`. It was invoked straight from
a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi
these devices typically have — it started requests that would time out during the
exact window the startup gate needs. Its immediate neighbour
`ActiveProfileBinder` is explicitly not auto-started for this reason and says so
in a comment; the same argument applied here and had simply not been followed.

`start()` is now the live/network entry point and `hydrate()` is the disk-only
half, coalesced and lifecycle-guarded like `start()` already was. The provider
`create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow
picker hydrates, because it reads `current` immediately and is reachable while
offline. Only `MainScreen` goes live, gated on `!_isOffline`, with
`_handleOfflineStatusChanged` picking it up if the session later regains network —
otherwise an airplane-mode launch would never refresh Plex Home again.

Hydration still `_emit()`s, so `stream`'s replay contract holds even when the
network side never starts, which is what keeps a late listener behind a
`combineLatest` off a permanent spinner.
2026-08-23 18:13:39 +02:00
edde746 f95465067e chore(tools): add the on-device frame and startup benchmark harness
The measurements behind the preceding commits needed a repeatable way to drive a
real Android TV box and read both sides of the frame. `dumpsys gfxinfo` alone is
not enough (it sees the HWUI composite, not Flutter's UI and raster threads), and
DevTools is not scriptable.

- `vmclient.mjs` -- Dart VM Service client over WebSocket, plus percentile and
  timeline helpers and a CPU self/total-time reducer over `getCpuSamples`.
- `scenarios.mjs` -- repeatable D-pad workloads. Keys are sent as one batched
  `input keyevent` invocation per burst, because a separate invocation per key
  costs more on the device than the interaction being measured.
- `bench.mjs` -- runs a scenario and correlates Flutter's own frame phases
  (`Animator::BeginFrame`, LAYOUT, SEMANTICS, BUILD, PAINT, `Rasterizer::DoDraw`)
  with HWUI framestats, reporting medians over repeats.
- `profile.mjs` -- CPU self/total time by function plus an allocation profile.
- `launch.sh` -- cold-launches the profile build, sets up its own port forward and
  prints a host-reachable VM Service URI. Passes `--ez enable-dart-profiling`,
  which only `flutter run` supplies by default, and wakes the display first
  because `am start -W` silently reports no `TotalTime` when the screen is off.

Not referenced by `lib/` and not packaged; `tools/` is outside the APK.
2026-08-23 18:13:39 +02:00
edde746 e6a0840fbe fix(player): stop rebuilding the whole controls tree on every tick
Nothing sat between the full-screen `RasterizedGradient` scrim and the
position-driven leaves, so `markNeedsPaint` from the timeline slider walked past
the gradient and re-rasterized the entire chrome -- top bar, button row,
timestamps, timeline -- on every 250 ms position tick. That also partly defeated
`RasterizedGradient`, which exists because Skia gradients cost ~10 ms per
full-screen pass on this GPU class.

Around that, four things drove root `setState` calls that only needed to change a
leaf:

- Held D-pad seek called a root `setState` per key repeat, even when the readout
  was already visible in the same direction and only the accumulated seconds had
  changed. The seek amount now flows through a `ValueNotifier` that
  `DoubleTapFeedback` reads, so a burst updates one `Text`.
- `DoubleTapFeedback`'s drift chevron ran `..repeat()` with no stop condition, and
  kept ticking at 60 Hz for the ~300 ms the readout spends at zero opacity after
  every burst. The controller is now gated on visibility.
- The auto-skip countdown ran a 5 Hz timer that root-`setState`d the controls tree
  for the whole intro/credits window, to animate one progress ring.
- The play-next and still-watching countdowns root-`setState`d the player screen
  once a second, which re-created `PlexVideoControls` with its ~50 props and
  rebuilt the entire chrome to change one digit. Prompt open/close deliberately
  stays on the state path, because `playNextFocusNode` is threaded through those
  props and D-pad focus depends on it.

Two of the four position subscribers existed only to recompute pointer-hover
tooltips that a D-pad viewer never sees, rebuilding a button subtree 4x/s and
rescanning the chapter list each time; the "ends at" readout reformatted a string
4x/s for a value that changes once a minute. All three now dedupe.

Finally, the buffer path did two sequential `copyWith` calls on a 15-field
`PlayerState` and handed `BufferRangePainter` a freshly allocated list every tick.
`List` has no value equality, so `shouldRepaint` was unconditionally true.

Measured on device during playback with the chrome raised: 6 of 344 UI frames over
budget, p50 4.39 ms, layout p90 1.09 ms. During a held D-pad seek -- the worst
frame-time window on the TV path, because the readout and the decoder flush land in
the same frames -- 1 of 354 frames over budget, p50 4.28 ms, zero raster frames over
budget.
2026-08-23 18:13:39 +02:00
edde746 cb55134cef fix(startup): move optional work off the launch gate
Cold start on the target TV box reaches its first frame 21 ms after `dart_main`,
so nothing Dart-side gates the splash. Everything below is on the path to the
first *useful* frame, which is a strictly serial chain and where the viewer
actually waits.

- `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an
  applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole
  `ThemeData` again. It was rebuilt five times per cold start, two of them before
  `runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by
  palette plus `TargetPlatform` -- the platform matters because `ThemeData()`
  derives tap target size, visual density and typography from
  `defaultTargetPlatform`, so a palette-only key would be wrong under a debug or
  test platform override.
- `initializeDateFormatting` ignores its locale argument and builds CLDR symbols
  and patterns for all 121 locales synchronously. It blocked the gate ahead of the
  database open for data that only content screens use.
- `DownloadStorageService.initialize` ended in a `path_provider` round trip plus
  mkdir at the tail of the gate, contradicting the comment above it that already
  explained offline artwork is not a launch requirement.
- `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from
  `initState` of the widget whose first build produces the first app frame, and
  the RSS watchdog installed a periodic timer there whose first useful sample is
  15 s away regardless.
- `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main
  isolate, uncached, on every registry read and on every Drift re-emit -- and the
  binder writes tokens during the startup sweep, so writes re-triggered reads.
  Decryption is memoized by ciphertext, with `invalidateCache()` wired into the
  preference-store repair path so a repaired install cannot serve stale plaintext.
- `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around
  the legacy migration are deliberately not merged; only genuinely concurrent
  callers share a snapshot.
- `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit
  purely to compare, allocating two maps and two strings each time.
- The splash rendered one `CircularProgressIndicator` per pending server on top of
  the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of
  `awaitBindingSettle` -- competing with the startup work they were reporting on.

Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms,
`credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is
unchanged at ~18 ms, as expected.
2026-08-23 18:13:39 +02:00
edde746 5d83d4833c fix(images): stop decoding artwork nothing ever displays
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.

- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
  `resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
  10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
  the same provider in `ResizeImage`, so it decodes again under a different cache
  key. The comment already said the intent was only to warm the disk cache, so it
  now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
  10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
  own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
  3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
  viewer sits still and evicting the rail posters behind it. The fade is already
  zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
  `getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
  a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
  `backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
  `CachedNetworkImage` directly, keeping the sized request but losing the
  `plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
  re-downloaded on detail open. The season strip also discarded its height bound.

Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
2026-08-23 17:51:27 +02:00
edde746 f2bf43a8bc fix(windows): restore window placement onto a live monitor without a blank flash
The remembered window placement restored invisible when its monitor was
gone (undocked laptop, powered-off TV), briefly flashed a blank window at
the restored spot before the first Flutter frame, and could lose the
maximized state: the exit path hides the window before a multi-second
teardown, so a debounced save landing in that gap recorded SW_HIDE, and a
window closed while minimized-from-maximized restored as a normal window.

Validate the saved rect against current monitors and keep only the size on
a miss, apply the placement with SW_HIDE so the first-frame callback stays
the single show, skip persisting while the window is hidden, and honor
WPF_RESTORETOMAXIMIZED when deciding to relaunch maximized.
2026-08-23 10:57:42 +02:00
edde746 b4b36c7e18 style(native): clang-format the mpv executor test, display-mode manager, and demuxer policy 2026-08-23 10:30:43 +02:00
edde746 be01631cab chore(ratings): delete the unused MediaRatingBadgeGroup pill
The detail hero builds its own pill around InlineRatingBadges since the
hero-chip rework, leaving MediaRatingBadgeGroup without a caller and the
unused-code check red. Remove it and inline the style helper its removal
left with one call site.
2026-08-23 10:30:43 +02:00
edde746 02a5f8ad2c fix(settings): pass the connection dialect when listing known servers in discovery
PR #1666 merged without compiling: it maps existing connections into the
add-server discovered list, but DiscoveredJellyfinServer gained a required
dialect with the Emby backend. Carry the connection's dialect through and
list only connections matching the screen's dialect, so the Add Emby screen
does not offer existing Jellyfin servers.
2026-08-23 10:30:36 +02:00
edde746 0964ab8ba2 feat(libraries): open grouping, filters, and sort as anchored menus on desktop
The three browse chips opened bottom sheets on every form factor. On
desktop platforms they now anchor dropdown popups to the chip: grouping
is a radio menu, sort shows the direction on the active field
(re-selecting toggles it) plus Clear, and filters use a two-level menu
with per-category values and an All row to clear one category. Phones
and TV keep the existing sheets.
2026-08-23 10:12:27 +02:00
edde746 89b8af62ea fix(settings): use background focus chrome on the add connection cards
The backend cards drew the outline focus border; the wrapper now sits
inside the opaque Material with useBackgroundFocus so the fill paints
above the surface, matching the other settings surfaces.
2026-08-23 10:12:27 +02:00
edde746 5842d9c1ec fix(profile): replace the profile tile chips with inline metadata
The Active badge and connection chips used opaque primaryContainer /
surfaceContainerHighest - both resolve to the card surface color in the
mono scheme, so they punched dark holes in the focused tile's background
highlight, and boxed pills fight that fill however they are tinted. The
Active badge becomes a muted check icon + label next to the name, and
connection chips become plain muted icon + text entries, matching the
app's other meta rows.
2026-08-23 10:12:27 +02:00
edde746 07463eea04 fix(explore): keep the page at the top when a tab switch focuses a hub
Switching to the Explore tab hands focus to the first hub, and
HubSection unconditionally ran Scrollable.ensureVisible, scrolling the
app bar and search field off-screen in touch mode. The scroll is now
gated on keyboard/D-pad input mode, mirroring FocusableWrapper's
autoScroll.
2026-08-23 10:12:27 +02:00
edde746 627374d4b5 fix(detail): fit hero chips on one row and even out the mobile sections
The hero metadata strip wrapped to a second run, leaving the Rate chip
alone on its own line (or clipping it away on short heroes). The strip
now measures chips and sheds them by usefulness - scores pill first,
then quality labels, edition, certification, runtime - never the year
or the interactive Rate chip, mirroring the TV FittedMetadataLine.

Episode rows replace the 'E0' primary-container chip with an inline
'3. Title' prefix that mirrors the server-provided number, including a
genuine episode 0. Phone section gaps drop from 24px to 12px so Cast,
Extras, and the related hubs share one rhythm; TV keeps its own values.
2026-08-23 10:12:27 +02:00
edde746 c4b043cba9 fix(ui): present the library switcher and profile menu as bottom sheets on mobile
Both anchored popups were small tap targets on phones. AppMenuButton
gains an opt-in adaptiveSheet flag that routes through
showAdaptiveAppMenu: full-width, untitled sheet rows on touch platforms
(phones plus Android TV / tvOS), the same anchored popup on desktop.
2026-08-23 10:12:26 +02:00
edde746 aa9118a799 fix(tv): hold the hub focus glow while the rail scrolls vertically
Moving UP between hubs showed the focused card's glow immediately: the
glow paints in the root overlay, unclipped by the rail viewport, so it
flashed over the spotlight while the target row was still offscreen.
The rail now suppresses only the glow for the 250 ms vertical scroll
and lets it fade in once the row settles. FocusGlowOverlay defers
attached-portal shows out of build, which OverlayPortalController
asserts against.
2026-08-23 10:12:26 +02:00
edde746 fe954eef5f fix(profile): use background focus chrome on the profile screens
Profile tiles and buttons drew the outline focus border while every
other settings surface highlights with a background fill. Tiles nest
the FocusableWrapper inside the Card so the fill paints above the
opaque card surface; buttons opt into useBackgroundFocus.
2026-08-23 10:12:26 +02:00
edde746 66d0ef054e fix(auth): lay out the plex QR retry and cancel buttons inline on TV
The QR sign-in screen stacked Retry above Cancel with intrinsic widths,
reading as sparse on a TV canvas and pushing Cancel off the bottom edge.
Both wait states now share one centered row, matching the player's
initialization-error surface.
2026-08-23 10:12:26 +02:00
Thomas LupinandGitHub 8f13a56e1a Add existing Jellyfin servers to discovered servers (#1666) 2026-08-23 09:29:04 +02:00
edde746 8061c4f5f2 docs(contributing): require AI-assisted PRs to disclose models used 2026-08-23 09:19:55 +02:00
edde746 802d47e980 fix(windows): preserve Dynamic Refresh Rate when restoring display mode
On a Dynamic Refresh Rate display (e.g. Surface 120Hz panels), stopping
playback with Match Refresh Rate enabled left Windows pinned to a fixed
60Hz with DRR disabled: the legacy EnumDisplaySettingsW capture only sees
the DRR base rate, and a DEVMODE ChangeDisplaySettingsExW restore cannot
express the CCD-level DISPLAYCONFIG_PATH_BOOST_REFRESH_RATE selection.

Capture a virtual-refresh-rate-aware QueryDisplayConfig snapshot of the
topology before the first display mutation and restore it through
SetDisplayConfig (saving to the database to repair the 24/48/60Hz
registry-workaround write-back), keeping the DEVMODE path as fallback.
The HDR toggle mode dances re-apply the same kind of snapshot, and crash
recovery returns to the persisted display configuration via
SDC_USE_DATABASE_CURRENT, verified against the recorded mode before the
legacy restore takes over.

close #2055
2026-08-23 07:34:56 +02:00
edde746 1a9d2686c9 fix(tv): declutter the detail page with a slimmer rail and spaced hero
The TV media detail page felt cramped on 1080p TVs (540 logical px): the
bottom rail's title strip, card label bands, and next-hub peek consumed
nearly half the screen and cut the next hub's posters mid-card at the
screen edge, while the hero packed logo, metadata, genres, summary, and
action row into 5-10 px gaps against the back arrow, shedding summary
lines to fit.

Slim the shared rail metrics (hub strip 36->30, next-hub peek 30->20,
label band 42->36, person labels 58->52, height slack 14->10) and the
detail page's rail card scale (0.8->0.72), then spend the freed height
on the hero: larger gaps between metadata/genres/summary/actions, a top
offset that clears the back button, and an info column widened from 57%
to 60% of the screen. Movie and show details now keep three spaced
summary lines, and the wider metadata line fits one more rating badge
before shedding.

The #1893 metadata-line regression test pinned the exact badge-shed
count at its test viewport; it now asserts rendered badges stay fully
on screen while keeping the quality-label guards.
2026-08-23 07:08:27 +02:00
edde746 95800d8a72 fix(tv): open a full details sheet from the detail hero info block
On TV the detail hero caps the description at three lines with no way to
read the rest, and the fitted metadata line sheds rating badges and
quality labels when they do not fit, leaving that information
unreachable by D-pad.

The hero information block is now a focusable target: D-pad up from the
action row lands on it, and select (or tap) opens a sheet with the
complete metadata fields, every rating badge, the genres, and the
untruncated description. Long descriptions page with D-pad up/down.

close #2042
2026-08-23 05:51:36 +02:00
edde746 3f55cc461e fix(player): show audio codec and bitrate in the performance overlay on ExoPlayer
The Android performance overlay showed only sample rate and channels for
EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec
from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide
(hence AAC working), and the bitrate from Format.bitrate, which Matroska
carries only when the muxer wrote BPS statistics tags.

Fall back to the already-transmitted sample MIME type for the codec name
(audio and video), and measure the audio bitrate in the FFmpeg demuxer
from packet sizes over their pts span — the same source mpv uses for its
audio-bitrate property — when the container declares none.

close #2063
2026-08-23 03:44:45 +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 974806fe80 fix(linux): render the mpv video plane off the GTK main thread
The player UI was choppy and slow while video played, and scrub-bar
thumbnails rarely appeared until the video was paused (worst with 4K
HDR content, whose per-frame tone-map render is expensive). The cause:
mpv's render and the plane's eglSwapBuffers ran on the GTK main thread,
which also rasters Flutter's UI and dispatches input, so every UI
repaint and pointer event waited out the video frame render.

Move the render + swap onto a dedicated plane render thread
(PlaneRenderExecutor). All Wayland protocol state stays on the main
thread: Present() splits into PreparePresent() (gates + frame-callback
request) and CompletePresent() (first-frame scale flush, ack watchdog,
mid-flight hide/rect-loss re-detach). The plugin serializes one job at
a time, defers rect application and HDR transaction starts to the job
completion so a resize never races the swap and a staged colour
transition can never pair an old-colour buffer with a new description,
and drains the worker before disposal - which is what lets
RenderToSurface render without holding native_mutex_. A worker wedged
inside a driver call is abandoned after a bounded wait and the
session's player and plane are deliberately leaked instead of freed
under it. PLEZY_PLANE_RENDER_MAIN_THREAD=1 restores the old inline
behaviour as a temporary escape hatch.

Measured in a headless-sway container with a 4K test file (llvmpipe
inflates render cost the way DV tone-mapping does on real hardware):
idle-playing UI commits went from ~354 ms to the keep-alive's ~100 ms,
and pointer reads from ~185 ms bursts back to input rate; the #2067
hide/backoff/recover log signature is byte-identical.

close #2057
2026-08-23 00:13:43 +02:00
edde746 6f64ca2952 fix(linux): recover the wayland video plane after the compositor stops acking frames
Switching workspaces on KDE or a wlroots compositor hides the toplevel
without any GTK visibility change, and the compositor stops answering
the video plane's frame callbacks. Once the ack watchdog spent its miss
budget it stopped dead: the giveup destroyed the only outstanding
wl_callback, mpv's edge-triggered redraw latch was already saturated by
a render that bailed on frame_pending(), and no visibility change ever
comes - so video stayed frozen on the last buffer until a resize or
fullscreen toggle, while audio kept playing.

Back off to a slow re-present timer instead of stopping. A present only
happens when mpv actually has a new frame, so a hidden playing plane
settles at one commit per ~1.5 s and recovers within a second of being
shown again; a paused hidden plane goes dormant and is revived by mpv's
next update edge.

close #2067
2026-08-22 15:31:34 +02:00
edde746 a9a7498991 fix(plex): remove comma-containing tags one request per tag
Plex's tag.tag- removes comma separated tags and has no escape syntax, so
removing a tag containing a literal comma ('Action, Comedy') split it at the
separator and could over-remove siblings named 'Action' or 'Comedy'.

Fields whose removals contain a comma now defer to one removal per request
after the main edit; every request restates the kept tags and the lock, so
the sequence converges regardless of order. Removals without commas keep the
exact single-request wire shape.
2026-08-21 19:23:45 +02:00
edde746 37446e2d8e fix(android): fail loud on non-literal JVM reflection in the shrinker check
The JVM-reflection scan only recognized string-literal lookups, so a
Class.forName or getDeclaredField written with a constant, variable, or
concatenated name was silently skipped and a release build could lose the
member while the check stayed green — inverting the fail-loud behavior the
checker applies to every native lookup it cannot trace.

Unclaimed reflective occurrences now error like the cannot-trace paths, and
the forName pattern requires the closing paren so a concatenated argument is
not half-claimed as a bare literal prefix. Production reflection stays
all-literal, so repository validation is unchanged.
2026-08-21 19:23:45 +02:00
edde746 72f68bdfa7 fix(player): mark chrome opaque only after the fade-in completes
Hiding the chrome in the frame between the post-frame opaque mark and the
next build trusted an opaque flag the renderer never realized: hide() kept
controlsPresented while the fade-in target never rendered, so no fade-out
ran, markControlsHidden never arrived, and Back/Escape stayed swallowed
until a later show/hide cycle repaired the flag.

The controller's opaque flag now follows the real fade-in completion via
AnimatedOpacity.onEnd, so any hide it observes can rely on a fade-out that
will actually run. Branches that mount the chrome directly at full opacity
keep marking immediately, since those insertions fire no onEnd.
2026-08-21 19:23:45 +02:00
edde746 24d7681d92 fix(jellyfin): request DateCreated on album hub rows so Date Added sorts work
The Latest Albums see-all sheet offers a Date Added sort, but its rows were
requested without DateCreated, so addedAt mapped null and the sort silently
compared nulls — the same gap the earlier DateCreated work closed for catalog
and hub rows. DateCreated is a direct dto property, not one of the per-row
COUNT fields that motivated this set's slimness (#1552), so the cost profile
of these folder-dto requests is unchanged.
2026-08-21 19:23:44 +02:00
edde746 595a846bb9 fix(tv): clear stale back-suppressor arming when the next back press arrives
Backing out of a focused text field on TV fires onBack on KeyDown and arms
BackKeyUpSuppressor so the orphaned KeyUp cannot run a second back. When the
closing IME session swallowed that KeyUp entirely, the armed state persisted
and silently consumed the next back press anywhere in the app.

A matching KeyDown now proves the suppressed press ended without its KeyUp
reaching us: the arming clears and the fresh press acts normally. The select
suppressor keeps consuming KeyDowns because the hotkey recorder arms against
the very KeyDown it re-dispatches; KeyRepeat while armed is still consumed.
2026-08-21 19:23:44 +02:00
edde746 7652d060e2 fix(trackers): purge queued writes on session invalidation and lock-order the fallback sweep
Queued tracker writes still replayed through the wrong account when the session died by token expiry instead of explicit disconnect: the auth-failure teardown cleared the store and rebound null but never purged the service's retry queue, so rows queued under account A replayed through whichever account connected next. The invalidation callback now purges like the disconnect path, after the rebind so an in-flight failure is dropped by the account-binding check instead of re-queueing behind the purge.

Narrower second hole in the same invariant: removeService swept the in-memory fallback outside the queue lock, so an enqueue whose persist failed while a disconnect raced it re-buffered the row after the sweep and the next flush resurrected it. The fallback add and the fallback sweep both run inside the queue lock now, so lock-slot order covers the buffered store the same way it already covered the persisted one.
2026-08-21 19:23:44 +02:00
edde746 7fc559e64b fix(downloads): resolve offline detail metadata through the active profile scope
A shared physical download created under MediaBrowser user A could replace user B's offline detail metadata with A's watch position and token-stamped image URLs: lookupOfflineMetadata resolved via the download creator's clientScopeId. It now resolves the active profile's persisted scope like every other profile-visible read; a profile without its own cached row falls back to the lightweight seed metadata.
2026-08-21 19:23:44 +02:00
edde746 ed1d0f1b10 fix(profiles): require the target profile's PIN before managing or deleting it from the picker
Long-pressing a PIN-protected local profile in the picker offered Manage and Delete with no verification, so any user could open its detail screen, clear its PIN, or delete it outright. Both actions now verify the target's PIN first (active profile and Plex Home profiles keep their existing flows).
2026-08-21 19:23:44 +02:00
edde746 5591a0094d fix(windows): key HDR restore to the recorded display and hand off after a monitor move
HDR state was recorded for the display playback started on, but the restore gate queried whichever monitor the window sat on at exit; moving the window to another monitor could skip the restore, clear the change flag, and delete the recovery record, leaving the original display stuck in HDR. The gate and any repeat toggle now resolve the recorded target.

A repeat SetHDREnabled issued after the window moved also stayed pinned to the recorded display: playback moved from HDR monitor A to monitor B re-toggled A, returned success, and left B untouched even though the Dart caller had probed B. With a change live, the operation now restores the recorded display first (retiring its recovery record exactly as a normal restore) and proceeds as a fresh change on the current monitor. If that restore fails the call refuses and keeps the old record so no display diverges from its recovery state; if the current monitor cannot be determined it stays pinned to the recorded display.
2026-08-21 19:23:43 +02:00
edde746 6ab46ced54 fix(relay): claim OAuth callbacks before the code exchange and bound poster and poll traffic per IP
Two callbacks with the same state could both exchange the authorization code upstream (concurrently or by replay); the session is now atomically claimed under the proxy lock before the exchange.

GET /posters/ and the /auth/result long-poll had no per-IP limiting or concurrency bound, and every poster lookup serialized through an exclusive store lock; both endpoints now use the established limiter and non-expired hits take a read lock. The limiter also tracks active transfers per IP with caps below the global limits (4 for fetches, 2 for uploads), because one unauthenticated client could otherwise take all 16 global poster-fetch slots and hold them through slow ServeContent reads, starving everyone else with 429s; concurrency checks precede bucket charges so a capped denial consumes no admission tokens.

/auth/result gets its own per-IP budget instead of sharing /auth/start's burst-3 bucket, where two concurrent NAT'd sign-ins 429'd on the fourth request. It is charged only after the poll secret validates (bogus requests keep the generic 410 and cost nothing), denials carry an honest Retry-After, and the Dart poller - which treated 429 as terminal and abandoned a valid session - retries them until the session's 10-minute lifetime expires.
2026-08-21 19:23:43 +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 ca7f90d1bd fix(widgets): dismiss the file-info spinner when the launching card unmounts
The non-dismissible file-info loading dialog was popped only from the card's own context; if a list refresh removed the card mid-fetch, both pop sites were skipped and the modal stuck forever. The spinner is now owned by ScopedLoadingDialogController and dismissed in a finally.
2026-08-21 19:23:42 +02:00
edde746 4fcb221643 fix(downloads): repair recovery paths, queue stalls, resume coverage, deletion fan-out, and aggregate status
Post-restart completion recovery joined directory/filename, losing the base-directory component the Task constructor strips on custom roots. An unresolvable queue head (offline server) broke out of the drain loop, stalling every other queued download; the drain now excludes it and continues. The download tree also read all-cancelled or partial containers as completed.

The queued-download resume was a one-shot on the first online client, so a persisted row skipped because its server was offline stayed queued until restart. The resume now re-fires whenever a server comes online that the last resume did not cover (including reconnects), and a resume landing mid-drain re-drives the pass instead of being swallowed by the processing guard.

Deleting an episode fanned out one network playback-extras request per sibling row; the reference scan is now cache-only, retaining thumbnails when a sibling cannot be resolved. Season/show deletion removes rows sequentially, so that conservative retention would have counted siblings queued in the same batch - a sibling, or the container row whose extras cache is always a miss, made every episode retain its thumbnails and orphaned the files once the batch's rows were gone. The fan-out threads the batch's rating keys through so rows scheduled to disappear neither retain thumbnails nor contribute in-use paths; genuinely surviving rows still trigger retention.
2026-08-21 19:23:42 +02:00
edde746 0f5cd11c08 fix(screens): claim the mandatory profile picker before awaiting and skip the full-screen loader after playback
The mandatory profile picker checked its re-entry flag before several awaits, so an initialization notification could stack two requireSelection routes; the flag is now claimed before the first await and the late-profiles edge only fires on a real transition. Returning from playback also reran the full metadata loader, replacing the whole detail screen with a spinner and resetting the selected season; playback returns now use the non-loading watch-state refresh.
2026-08-21 19:23:41 +02:00
edde746 7d0e04bcc0 fix(player): retire never-faded chrome immediately and display sync offsets through their scope
A show/hide inside one frame left the chrome flagged presented with no fade to clear it, so back and Escape did nothing until a later full cycle repaired the flag; hide now retires presentation directly when the chrome never became opaque. The sync-offset rows and slider seed also read the global pref while writes and the player used the scoped store, so a title/library-scoped offset displayed as 0ms and the first slider tap jumped from the wrong baseline.
2026-08-21 19:23:41 +02:00
edde746 09e0b06e49 fix(tv): unbreak collapsed-rail D-pad traversal, action-bar focus survival, and text-field back handling
With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates.

An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment.

The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
2026-08-21 19:23:41 +02:00
edde746 5c08862194 fix(trackers): purge queued writes on disconnect, evict stale anime snapshots, and cancel dismissed auth dialogs
Queued tracker writes carried no account identity, so rows created under account A replayed against account B after a reconnect; explicit disconnect now purges that service's queue. An in-flight markWatched/reconcile that failed after the purge was re-enqueued anyway (the write scope only tracked profile generation) and replayed into whichever account connected next: Tracker.accountBinding (client identity, rebound synchronously on every disconnect) is captured before each write and re-checked before queueing a retry, and because the check sits in the same synchronous segment as the enqueue, a row that passes it is claimed ahead of the purge in the queue mutex and is still removed by it.

The memoized anime-list snapshot survived writes, so sequential rewatch updates computed from pre-write state; successful writes now evict the entry.

A device-code dialog dismissed by system back never cancelled the poll (blocking new attempts until the deadline), and a user-cancelled sign-in showed the connection-failed snackbar; both now route through one cancel path.
2026-08-21 19:23:41 +02:00
edde746 de91db47c7 fix(player): keep progress reporting and live sessions alive across failed retries, re-arm the sleep timer on prompt dismissal
A Jellyfin live retry that failed before open stop-reported the still-current session (recover returns the receiver), killing /Sessions/Playing for the rest of the session; the discard now skips a recovered session identical to the current one. An in-place reload failing before the open boundary left the progress tracker disposed and nulled, so the resumed stream never reported again and the exit flush was skipped; the re-wire now also runs on the rollback path against the restored metadata. Dismissing the Still Watching prompt via back or next also never re-armed the sleep timer; it now counts as the same acknowledgement as Continue.
2026-08-21 19:23:40 +02:00
edde746 d7422c81cb fix(providers): keep reconnects, retained libraries, and Discord presence alive after failures
Companion-remote reconnects derived their intent from a session status the peer overwrites mid-join, so a failed reconnect attempt often never rescheduled; the attempt now carries its own intent flag.

Library loads dropped retained state on failure. A delta load that failed for every requested server removed their libraries while adding nothing, and an in-place refresh where one server succeeded and another timed out replaced the whole list with only the successful response, wiping the failed server's sidebar entries even though it stayed out of the loaded set. Both paths now key retention to the servers that actually responded: unreachable servers keep their entries and are refetched on the next status emission, removed servers still drop.

Discord RPC kept the dead client after a disconnect, making every 30s reconnect a no-op for the rest of the session; disconnect now tears the client down so the timer builds a fresh one. Rapid disable/enable while a connect was still initializing also let the old client's failure dispose the fresh one, so each attempt binds its listeners and cleanup to its own client instance and stands down when it is no longer current.
2026-08-21 19:23:40 +02:00
edde746 da21481ff3 fix(android): keep the reflected media3 MatroskaExtractor fields under R8
AssMatroskaExtractor and MatroskaLatmSupport reflect MatroskaExtractor's private extractorOutput/subtitleSample fields by name, but no keep rule covered them: R8 renames the fields and every MKV direct-play in a release build fails constructing the extractor. Adds the keepclassmembernames rule (descriptors verified against media3 1.11.0 in the Gradle cache) and extends check_shrinker_rules.py to scan Kotlin/Java reflection across all android src/main roots including libass.
2026-08-21 19:23:40 +02:00
edde746 e5dc9c3f89 fix(networking): surface cancelled responses as cancellation and stop crediting a failed Plex hub fetch
A response cancelled before or during body consumption completed as a successful empty body, so cancellation-triggered aggregation could overwrite valid state and a cancelled download could commit an empty file. The managed client now delivers an abort error, and a request whose own abort has fired classifies secondary teardown errors as cancelled (timeouts keep their type). A Plex server whose /hubs leg failed was also counted as succeeded when only the optional music leg returned, letting Discover replace good hubs with a music-only result.
2026-08-21 19:23:40 +02:00
edde746 d7500c25c1 fix(plex): stop double-encoding removed tags and surfacing failures as empty lists
Removing a tag containing a space or symbol silently did nothing: the removed values were pre-encoded and the transport encoded them again, so Plex matched nothing while the UI showed the tag gone. Folder listings and the shared list wrapper also converted every transport failure (401, 500, timeout) into a successful empty list, so an unreachable server rendered 'no folders'/'no results' with no retry while Jellyfin surfaced an error. Failures now propagate to the existing error paths, and the match screen gained one.
2026-08-21 19:23:40 +02:00
edde746 d1ccd236fd fix(gamepad): release held-direction state when a controller disconnects
Disconnecting a gamepad while holding a D-pad direction or a deflected stick left the synthetic key repeat running forever, since only window blur tore that state down. The disconnect event now runs the same teardown: stop repeats, release held keys, clear pressed/suppressed sets and stick latches.
2026-08-21 19:23:40 +02:00
edde746 5961bf8881 fix(settings): reject blank intro/credits patterns and ignore stored blanks
A cleared skip-marker regex saved as an empty pattern, and RegExp('') matches every chapter title: all synthetic markers became intros or credits, and an end-of-file credits marker could auto-advance the episode. Blank input is now rejected at both validation and save, and the chapter fallback treats already-persisted blank patterns as absent.
2026-08-21 19:23:40 +02:00
edde746 0a75e2345e fix(jellyfin): request DateCreated on list rows, sync ProductionYear on date edits, and window the EPG on overlap
Jellyfin gates DateCreated behind explicit Fields, so browse/hub rows mapped addedAt as null: the Date Added sort was a no-op and unplayed Next Up rows sorted dead last in merged Continue Watching. Both base field sets now request it.

Editing a release date re-posted the full DTO with the stale ProductionYear; the year now follows the edited date and clears with it.

The guide requested programmes by MinStartDate, dropping anything already airing; the lower bound is now MinEndDate so overlapping programmes stay in the window.
2026-08-21 19:23:39 +02:00
edde746 70ffe8ff15 fix(player): mirror ExoPlayer rate into PlayerState and keep secondary subtitles out of the primary selection
On the default Android ExoPlayer backend the native core never emits a speed property, so PlayerState.rate stayed at 1.0 forever: the speed sheet checkmark, keyboard speed stepping, long-press 2x restore, and media-session rate all computed from a stale 1.0. setRate now mirrors its value into state like setVolume already did.

On mpv backends, a selected --secondary-sid subtitle later in track-list order overwrote the primary selection because parsing ignored main-selection; the track sheet then badged the secondary as primary and replaced the user's primary on tap. parseTrackList now treats main-selection 0/absent as primary and 1 as the secondary selection.
2026-08-21 19:23:39 +02:00
edde746 938ddc5942 fix(startup): build the repair dialogs below the bootstrap MaterialApp
Tapping Repair on the startup-failure screen always threw: the repair flow received the gate State's own context, which has no Navigator, MaterialLocalizations, or ScaffoldMessenger, so both the confirm dialog and the fallback error snackbar raised instead of rendering. Thread the bootstrap home builder's context (below the MaterialApp) into the repair flow and its failure snackbar.
2026-08-21 19:23:39 +02:00
edde746 ec530c6713 fix(jellyfin): refuse ambiguous bare-scope cache resolution across users
With two profiles bound to two Jellyfin/Emby users on one server, a legacy bare machine-id lookup (offline downloads, cached playback metadata) could resolve another user's cache row, serving their resume position and token-stamped image URLs. Mirror the write-path guard: when the bare scope matches more than one user, log and return nothing.
2026-08-21 19:23:39 +02:00
edde746 a53041a352 fix(playback): match preferred track language by ISO variation, not string prefix
A 2-letter preferred language (es, ar, ru, ...) prefix-matched unrelated 3-letter track codes (est, arm, rum), so profile-based audio/subtitle selection could pick a wrong-language track whenever it preceded the wanted one in container order. Route the matcher through languageMatches, which compares exact codes, region variants, and ISO-639 variations.
2026-08-21 19:23:39 +02:00
294419109b feat(player): dismiss the skip prompt on Back instead of exiting (#2049)
While a Skip Intro / Skip Credits prompt is up and the chrome is down,
Back walked the screen's staged chain and exited playback. On a remote
nothing else means "no thanks": left/right seek, OK takes the skip,
up/down raises the OSD. Declining an intro cost you the episode.

Adds a stage for it alongside the sheet and content-strip stages that
already run locally in the player controls. Gated on the same condition
as Select's skip path, off on phones (#1938), off without canControl,
and off while a screen-level prompt owns the key.

The claim is latched for the whole press: handleBackKeyAction acts on
the key-up, and the button's own 7s auto-dismiss can fire in between.

Co-authored-by: cajunflavoredbob <cajunflavoredbob@users.noreply.github.com>
2026-08-21 08:48:44 +02:00
edde746 61268fab73 fix(livetv): derive recording state from grid subscription attributes
Recording indicators never appeared in the EPG and the program sheet kept
offering "Record" for already-subscribed programmes, so rules were created
repeatedly (nine duplicate subscriptions in the linked report). Two defects,
both confirmed against the official web client bundle:

- The sheet's scheduled-state check called
  /media/providers/<identifier>/media/subscriptions/mapping/<ratingKey>,
  which 404s: PMS mounts that route under the numeric MediaProvider id from
  /media/providers. The check failed instantly and unconditionally on every
  platform, so the button always read "Record".
- The guide derived its dot solely from cross-matching grab metadata, and
  the grab refresh issued right after scheduling raced the server
  materializing the grab, wiping the optimistic key before one frame showed.

The grid response already tags subscribed airings with subscriptionID /
grandparentSubscriptionID - the signal the official client renders from.
LiveTvProgram now carries those attributes; the guide checks them first
(grab matching stays as the secondary signal) and keeps local
schedule/cancel actions authoritative until the next grid load. The sheet
resolves Record vs Manage from the tagged rule key without a network call,
falling back to the fixed numeric-id mapping route for untagged airings.
Grab parsing also accepts the nested airing under "Video", which PMS uses
for every non-scheduled grab status in JSON.

close #2009
2026-08-21 07:44:27 +02:00
edde746 8b7917fcc4 chore: bump version to 2.16.2 2026-08-21 07:34:28 +02:00
edde746 2563d624ca fix(player): stop periodic audio dropouts during Dolby passthrough via MPVKit 1.0.25
With Audio Passthrough on, every (E-)AC-3 track on Apple TV dropped
100-220ms of audio every 1.79s. The system pipeline behind the
compressed AVPlayer path reads unboundedly far ahead of the playhead
and consumes its buffer in fixed ~1.8s quanta; the AO's 2s feed lead
meant each refill overran the write head and the renderer skipped the
missing audio to stay on clock. Bitrate- and route-independent, and
invisible to the AO: item status, time control, and the feed margin
all stayed clean while the sink output gapped (#1300, #1776).

MPVKit 1.0.25 feeds the elementary stream 8s ahead of the observed
playhead -- 4x the pipeline's refill quantum -- sizes the ES window
from the configured lead, exposes it as
--ao-avfoundation-compressed-lead, and keeps waiting during preroll
while a slow source (a realtime-pinned server transcode) is still
making priming progress instead of abandoning Atmos for PCM.

Verified on an Apple TV 4K gen 3 with an HDMI audio capture rig
against EAC3 768k and 640k streams: 21 dropouts/min on 2.15.0, zero
across 10.7 minutes with the new lead; a 997Hz sine encoded as EAC3
through the full passthrough path is statistically identical to
first-party AVKit playback of the same tone; a rate-limited server at
0.95x realtime now prerolls late on the compressed path instead of
downgrading, and once the lead is filled mpv's cache pauses absorb
the deficit with the margin intact.
2026-08-21 07:30:41 +02:00
edde746 54af977f39 fix(library): refetch content tabs when the app resumes from a long backgrounding
Opening Plezy on a TV box resumes the resident process rather than
cold-starting it, so the Libraries grid kept showing the in-memory
content from the previous session -- hours stale -- until the user
switched libraries and back. Nothing on the resume path refreshed tab
content.

MainScreen now latches genuine backgrounding and, on a resume more than
five minutes later, refreshes LibrariesProvider and sweeps the content
tabs through each screen's in-place Refreshable.refresh() -- skipped
while offline, before startup priming, during playback, or with no
connected servers. LibrariesScreen.refresh() now refetches the selected
library's loaded tabs (the toolbar refresh action) instead of
re-selecting the saved library, which never reloaded them because tab
widgets only reload when the library's globalKey changes.

close #2043
2026-08-20 18:19:03 +02:00
edde746 21f48382bc fix(hubs): derive wide hub-row cards from the grid's widening scheme
A continue-watching episode card was 180dp on home but 232dp in the
grid behind "see all" at 480dp: hub rows widened the resolved poster
cell (x1.5) while grids widen the max extent (x1.8) before the integral
column packing, and the TV shelf multiplied the rail's tall card by the
same 1.5. Rows now adopt the grid's packed wide cell via
MediaGridDelegate.wideCellWidth, the TV shelf passes the hub's wide
flag to TvBrowseRailLayout.cardWidthFor, and the single widening
scheme is stated in MediaGridDelegate. Grids are untouched.
2026-08-20 14:49:33 +02:00
edde746 424c14a344 fix(tv): size HubSection shelf cards with the browse rail formula
Live TV "What's On" and catalog related rows are the two TV surfaces
without a TvBrowseRail path; HubSection's own clamp ([210, 340],
unscaled) rendered ~40% larger cards than every neighboring rail and
gave 720p TVs the same 210dp floor as 4K. Delegate the shelf card
width to TvBrowseRailLayout.cardWidthFor so the clamps scale and match
the rails.
2026-08-20 14:34:39 +02:00
edde746 fcb167e661 fix(library): size hub detail posters like home rows and library grids
Home hub rows showed ~3 large posters per row while the hub's "see all"
page packed 5 small ones on the same 360dp phone. Hub detail was the
only surface on the padding-aware target-count formula, which resolves
ceil(lerp(5, 2, f)) columns regardless of screen width (and 9+ columns
on TV). Drop the flag so hub detail shares the fixed-extent formula
with every other grid, and delete the now-unused
getMaxCrossAxisExtentWithPadding and its usePaddingAware plumbing.

close #2039
2026-08-20 14:34:27 +02:00
edde746 32b3fb64a2 fix(linux): ship gio-launch-desktop so the bundled libgio can launch URLs
Root cause of the broken 'Sign in with Plex' and update links on
portable builds: the bundle ships Ubuntu's libgio, whose compiled-in
helper path (/usr/lib/x86_64-linux-gnu/glib-2.0/gio-launch-desktop)
exists only on Debian-family hosts. glib then falls back to a bare PATH
search, and distros like Fedora keep the helper in /usr/libexec outside
PATH, so every URL/desktop-file spawn failed. Installing the RPM masked
it only because system glib is built with the right path.

Bundle the build host's gio-launch-desktop next to the libgio it was
built with (bundle-libs.sh now fails the build if libgio is bundled and
the helper cannot be found) and export GIO_LAUNCH_DESKTOP from plezy.sh,
which glib checks before its compiled-in path. Covers the tarball and
the deb/rpm/pacman packages alike, since they all ship the same bundle
and launch through the wrapper.

close #1477
2026-08-20 13:31:16 +02:00
edde746 71cbb83c06 fix(player): keep a play/pause tap from crashing on a torn-down player
Transport from the controls layer ran unawaited with no error handling:
a tap racing player teardown (NOT_INITIALIZED on Android) or a wedged
mpv event queue (SET_PROPERTY_FAILED on Windows) crashed the app. The
controls path now tolerates failures the same way the remote transport
path one function above already does.
2026-08-20 13:31:03 +02:00
edde746 33d80b6d0e fix(companion): keep LAN discovery from emitting into a closed hosts stream
The most frequent Dart crash in production: CompanionRemoteProvider's
async crypto rebuild can call stopListening() on a discovery service
that was already disposed, and _emitHosts() then threw 'Bad state:
Cannot add new events after calling close'. Emitting after dispose is
now a no-op.
2026-08-20 13:31:03 +02:00
edde746 a9cbb72c63 chore(server): bump bugs image to sha-c996cf3
Deployed 2026-08-20. Adds dartsymbolmap chunk-upload support and
crashpad minidump ingestion; fixes the method-scoped SPA fallback
that 405'd every non-GET API request.

Old: sha-319e0eb@sha256:1e5a2d8ab80e703de4a8a8b15d858ce931609e2226bf1e6d04979c0ca52a3005
New: sha-c996cf3@sha256:540d985c51f953704c8b97c6c568d0ffc107b91223dd5279136cce1c5522598b
Index verified on two hosts; linux/amd64 + linux/arm64 present.
Migration 18 (event_attachments) rehearsed on a cloned DB copy.
Rollback: prior digest + /root/bugs-db-backup-20260820-082218 on the host.
2026-08-20 10:31:06 +02:00
edde746 7c496413f8 fix(remote): survive app backgrounding by guarding the reconnect cycle and retrying on resume
Backgrounding the Android app or locking the screen near-always dumped the
companion remote back to the device-selection page, forcing a manual
re-connect. Three defects combined: the peer's trailing disconnected status
event knocked the session out of reconnecting and broke the retry chain, the
bounded backoff budget (~31s) burned out against restricted background
networking before the user returned, and nothing retried on resume.

The provider now ignores stale peer status/error events while a reconnect
cycle is active, defers retries while backgrounded instead of consuming the
budget, and on resume retries immediately with a fresh budget - or pings a
nominally-connected session so a dead socket fails into the reconnect path
right away. Authenticated socket errors in the peer now surface as
disconnects like clean closes, keeping both terminal signals on the
reconnect path.

close #2035
2026-08-20 07:10:51 +02:00
edde746 04673a8c8a fix(library): render Plex home videos with 16:9 thumbnails instead of cropped posters
Plex "Other Videos" libraries returned items as type=movie with
subtype=clip, which mapped to MediaKind.movie and rendered 2:3 poster
cards that cropped the generated 16:9 video-frame thumbs.

Items keep the movie kind so downloads, add-to, delete-from-server, and
detail navigation stay available, but subtype=clip now renders wide with
the thumb-first artwork clips already use. Sections marked subtype=clip
in /media/providers map to the clip library kind, giving Plex home-video
libraries the same folder-first grouping and wide grid cells as
MediaBrowser homevideos views. Wide-only hubs also keep 16:9 cards in
the poster episode modes, matching the TV rail's gate.

close #2036
2026-08-20 05:44:08 +02:00
edde746 958c27350d feat(navigation): open settings with Cmd+, / Ctrl+, on desktop
Pressing Cmd+, on macOS did nothing: the stock Flutter template left a
disabled Preferences menu item holding the key equivalent, and nothing
in the app handled the chord.

MainScreen now handles Cmd+, (macOS) / Ctrl+, (Windows/Linux) beside
the existing Cmd+F search shortcut, reusing its tab-aware open path. A
SettingsShortcut fallback above the profile navigator covers pushed
content routes, pushing a named settings route; a navigator observer
blocks the chord while settings is already in the stack, and the video
player route keeps ownership of the keyboard. The dead xib menu item
is removed.

close #1909
2026-08-20 05:28:37 +02:00
edde746 591f33aa2c chore: bump version to 2.16.1 2026-08-20 04:21:15 +02:00
edde746 dcf2314d9d fix(player): interpolate the media-controls suspend flag in lifecycle log
The controller extraction left a bare $_mediaControls.suspendedForTvBackground
in the lifecycle diagnostic string, so the line logged the controller
instance instead of the boolean it exists to surface during TV
background-suspend debugging.
2026-08-20 03:30:07 +02:00
edde746 4c74d6a1d5 chore(player): latch TV background suspend state in one owned object
The Android TV background suspend (#1911) kept its grace timer, the
suspended latch, and four pre-stop position/track fields loose on the
State, with the latch/rollback/consume invariants enforced only by
convention across three lifecycle methods and the redelivery loop.
Move them into TvBackgroundSuspendState: latch() sets the snapshot and
the suspended flag together, clear() rolls both back when the native
stop fails, and consumeForRestore() drops the suspended latch before
handing the snapshot to the restore reload — the ordering the bounded
stop-report redelivery depends on. Pure eligibility predicates stay in
tv_background_suspend_policy.dart; orchestration stays in the
lifecycle part.
2026-08-20 03:08:43 +02:00
edde746 5842d30ae8 chore(player): route all live-session mutations through the live part
The live fallback ladder, retry latch, timeline suspend/resume, and
exit-on-resume flags were poked directly from the error handler, the
play-intent path, the playing-state listener, and lifecycle handling.
Give the live part named operations (_beginLiveLadderRetry,
_retryLiveStreamForPlayIntent, _resetLiveLadderOnPlaybackRestart,
_stopLiveSessionForTvBackground, _consumeLiveExitOnResume) and move
the timeline suspend/resume helpers over from lifecycle, so
LiveTvSessionState is mutated only by live_tv.dart and the initial
tune in the start composition root.
2026-08-20 03:04:14 +02:00
edde746 5e19d9dad4 chore(player): give the episode vertical an owned session-state object
Adjacency, loading flags, the Play Next prompt/countdown, the
transient-retry budget (#1867), the completion latch, and the per-
screen adjacency loader were thirteen loose State fields written by
the episode parts, the reload engine, and the stream listeners. Move
them into EpisodeSessionState — the episode analog of
LiveTvSessionState — so the vertical's mutable state has one home and
the reload engine's touch points (clear prompt on open, reset
adjacency and retry budget on swap, record failure reason) are visible
as writes to one object. Logic is unchanged; the screen disposes the
state object instead of the raw timer.
2026-08-20 03:00:27 +02:00
edde746 b0b4d5d6db chore(player): extract spurious-EOF recovery into an owned helper
The dead-stream recovery budget, its progress-based refill, and the
parked latch (#1520) were four State fields written from the commit
chokepoint, the position listener, the reload engine, seeking, and the
transport-intent handlers. Move the state machine into
SpuriousEofRecovery with explicit verbs (interceptEof, retry,
clearPark, resetBudget, onPositionAdvanced); the reload call rides one
injected callback that fills the common in-place-reload arguments. The
reload outcome enum becomes the public MediaReloadOutcome so owned
helpers outside the screen library can consume it.
2026-08-20 02:55:08 +02:00
edde746 a55ec642b3 chore(player): move the transition lock and generation into a gate object
The mutually-exclusive playback transition state, its lease identity,
the idle completer, and the playback generation counter were four State
fields manipulated by centralized screen methods. Move them verbatim
into PlaybackTransitionGate (tryAcquire/owns/advance/release/forceIdle/
waitForIdle/beginGeneration) so lease discipline is enforced by one
type and parts can no longer touch the raw fields. The transition enum
and lease type become public in the gate's library; leases are only
mintable through tryAcquire.
2026-08-20 02:50:18 +02:00
edde746 aedca9b416 chore(player): centralize first-frame readiness in FirstFrameGate
_hasRenderedFirstFrame had five writer files with hand-rolled
snapshot/restore pairs in the reload and channel-zap rollbacks, and the
paired _hasFirstFrame ValueNotifier was reset independently along the
open path. Move both flags into FirstFrameGate with explicit verbs —
markReady, resetUiForOpen, resetRenderedForAttempt, reset,
forceUiReadyOnFailure, snapshot/restore — so the UI-vs-reporting
asymmetry and the transactional rollbacks are enforced by one type.
The uiReady notifier keeps its identity across attempts; the video
surface, controls, and buffering overlay hold it by reference.
Unit-tests the gate's verb semantics.
2026-08-20 02:45:53 +02:00
edde746 d9a5fb034e chore(player): extract shader and zoom orchestration into a controller
Shader preset application, ambient lighting enable/restore/toggle, and
video zoom/boxfit lived as an extension reading per-attempt services
off the player State. Move them into VisualEffectsController, a plain
State-owned helper; the shader, ambient-lighting, and video-filter
services are injected as late-bound getters because all three are
re-created per playback attempt and nulled in teardown. Rebuilds keep
flowing through the single requestRebuild callback.
2026-08-20 02:40:30 +02:00
edde746 92cc49a265 chore(player): extract companion remote wiring into an owned binding
The CompanionRemoteReceiver callback install/uninstall, home-fallback
save/restore, provider sync handle, and receipt-time volume/seek
dispatch lived on the player State. Move them into
CompanionRemoteBinding, a plain State-owned helper: the binding
instance replaces the State as the receiver's playerOwner token with
identical identical()-guard semantics, and the provider is captured at
bind time because context.read can fail during dispose. Subtitle/audio
cycling stays on the State — the drain loop is bound to the playback
transition lease — and is passed in as callbacks.
2026-08-20 02:30:41 +02:00
edde746 1e83e4e6c2 chore(player): extract OS media-controls adapter into an owned controller
The Android TV background suspension latch, availability policy, and
resume/rewind restore logic lived as an extension on the 149-field
player State, with the suspension flag read and reset from three other
parts. Move them into MediaControlsScreenController, a plain
State-owned helper following the established player pattern: the
manager, player, and current item are injected as late-bound getters
because they are re-created per playback attempt. The controller now
solely owns the suspension latch; teardown clears it via
resetSuspension() instead of writing the State field directly.
2026-08-20 02:25:16 +02:00
edde746 e71580def9 chore(player): move the in-place reload engine into its own part
episode_navigation.dart fused two responsibilities: the episode
next/previous vertical and the screen's generic in-place media
transition engine (_switchPlaybackSource, _performPlaybackSourceSwitch,
_selectSourceSubtitleLocally, _reloadMediaInPlace,
_reapplyScopedPlayerPrefsForItemChange), which lifecycle restore,
spurious-EOF recovery, Watch Together, and the companion remote also
drive. Move the engine and deferTranscodeSubtitleSelection verbatim
into playback_reload.dart so the episode part contains only the
episode feature. No code changes beyond the file split.
2026-08-20 02:16:12 +02:00
edde746 2f47ed23cd chore: dedupe resolution display labels and fold single-use helpers
Two private formatters mapped the canonical server resolution label to
display text (media_quality_labels, media_version) with edge-case
drift, and several single-consumer helpers added indirection without
callers:

- add shared resolutionDisplayLabel in resolution_label.dart and
  migrate MediaVersion.displayLabel and the quality-label builder to it
- drop the resolutionLabelFromHeight compat re-export from
  jellyfin_mappers (no remaining consumers) and its stale doc note
- delete TraktCatalogSource.membershipKeysFor, byte-identical to the
  CatalogWatchlistMachinery default
- derive the rating sheet backend label from
  MediaBrowserDialect.productName instead of a hardcoded switch
- inline EndpointFailoverManager into failover_http_client.dart, its
  only importer, and remove endpoint_failover_interceptor.dart
2026-08-20 02:01:12 +02:00
edde746 b66eec827d fix(trackers): use the shared refresh timeout for MAL token refresh
MAL token refresh waited on the general 20s request timeout while the
Trakt and MDBList refresh paths use the dedicated 15s refresh timeout,
so a hung refresh held the 401 retry longer on MAL than on its
siblings. Align MAL with the shared refreshTimeout.
2026-08-20 01:59:00 +02:00
edde746 df0d71e6f8 fix(auth): let the Plex QR and browser waits cancel back to the sign-in actions
Google Play rejected 2.16.0 (versionCode 142) for an Android Automotive
navigation dead end: on a car, "Sign in with Plex" resolves to the
in-app QR wait, which offered only Retry — and the AAOS system bar has
no back button, so a touch user could not leave the screen without
killing the app. Add a Cancel action to the QR and browser-polling
states that aborts the attempt and returns to the initial sign-in
actions on every platform.

Verified on an API 34 Automotive emulator: Cancel returns to the
landing with the Jellyfin/Emby paths reachable.
2026-08-20 01:11:23 +02:00
BockiandGitHub 088886bfb6 Add Emby as option in issue and feature templates (#2014) 2026-08-20 00:30:57 +02:00
edde746 e0122feb2b fix(release): address the Amazon submission API by app id instead of package name
The App Submission API stopped resolving apps by package name and now
returns 400 "No app found with the entered Inputs"; it requires the
amzn1.devportal.mobileapp app id from the console URL. Read the id from
AMAZON_APPSTORE_APP_ID, validate it in preflight, and keep the package
name for console-facing messages.
2026-08-20 00:29:21 +02:00
github-actions[bot] 08a3a61c2a chore: update cask to 2.16.0 2026-08-19 22:27:04 +00:00
edde746 4cc8b4c0d9 chore: bump version to 2.16.0 2026-08-19 23:42:07 +02:00
edde746 7a4cdc8b5e fix(player): pin loudnorm output to 48 kHz float to stop startup audio stutter
Audio Normalization on the mpv path made every video start with stuttering
audio on Linux: dynamic-mode loudnorm always outputs float64 at 192 kHz, so
the AO opened at f64/192k and PipeWire had to convert/resample on the
deadline-critical path (~4x the per-cycle DSP work) while playback startup
load was still settling. Appending mpv's native format filter pins the
chain back to 48 kHz float, so the conversion happens once on the buffered
decode side and the AO opens a normal stream. Integrated loudness output is
unchanged (-14 LUFS, verified via ebur128). mpv's own format filter is used
instead of lavfi aformat so the fix does not depend on which lavfi filters
each platform's bundled ffmpeg compiles in.

Addresses the normalization half of #1720; the residual startup
micro-stutter with normalization disabled is a separate regression.
2026-08-19 23:25:59 +02:00
edde746 20896a3d48 fix(player): keep the wake-time profile picker responsive on Android TV
Sleeping the device mid-video with "Ask for profile on app open" enabled
left the app wedged on an unresponsive Choose Profile screen (Nvidia
Shield). The picker is pushed on the root navigator, but the player's
focus self-heal only consulted its own nested profile-session route, so
it yanked D-pad focus back behind the picker whenever it lost it.

Route currency now walks every enclosing navigator (isRouteChainCurrent)
before the player reclaims focus, primes loading-phase navigation focus,
or claims the surface on window focus. The resume-time prompt is also
skipped entirely while a video player is active: waking mid-stream
resumes the stream instead of stacking the picker over the session.

close #2034
2026-08-19 23:08:32 +02:00
edde746 fc96ddc1ab style(android): fold initialVideoOutput body onto its signature per ktlint 2026-08-19 23:06:08 +02:00
edde746 4522f42bbf fix(focus): reveal focused items only during keyboard/D-pad sessions
Closing an episode context menu (or its Rate / File Info sheets) on a
show opened from a Home section scrolled the detail page back to the
top, and on shows with many seasons jumped the season selector back to
the entered-from season. The Home path parks invisible focus on the
initial episode/season target; dismissing the menu restored focus to
that parked node, whose focus-gain auto-scroll then yanked the viewport.

Focus chrome is already keyboard-mode-only, so make the focus-gain
reveal match: FocusableWrapper, FocusableChipStateMixin, and
FocusableTileStateMixin now scroll into view only during keyboard/D-pad
sessions. The gate reads the tracker's live state (new
InputModeTracker.currentMode) because the inherited provider is one
frame stale on the first navigation key of a session. The touch OSK
search submit keeps its jump-to-results via an explicit reveal, matching
the existing pointer-mode convention of pairing requestFocus with an
explicit scroll.

close #2031
2026-08-19 22:38:18 +02:00
edde746 a4b88df965 fix(explore): show Seerr request once the detail fetch resolves a tmdb id
Plex Discover's hub, search, and related endpoints ignore includeGuids,
so items opened from those rows carried no tmdb id and the Request
action never appeared — only watchlist items (whose endpoint does
return Guids) could be requested. The gate was frozen in initState from
the row item; it now reads the detail-enriched item, whose metadata
fetch does bring the tmdb id.

close #1959
2026-08-19 20:54:00 +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 cb900c255a fix(trackers): preserve AniList/MAL rewatch status when scrobbling
Finishing an episode of a show set to rewatching stomped the entry back
to watching. The scrobble write now reads the entry's status and rewatch
count in the same request as the episode count, keeps REPEATING /
is_rewatching entries rewatching, starts a rewatch when progress lands on
a completed entry, and bumps the rewatch count when a rewatch completes.

close #2026
2026-08-19 19:34:04 +02:00
edde746 43ab083cda fix(tvos): match Siri Remote swipe physics to measured native focus engine
Swipe navigation felt sluggish next to native tvOS apps: Plezy priced a
focus step at 0.55x the focused item's extent, throttled repeats to 140ms,
and stopped dead on finger lift - the inverse of the native engine's feel.

Retuned AppleTvRemoteTouchService to on-device measurements of the native
focus engine (issue #2006):
- one focus step per fixed 400pt of touch travel; drop item-extent scaling
  (measured step distance was identical across a 230pt and a 345pt axis)
- repeat cadence 140ms -> 60ms (native median)
- fast lifts glide one or two extra steps (>=2000 / >=8000 pt/s) within
  ~130ms of the lift; reversal pivots and new touches cancel the glide
2026-08-19 18:26:30 +02:00
edde746 3fb90a4948 fix(images): fetch full-resolution tile artwork on low-end hardware
Posters and episode thumbnails looked noticeably soft on Fire TV sticks and
similar boxes: the auto-detected reduced tier capped artwork DPR at 1.5 and
tightened tile decode caps, so every tile was fetched at 56% of its pixels
and upscaled twice on large 4K panels.

Drop the reduced-tier DPR cap and the poster/square/thumb decode caps so
tiles fetch and decode at full TV density on every tier; raise the reduced
image-cache budget to the 64MB TV baseline to absorb the larger tiles.
Backdrops keep their scrim-masked ~720p reduced caps, and the display budget
factor stays pinned to 1.0 there, so the low-RAM art ceiling is unchanged.

close #2020
2026-08-19 13:16:36 +02:00
edde746 65713858ea fix(tvos): open the sign-in keyboard after fast server probes instead of freezing remote input
Adding an Emby server on Apple TV locked the app up after entering the
URL: the server probe answered inside the URL keyboard's dismissal
animation, so the username field's system keyboard silently never
presented while its editing session kept ownership of every remote
press — leaving only Menu, which suspends the app. Jellyfin was
unaffected in practice because Quick Connect skips the username focus.

Engine 3.44.0+6 defers first-responder takes until the in-flight
keyboard presentation clears and routes remote presses to the engine
whenever no keyboard is actually on screen, so the session can no
longer strand the user.

close #2011
2026-08-19 12:57:15 +02:00
edde746 744e72d76b fix(player): stop continuous Dolby Direct Play judder via MPVKit 1.0.23
Direct Play of any (E-)AC-3 track on Apple TV developed persistent
micro-stutter: mpv's audio clock was derived from es_pts on the
compressed path, which is credited a whole 32 ms burst the moment a
payload lands, sawtoothing the clock on a ~0.8 s cycle that video
retires as visible skip/hold pairs. AAC content was unaffected, which
is why the symptom tracked Dolby audio and survived every VO-side fix.

MPVKit 1.0.23 measures the clock from IEC samples actually handed to
ao_read_data instead. Verified with an HDMI capture rig against the
CI-built binary: EAC3 display cadence is now statistically identical
to AAC (was 47.6 skips/min plus 453 multi-frame holds over 38 min).

close #1776
2026-08-19 12:57:15 +02:00
edde746 761303de11 fix(subtitles): render zlib-compressed embedded MKV subtitles on the ExoPlayer path
Embedded subtitles on mkvmerge-muxed files with compressed subtitle tracks
(the anime-release convention, reported against AV1 encodes) never rendered:
the track selected fine but nothing ever appeared on screen.

Two defects compounded. ContentCompAlgo 0 (zlib) is the Matroska default
value, so mkvmerge omits the element and the explicit-value detection never
engaged - media3 ignores an empty ContentCompression and silently emits
compressed samples. And for text subtitle tracks TrackOutput-level inflation
can never work: MatroskaExtractor prepends the plaintext timecode prefix to
the still-compressed payload and truncates the sample at the first NUL byte
before any TrackOutput runs, while AssTrackOutput feeds that same internal
buffer to libass, which then parses zero events and reports every frame as a
changed empty render.

Treat the presence of ContentCompression as zlib until an explicit
ContentCompAlgo says otherwise, and for SRT/ASS/SSA/VTT tracks inflate the
block frame payload before the parent's subtitle sample assembly, leaving the
TrackOutput wrapper to the formats it can actually handle. Blocks that are
laced, corrupt, or over-bound pass through byte-identical. The detection fix
also makes the existing whole-sample TrackOutput inflation engage for
PGS/VobSub zlib tracks, which media3 does not truncate.

Verified with mkvmerge zlib/plain fixture extraction tests and end-to-end on
an API 36 emulator against a Jellyfin direct-play mux mirroring the reported
file.

close #2023
close #2022
2026-08-19 12:34:40 +02:00
edde746 4a1111f435 feat(navigation): restyle sidebar as M3 Expressive navigation rail
The custom sidebar read as an ad-hoc widget: collapsed rows were the
expanded layout clipped to the strip, the libraries tree used a
staircase of indents and mixed row heights, and hover expansion pushed
the whole content area sideways.

Rebuild the rail on M3 Expressive geometry: collapsed destinations are
icon-only 56x32 pills centered in an 80px strip (48px icon-only on TV),
expanded rows are fixed-height destinations with a full-width stadium
indicator, and the two layouts crossfade/lerp during the width morph
instead of clipping. The libraries section is flattened into full-width
destination rows under a chevron header at standard destination
metrics, hover/touch expansion overlays content as a rounded, shadowed
panel over a dimmed scrim instead of pushing it, and rail width, item
morphs, content translate, and bleed all share one duration/curve.
2026-08-19 09:11:18 +02:00
edde746 4b6533d25b fix(linux): stop the HDR watchdog from firing after synchronous no-op transactions
On a Wayland HDR session, colors flickered between HDR and washed-out SDR every ~5 seconds: the mpv-leg timeout in apply_hdr_state was armed after SetHdrOutput returned, so a synchronous reply (the no-op short-circuit that runs on every playback restart and re-describe) orphaned the timer. Five seconds later it fired against a healthy plane and withdrew the live HDR image description; the next re-apply re-attached it and armed the next orphan.

Arm the timeout only while the reply is genuinely outstanding. An asynchronous reply still removes it, and a genuinely wedged core is still bounded.

Verified in a headless-sway Docker repro against the same PQ/BT.2020 file: 2.15.0 logs the false 'mpv never answered the output colour-space switch' warning 5 s after plane bring-up and again after source recognition; the fixed build logs none across bring-up, HDR playback, and seeks.

close #2016
2026-08-19 06:07:25 +02:00
edde746 05ef93c55b fix(player): keep gpu-next off the Android hardware-decode path
HEVC files played through the mpv backend on the Nvidia Shield show a
solid blue screen with audio: under hwdec=mediacodec, gpu-next samples
the decoder output as a samplerExternalOES that libplacebo declares in
both shader stages, and the Tegra GLES linker rejects the pair ("struct
type mismatch between shaders for uniform"), failing every frame. The
in-chain gpu fallback never engages because gpu-next initializes fine,
and mpv/libplacebo master reproduce it unchanged.

DV reshaping — the reason gpu-next exists on Android — only happens
under software decode, so offer gpu-next exactly there and keep the
legacy gpu VO for hardware sessions on both the primary mpv backend and
the ExoPlayer fallback core.

close #2010
2026-08-18 23:21:19 +02:00
edde746 8d2eca42d4 feat(search): filter search results by media type
Searching a name shared by a movie, a show, and audio drowns the wanted
item in episode and track rows. The search screen now offers kind chips
(All, Movies, TV Shows, ...) above the results; chips derive from the
pre-rank candidate pool, and selecting one re-ranks that kind's
candidates with the full display budget, so a kind crowded out of the
trimmed All view still surfaces everything the servers returned for it.
Chip switches never refetch, and the filter falls back to All when a
refined query no longer returns its kind.

close #2001
2026-08-18 20:58:35 +02:00
edde746 0f3d06668f fix(jellyfin): read Emby playback shelves from the hide-aware resume route
Removing an item from Continue Watching on Emby posts HideFromResume,
which only the dedicated /Users/{uid}/Items/Resume route honors: the
IsResumable /Items query and the per-series /Shows/NextUp lookups kept
returning hidden rows, so removed items blipped back on every refresh.

Emby's dedicated route conflates both shelves (in-progress items plus
one zero-position next episode per started series), so every playback
surface now reads one hide-aware window from it and splits the rows by
PlaybackPositionTicks: positive rows feed Continue Watching, zero-
position episode rows feed Next Up. The 1+N per-series Next Up
reconstruction is deleted; one recency scan still supplies the play
dates and the NextUpDateCutoff window. Jellyfin request strings are
unchanged.

close #2003
2026-08-18 20:58:35 +02:00
edde746 7131be4cc0 fix(plex): request native-size artwork instead of server-side upscales
Browsing artwork showed PMS PhotoTranscoder log lines flagged
"upscaled: 1" on every request. The flag merely echoes the upscale=1
query param Plezy sent on every cover request (PMS prints it before
locating the source, including on cache hits and pure downscales), but
the param had a real cost: whenever the requested box exceeded the
source — hero art at the 4K display budget (3840x2160 requests against
the near-universal 1920x1080 agent art), portrait backdrops on >1080p
phones — PMS genuinely enlarged the image server-side. Verified against
PMS 1.43: ~2.5x the transcode time and transfer bytes for zero rendered
detail, since the client covers the slot on the GPU regardless and the
fit-policy decode bounds never enlarge.

Send upscale=0 on cover requests too: downscale output is byte-identical
and oversized requests now return the native image. Jellyfin needs no
change — MaxWidth/MaxHeight never enlarge.

close #1975
2026-08-18 19:47:52 +02:00
edde746 436fd7d2d5 fix(desktop): hide the window before exit teardown and keep shutdown out of live UI
Closing the window now runs a graceful exit (so trackers get their
terminal scrobble), but the teardown kept the window on screen for
seconds while screens visibly emptied as servers disconnected.

Hide the window as the first step of exit teardown, bounded so a
stalled platform channel cannot hold an already-accepted exit open, and
cover every graceful-exit entry (window X, Cmd+Q, OS-initiated), not
just the close button. Add a terminal MultiServerManager.shutdown() that
closes the status streams before draining clients: the still-mounted
widget tree must never observe an "all servers gone" snapshot mid-exit,
which would flip the app into offline UI and dismantle screens (and
with it the teardown-driven AppLifecycleListener used-after-dispose
assert). A health result landing after shutdown is a silent no-op.

Supersedes the flag-based approach in #1999.
2026-08-18 19:38:30 +02:00
edde746 c198bab04c fix(tv): keep focus on the played item when continue watching reorders
Exiting the player after enough progress moved the played item to the front of Continue Watching left D-pad focus at its old position, now occupied by a completely different item.

TvBrowseRail and HubSection now remap the focused index to follow the focused item identity when a hub reorders underneath it, falling back to the same series replacement entry when a finished episode is swapped for the next one, and HubSection focus memory is remapped so re-entering the row lands on the same item.

close #1987
2026-08-18 19:27:00 +02:00
edde746 2a6f752860 fix(release): make multi-channel deployment resumable 2026-08-18 18:00:52 +02:00
github-actions[bot] 16c0e00302 chore: update cask to 2.15.0 2026-08-18 15:05:13 +00:00
edde746 6d75e58f52 chore: bump version to 2.15.0 2026-08-18 14:50:24 +02:00
edde746 ad1a12c998 fix(release): reset fresh state and preserve deferred phases 2026-08-18 14:45:32 +02:00
edde746 9fe6dbe9f2 fix(explore): add MDBList watchlist source
Connected MDBList accounts were omitted from Explore, so their watchlists could not be selected or displayed. Register the profile-scoped MDBList client as a catalog source with watchlist mutations and search.

close #1888
2026-08-18 13:13:06 +02:00
edde746 304d8a5e33 fix(plex): keep relay out of the phase-1 race, preferred persistence, and pinned sessions
A relay connection can win the phase-1 race (plex.tv edge answers fast),
gets saved as the preferred endpoint, and the cached URL then wins the
deterministic head-start probe at every bind — pinning future sessions to
relay's 2 Mbps cap even after direct connectivity returns, surfacing as
HTTP 500s on Original quality. A session that lands on relay legitimately
(direct down at connect time) stays there all evening: re-optimization
only fired on connectivity events.

Relay is now a fallback tier in the race: all probes still start at once,
but a relay success is held until every direct candidate has failed, and a
cached relay URL gets no head start. A single persist gate refuses relay
at all four endpoint-save sites, classified against both the live server
and the connect-time capture so rotated relay URIs cannot slip through.
While the active endpoint is relay, a bounded-backoff re-probe (30s/60s/
120s) re-races the candidates so a returning direct endpoint promotes
away without a restart.
2026-08-18 09:23:04 +02:00
edde746 986237491f fix(i18n): fill missing translations and remove unused keys 2026-08-18 08:41:13 +02:00
edde746 35eef09f72 fix(tvos): present video on the host clock instead of the media timebase
Direct Play on Apple TV develops recurring micro-stutter that worsens
the longer playback runs and clears temporarily on pause/play
(issue #1776). Since 2.4 the avfoundation VO has presented frames
against a media-time CMTimebase anchored and rate-servoed to follow
mpv's audio-slaved schedule; clock skew between the two domains can
only be retired by rate slewing or a re-anchor snap that retimes every
queued frame at once. The media timebase exists for PiP, which tvOS
does not have.

Bump MPVKit to 1.0.22 and opt tvOS into its new
avfoundation-presentation=host mode: samples carry mpv's scheduled
display time against a free-running host-clock timebase, so drift is
absorbed per frame inside mpv and no timing state accumulates in the
VO. The queue lead returns to 128ms; 500ms only dampened media-mode
snaps and quadrupled their blast radius. 1.0.22 also paces the
compressed-passthrough feed off the most-advanced playhead reading,
fixing the periodic audio dropouts passthrough users reported on 2.14.
2026-08-18 08:38:46 +02:00
edde746 8461e6ba5f feat(detail): split play button with version picker segment when multiple versions exist
There was no way to tell from the detail screen that a movie or episode
has multiple versions (theatrical/extended cuts, 1080p/4K encodes); the
only entry point was the hidden Play Version item in the overflow menu.

The Play button now becomes a Material 3 Expressive split button when
the item carries more than one version and its server is reachable: the
main segment keeps plain Play (which already resumes the remembered
version), and a narrower chevron segment runs the existing Play Version
flow (version picker, quality picker when the backend can transcode,
preference save). The flow itself is extracted from the context menu
into a shared promptAndPlayVersion helper, and FocusableActionBar gains
per-action spacingBefore so the joined pair can sit tighter than the
rest of the row. Transcode-only quality picking stays in the overflow
menu so the chevron keeps signaling a real version choice.

close #1881
2026-08-18 07:41:54 +02:00
edde746 93d3ec65e2 fix(exoplayer): decode DTS with FFmpeg when the stream cannot bitstream
DTS-HD files play silently on the Onn 4K Plus: DTS decode is license-gated
in its firmware, so c2.amlogic.audio.decoder.dtshd initialises, drains and
advances the playback position while rendering silence. Route DTS-family
decode to the bundled FFmpeg decoder whenever the track will actually be
decoded - passthrough off, downmix, normalization, a failure block, or a
route that cannot bitstream DTS in any shape. Bitstream-capable routes are
untouched: media3 selects direct output before it ever consults the decoder
list, so raw passthrough, the IEC 61937 carrier and the tunneling gate keep
their exact behavior. Kodi ships the same policy as its only configuration:
its MediaCodec audio whitelist is empty and DTS always decodes in FFmpeg.

close #1995
2026-08-18 06:28:28 +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 483691d43a feat(player): surface skip forward/back on the iOS and tvOS lock screen and remote
On iOS and Apple TV the lock screen, Control Center, and iPhone remote card only offered previous/next track for video, so the transport buttons restarted the episode or jumped to the next one instead of skipping a few seconds.

MediaControlsManager suppressed the MPRemoteCommandCenter skip commands on all Darwin platforms because they displace the next/previous buttons — the right call for music, the wrong one for video. The suppression is now a per-surface policy: the video player opts in with preferSkipOverTrackButtons and advertises the in-player small-skip step (seekTimeSmall, default 10s) via setSkipIntervals, while music keeps next/previous as its lock-screen transport. Skip events already routed through MediaControlRouter to _seekRelative on every platform, so the OS-echoed interval drives the actual seek.

close #1994
2026-08-18 04:06:11 +02:00
edde746 b4a84c6a28 fix(profiles): don't re-prompt for profile when a system overlay closes
On Fire TV, invoking Alexa and dismissing its overlay popped the profile
picker over a still-playing session: MainScreen treated every resumed
lifecycle event as an app open and re-applied "ask for profile on open".
The Alexa overlay only produces inactive -> resumed, which the handler
could not tell apart from a real return to the foreground.

Track the deepest lifecycle state since the last resume in a small gate
(ProfileSelectionResumeGate): only a genuine backgrounding (hidden,
paused, or detached) arms the prompt for the next resume. This also
keeps iOS working, where returning from the background arrives as
hidden -> inactive -> resumed.

close #1990
2026-08-18 02:09:28 +02:00
edde746 c5e7ce648b fix(player): gate audio passthrough on the IEC 61937 shapes each backend opens
On Shield Experience 8.x (API 28) ExoPlayer force-decoded TrueHD to
multi-channel PCM, and enabling passthrough on the mpv backend made every
non-AAC file wedge on an infinite buffering spinner with no audio pipeline
at all.

Two defects behind one symptom pair:

- The MAT/DTS-HD carrier oracle stopped at API 29 because
  AudioTrack.isDirectPlaybackSupported does not exist below it, so API 24-28
  never offered the carrier even on routes that genuinely bitstream it. Below
  API 29 the HDMI AudioDeviceInfo is the only vouching signal, so the carrier
  is now offered when an HDMI output explicitly advertises IEC 61937 at
  192kHz/8ch; unspecified (empty) capability arrays deliberately do not
  count, because an unvouched IEC track that initialises without being
  bitstreamed renders as full-scale noise. A route that advertises and still
  refuses the track fails AudioTrack init into the existing force-decode
  recovery.

- The mpv audio-spdif list was gated on the raw encodings the route
  advertises, but mpv's audiotrack AO opens every spdif format as a stereo
  ENCODING_IEC61937 track clamped to the 48kHz mixer rate. E-AC3, TrueHD and
  DTS-HD MA structurally cannot survive that shape, and a route can advertise
  raw encodings while its HAL takes no IEC track at all - naming any codec
  there leaves mpv's audio chain stuck before AO init (reproduced on a Shield:
  spdif_ac3 selected, no AudioTrack ever opened, playback never starts). The
  list is now capped to ac3,dts and additionally gated on the route accepting
  the stereo IEC shape, using the same tiered oracle as the carrier. The
  primary mpv backend previously wrote a hardcoded five-codec list from Dart;
  it now asks the plugin to derive the value from the audio route, like the
  ExoPlayer fallback already did.

Verified on a Shield (API 30) with a full-capability EDID: TrueHD rides the
carrier and survives speed transitions (instrumentation), and an E-AC3 title
that previously wedged under mpv+passthrough now decodes and plays with
audio-spdif=ac3,dts.

close #1991
2026-08-17 23:34:05 +02:00
edde746 7cfd68c491 test(player): match the renamed carrier diagnostic in the mismatch regression
The rate-mismatch regression greps the audio diagnostics for the literal
'MAT/IEC 61937 carrier', but 658469f9 reworded the sink's engage message to
'TrueHD (MAT) via IEC 61937 carrier' when DTS-HD joined the carrier. The test
self-skips on carrier-less CI emulators, so the rot only surfaces on real
hardware, where the fallback behaves correctly and the assertion still fails.
Match the shared 'via IEC 61937 carrier' fragment both codecs emit.
2026-08-17 23:33:35 +02:00
edde746 137f2a0e95 merge: music-library polish and Live TV guide day navigation
Brings in the feedback-driven fixes: user-assigned Plex playlist
posters, square music-grid gutters, grouped artist discography
(albums/singles & EPs/live/compilations for Plex), and the guide
day-jump rework (mounted reloads, immediate day apply, focus
retention).
2026-08-17 23:03:24 +02:00
edde746 1bdeca3b5f fix(libraries): give square music grids an inter-card gutter
Square-card (music) grids had zero delegate spacing - the only
separation was each card's internal 3px padding, and square shapes are
excluded from the full-card spacing path, so playlist and album tiles
sat nearly edge to edge. Square grids now get an 8px cross/main gutter
(automotive keeps its larger spacing); poster, list, and full-card
layouts are unchanged.
2026-08-17 22:44:12 +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 b0798cde9f fix(plex): backfill show logos on continue-watching rows missing them
PMS versions before the ~1.43 hub refresh omit a show's inherited clearLogo
image from episode/season hub rows, so the Discover hero fell back to the
title for TV series while movies (which carry their own logo) and the show's
detail page (which reads the show metadata directly) kept theirs.

Resolve the missing owners' logos in one bulk /library/metadata request
(comma-joined rating keys) and stamp them onto the rows, mirroring the
grandparent lookup Plex Web performs. Best-effort: a failed lookup never
fails the shelf and is retried on the next refresh.
2026-08-17 21:46:12 +02:00
edde746 417f3a5e36 fix(livetv): keep the guide mounted during reloads and apply day picks immediately
The guide replaced itself with a bare spinner on every window change,
unmounting the day-picker anchor and the guide focus node - after one
day or time change the pickers silently no-oped and the remote went
dead. Picking a day also applied nothing by itself: the window only
moved after the second (time-slot) menu, so backing out of it read as
'the day picker does not work'.

The grid now stays mounted during in-session reloads behind a light
loading overlay, a day pick re-anchors the window immediately (the slot
menu is an optional refinement), D-pad SELECT arms the key-up
suppressor before opening the menu, and the day chip uses the existing
'Tomorrow' translation.
2026-08-17 21:36:35 +02:00
edde746 d4d44e6c72 fix(music): show user-assigned Plex playlist poster instead of the auto composite
Plex emits an auto-generated composite mosaic for every playlist, so the
displayImagePath fallback order (composite first) meant a poster the user
assigned in Plex could never appear. Prefer thumbPath and fall back to the
composite; no-op for Jellyfin/Emby, which never set a composite.
2026-08-17 20:59:13 +02:00
edde746 658469f954 feat(player): bitstream DTS-HD MA through the IEC 61937 carrier on Android
Fire TV Stick 4K Max (and other Fire OS devices) advertise ENCODING_DTS_HD on
the HDMI route but only implement the DTS-HD basic profile, so media3's raw
path builds an AudioTrack that drains normally while the receiver hears
silence. The same routes do bitstream the 192kHz/7.1 ENCODING_IEC61937 carrier
TrueHD already rides, so DTS-HD MA access units are now packed into DTS type IV
bursts (a port of FFmpeg's spdif_header_dts4 at the 768kHz HD rate, the same
bytes Kodi's IEC packer produces) and played through that carrier.

The TrueHD MAT carrier sink is generalized into IecCarrierSink with one
IecCarrierPacker per codec. While the carrier route exists DTS-HD is binary -
the carrier or decoded PCM, never the lying raw path; routes without the
carrier keep the pre-carrier raw behavior. Master Audio peaks the carrier
cannot hold strip to the always-fitting core substream for ~60s, exactly as
FFmpeg does, and the packer output is pinned byte-for-byte against FFmpeg
spdif golden fixtures.

close #1988
2026-08-17 20:58:04 +02:00
edde746 efd33b3a4e fix(database): restore api_cache.cached_at read by the playback freshness gate
Main stopped compiling: the v21 column drop treated cached_at as
never-read, but the fresh-cache-first playback metadata gate
(ApiCacheSingleton.getIfFresh, from the playback start-latency fix)
reads it on every Plex and Jellyfin playback start, and a later
cleanup removed the put() stamp the gate depends on.

Reinstate the column, keep the v21 migration to the connections
half only (it never shipped in a release), restore the explicit
put() timestamp - on conflict the upsert only updates the
companion's columns, so without it a refreshed row would keep its
original write time and read as permanently stale - and copy
cached_at through the pinned-metadata rescope statement again. The
v21 migration test now pins that api_cache is left untouched.
2026-08-17 20:47:40 +02:00
edde746 86498547ba fix(plex): burn Live TV DVB subtitles on explicit selection
Plex Live TV lost DVB subtitles when 2.10 moved the live path to HLS
(#1983): the transcode starts with subtitles=none, which drops a
tuner's bitmap subtitle streams from the output entirely - unlike
in-band CEA captions, they are separate elementary streams.

The tune response now surfaces the part's embedded bitmap subtitle
streams as selectable tracks in the player's subtitle menu. Picking one
points the part's server-side selection at that stream and rebuilds the
live transcode with subtitles=burn, preserving the time-shift position.
Off stays the default so tuner captions are never auto-burned (#1590),
and channels without bitmap streams keep the native track list. The
selection survives stream recovery by re-mapping onto the re-tuned
session's streams and resets on channel zap. Jellyfin live sessions
record subtitle selection as intentionally unsupported: their one
negotiated URL has no rebuild to deliver it through.
2026-08-17 20:20:20 +02:00
edde746 5fe1e6dc65 refactor(ui): delete unwired log output, dead notifier filters, and never-passed widget params
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates.
2026-08-17 19:02:25 +02:00
edde746 1df47ed8e1 refactor(watch-together): remove the unconstructable disconnected state and write-only session surface
Participant.lastKnownPosition had zero reads or writes; SessionState.disconnected was never constructed, making isInSession's comparison tautological (now _session != null); the session indicator's onLeaveSession callback had no production passer; enterRoom's bool was awaited and discarded. Music engine: MusicPlayContext.id and sleepTimerEndsAt were write-only, and the coordinator constructor seam was never injected.
2026-08-17 19:02:25 +02:00
edde746 a7527718df refactor(services): delete dead branches, write-only fields, and test-only cache/service surface
plex_client.selectStreams' allParts=false branch, download_manager's both-branches-true conditional and unscoped getAllPinnedMetadata tail, playback_source_resolver.preferOffline, and update_service's unreachable catches had no reachable path; PlaybackSession.result/streamHeaders, PlaybackContext.clientScopeId, FileInfoStreams.audioStream, PrefsRepairOutcome.settingsReset/sessionsSalvaged, and JellyfinLiveSessionTracker.playSessionId were write-only; PlexConnection.directUrl, PlexServer.isOnline plus its parse-and-persist presence field, SafStorageService.createDirectory, FullscreenStateManager.stopMonitoring/dispose, the play_queue_launcher re-export shim, track-selection's constant params, and PlexApiCache.unpinForOffline/isPinnedRatingKey (matching the earlier Jellyfin removal) were dead API.
2026-08-17 19:02:25 +02:00
edde746 7cd5bb64bb refactor(player): remove dead completion-latch channels, unreachable settings states, and unread DV stats
classifyPosition's return enum was discarded by its only production caller (rearm is the side effect that matters); the latch's tolerance/retry knobs were never tuned; _loadAdjacentEpisodes' result was unread by all three callers; countsAsApplied carried a subsumed disjunct and _syncMediaControlsAvailability a stale-generation guard with no suspension point before it; _SettingsView.audioSync/subtitleSync could never be current (both routes early-return into the sync bar); PerformanceStats.dvRpuOutputTooSmall/dvPlaybackReason and ContentStrip.chaptersLoaded were written but never read.
2026-08-17 19:02:24 +02:00
edde746 b38df008ec refactor(screens): delete never-passed knobs, dead enum values, and the unconsumed context-menu onTap
plex_pin_auth_flow's QR knobs, ProfileNameField's navigation params, MusicDetailHeader.wideArtworkSize, and _buildReorderableList's bool were never passed; DownloadType.manage was never constructed; MediaContextMenu.onTap was declared but never invoked — removed with its four card passes (each card's own gesture handlers stay); the pin dialog's obscure chain was always true and _PinKey.label's icon branches unreachable; libraries_screen._isInitialLoad was write-only; LiveTvActionsMixin loses dead findChannel (the generic parameter stays — required by the on-clause, the report was wrong); add_jellyfin's focus-map removal branch ran only against an empty map; the recommended tab's hub-key null fallback was dead behind _ensureHubKeys. media_card also loses its always-false seasonal-rank comparison from the CatalogRankScope removal.
2026-08-17 19:02:24 +02:00
edde746 ceab5a5b59 refactor(providers): remove the unreachable init fallback, unread load results, and dead refresh surface
hidden_libraries' constructor always seeds _initFuture, so ensureInitialized's ?? _initialize() fallback could never run (field now late final); _loadLibrariesInternal returned a bool no caller read; OfflineModeProvider.refresh() had zero call sites; ExploreScreen's Refreshable application was dispatched by nobody (only the discover tab and library tabs are dispatched); the discover null-serverId branch stays — the TV spotlight resolver passes null items during initial load.
2026-08-17 19:02:24 +02:00
edde746 6eb0805f8d refactor(profiles): delete dead client accessors, the write-only focus aspect, and test-only registry API
getServerBoundPlexClient/getClientForLibrary existed only for their own unit tests; MetadataEditAdapter.backend and Plex's unreachable prefKey fallback and music-kind branches had no production path (edit is double-gated on supportsKind); MainScreenFocusScope's focus aspect, isSidebarFocused, and focusContent were write-only; resolveActivePlexIdentity.preferredAccount was never passed; ProfileRegistry.get was unused outside tests — its removal exposed a stale test contract, now pinned correctly: list() deliberately never serves plex_home rows.
2026-08-17 19:02:24 +02:00
edde746 a33c423985 refactor(models): stop parsing wire fields and enum members nothing consumes
Parse-only DTO surface deleted across trackers and catalog models: quality-preset storageKey/fromStorage round-trips (persistence uses EnumPref on .name), PlexHome's placeholder identity fields (id stays — deriveHomeSecret reads it), SeerrUser.email/avatar, SimklSearchResult.endpointType, SimklDetail.type, SimklAllItemsEntry.status/isShow, TraktCatalogEntry.rank, CatalogItem.relevance, and the never-produced CatalogRankScope.favorited/.seasonal members with their label arms. Tracker root: OAuthProxyStart.expiresIn, TrackerSession.scope/isExpired, copyWith slimmed to its one real parameter, TrackerHttpClient.service. TrackerAccountStore.service and the trackerAccountStore() alias are live (persisted-blob validation, callers) and stay.
2026-08-17 19:02:24 +02:00
edde746 1a26b0dd08 refactor(media): delete write-only item fields, dead copyWith/getters, and the unread live-TV activity wrapper
PlexMediaItem.subtitleMode/extraType were mapped from DTOs and never read (subtype stays — the detail screen's trailer picker destructures it); MediaPlaylist.copyWith, PagedMediaListState.mapItems, isMusicContent/isCollection, and MediaHub.copyWith's eight never-passed params had zero call sites; LiveTvActivityResult wrapped reloadGuide results nobody unwrapped (now Future<void>); PlayerLogLevel.none was unreachable — nothing parses or produces it; MediaSourceInfo.getPartId() was an accessor over its own public field.
2026-08-17 19:02:23 +02:00
edde746 4cc065b73a refactor(focus): delete never-selected auto-open behaviors and never-passed focus params
TvTextInputAutoOpenBehavior.onFocus/.onFirstFocus were handled by both TV-keyboard state machines but selected by no caller; FocusableWrapper.longPressDuration duplicated the controller's own 500ms default; FocusableActionBar.mainAxisSize was never passed; FocusableActionBuildState carried focusNode/isFocused/isKeyboardMode that no builder reads (AGENTS.md updated to document the surviving showFocus/animationDuration pair). includeFocusSemantics stays — focusable_media_card passes it.
2026-08-17 19:02:23 +02:00
edde746 071ae8e1c2 refactor(database): drop the never-read connections.isDefault and api_cache.cached_at columns
connections.isDefault was maintained by three write paths (upsert preservation, remove-promotion, setDefault) that no production code ever read back; api_cache.cached_at was documented 'optional future use' and written on every cache store. Both columns are dropped in a v21 migration with row-preservation coverage; the dead write machinery, recordAuthSuccess, updateSyncRuleLastExecuted, getDownloadOwnerCount, and OfflineActionType.fromId (rejection semantics live in the sync service's explicit switch) go with them.

Recovery-image compatibility: committed tvOS snapshots written by older builds still carry isDefault in connections rows, and the restore decoder deliberately rejects rows that do not round-trip exactly. A retired-columns allowlist now strips such keys before the strict check, so pre-retirement images stay restorable — the legacy-plaintext restore test caught this and now pins it. _bindServerStatusListener in main.dart also loses its unused provider param and resolver indirection.
2026-08-17 19:02:23 +02:00
edde746 7658587584 refactor(player): one subscription list, bool play-pause focus request, one desktop click contract, one sync-offset layout
The screen's player stream subscriptions were enumerated by hand in four places (re-wire, rollback, dispose, plus a parallel nulling list) kept in sync only by prose — _cancelPlayerStreamSubscriptions is now the single cancel-and-null authority, living beside the fields. PlayerChromeFocusTarget was a one-value enum with request/consume plumbing, now a bool with identical post-frame consumption timing. The desktop tap and controls-overlay tap duplicated the click-toggles-playback/double-click-fullscreen contract around shared state, now one helper. SyncOffsetControl shipped two layouts of which production only ever rendered compact — the full layout existed solely for tests, which now cover the shipped layout. TrackControlsState's never-non-null onLoadSeekTimes/onSyncOffsetChanged sheet-compat callbacks are gone.
2026-08-17 19:02:23 +02:00
edde746 49fce5ace9 fix(player): keep right-arrow alive on the last control when rotation and screen lock are both visible
_getButtonCount re-implemented every button-visibility condition from build() and had already drifted: the screen-lock button incremented the built index but was never counted, so with rotation lock and screen lock both visible the reported total was one short and arrowRight on the last button silently died. Navigation now bounds against the actual built list, so there is no condition table left to drift; the dead onLoadSeekTimes sheet-compat callback invocation goes with it.
2026-08-17 19:02:22 +02:00
edde746 057ff98ec1 refactor(ui): delete never-passed widget parameters and unreachable surfaces
Grep-verified dead surface across shared widgets: the app-menu system's minWidth/maxWidth/childPadding/alignmentOffset knobs threaded through four layers with no caller ever passing them; FocusableListTile's suppressInitialSelect/hoverColor/textColor/iconColor machinery (~55 lines) reachable by no call site; OptimizedMediaImage.playlist with zero constructions; TvSpotlightBackground's primary-action button that neither caller can render; MediaGridDelegate.createDelegate whose only caller was a test (MediaGridGeometry.resolve is the single delegate composition now); and the CustomAppBar->Config->DesktopTopBar chain collapsed onto DesktopSliverAppBar with its ~11 never-passed passthrough params (snap kept — real callers pass it).
2026-08-17 19:02:22 +02:00
edde746 00a6e056b0 refactor(plex): drop the test-only raw-JSON mapper flavours and title obfuscation branches
PlexMappers advertised a parse-and-map FromJson flavour of every mapper 'for callers that haven't already parsed a DTO' — no such production caller exists; every call site uses the DTO-typed flavour. Tests now parse the DTO and map it, preserving every assertion. The kBlurArtwork JSON-walking branches in the DTO factories (title/summary vowel rotation on the decode hot path) are deleted with the flag's title half.
2026-08-17 19:02:22 +02:00
edde746 78fa2c29e3 refactor(input): compile the disabled diagnostics and screenshot-blur subsystems out of normal builds
TextInputDiagnostics.enabled and kBlurArtwork were mutable/const-false flags nothing ever set, yet the diagnostics side eagerly built log strings on the gamepad and key-simulator hot paths on every event. Both flags are now const bool.fromEnvironment (PLEZY_TEXT_INPUT_DIAGNOSTICS / PLEZY_BLUR_ARTWORK) so the branches const-fold away, every eager interpolation sits behind a guard, and screenshot blurring stays one dart-define away. The vowel-rotation title obfuscation had no consumer need beyond artwork blur and is gone.
2026-08-17 19:02:22 +02:00
edde746 0e1af9faaf refactor(watch-together): flat engine callbacks instead of bundle classes
HostCoordinatorCallbacks and GuestReconcilerCallbacks existed only to be unpacked by the controller through ten forwarding lambdas into its own flat fields. The engines now take individual nullable callbacks; the controller keeps its late-binding lambdas (the provider assigns the public fields after construction), and the test harnesses forward the callbacks they exercise.
2026-08-17 19:02:22 +02:00
edde746 99b0528623 refactor(settings): drop the orphaned mute-toggle helper
SettingsService.resolveMuteToggle had zero production callers — the live mute policy moved to VideoVolumeController.toggleMute long ago, leaving two diverged implementations of the same rule. The two TV-aware pref defaults in the same file now read through the PlatformDetector facade like the rest of the app.
2026-08-17 19:02:22 +02:00
edde746 f5c7b95b02 refactor(tv): read TV detection through the PlatformDetector facade everywhere
TV state had two interchangeable entry points — PlatformDetector.isTV/isAppleTV and the raw TvDetectionService.*Sync accessors — with call sites split arbitrarily between them. App code now goes through the facade; the raw accessors document that they exist for the facade and tests.
2026-08-17 19:02:21 +02:00
edde746 d78a7d925e refactor(trackers): single-client Trakt refresh, distinct Simkl coalescer names
TraktClient's static per-refresh-token coalescer, initiating-vs-joiner branches, session-adoption identity checks, and both updateSession push methods defended a multi-client world production never creates — the one long-lived client is shared via TrackersProvider, and the two throwaway clients never refresh. Refresh now matches MalClient/MdblistClient's instance-coalescer shape. Simkl's _watchlistLoad field shadowed the identically-named coalescer on the mixin it applies (legal today, a trap on any future library merge) — renamed with comments distinguishing the two caches.
2026-08-17 19:02:21 +02:00
edde746 62c25e493e refactor(settings): drop the dormant manager-registration branch and the duplicate Plex add-account error surface
persistAndBindConnection's addToManager/visibleServerId parameters and post-commit manager phase had no production caller (the Jellyfin flow defers runtime pickup to the profile binder) — the function is void now. A failed Plex account add threw a StateError that PlexPinAuthFlow re-rendered as a raw 'Bad state:' string beside the already-rendered inline error; the flow's normal return already stops the spinner, so the inline error is the single surface.
2026-08-17 19:02:21 +02:00
edde746 e543f417ce refactor(profiles): one initial-focus mechanism and one end-session/recover scaffold
The profile picker requested first-tile focus twice (FocusableWrapper.autofocus plus a one-shot post-frame requestFocus for the same node) — autofocus alone remains, verified by the picker's D-pad tests. The end-session/try/catch-resume skeleton was copied across deleteProfile, the Plex sign-out, and connection removal; withEndedProfileSession now owns the pause/recover scaffolding while each flow keeps its own success-path resume decision and error policy.
2026-08-17 19:02:21 +02:00
edde746 4c2f533eef refactor(music): share the playlist play/download/delete flows between the context menu and the detail screens
The audio-playlist play flow, the playlist download flow (including its inline MediaItem synthesis), the delete-with-confirm flow, and the fetch-artist-then-navigate flow each existed twice — once in MediaContextMenu and once in the playlist/album/now-playing screens, with 'Match PlaylistDetailScreen' comments standing in for shared code. They now live in music_navigation.dart and download_utils.dart with per-caller strings preserved; the menu's collection/playlist download handlers fold into one parameterized handler, its duplicated sync/download menu tree is built once, and the generic picker dialogs move to collection_picker_dialog.dart (pure move).
2026-08-17 19:02:21 +02:00
edde746 fb048097ab refactor(livetv): one refresh-gating mixin, one multi-server iteration helper, simpler recordings coalescer
The pause/resume/ticker state machine (three gates, lifecycle observer, TickerMode subscription, timer sync) was triplicated across the What's On, Recordings, and Guide tabs — now LiveTvRefreshMixin, with the guide's drift catch-up layered on its hooks and the lifecycle test asserting the same contract against the shared implementation. Six hand-rolled multi-server loops share forEachLiveTvServer (the channel/favorites loaders deliberately iterate per DVR entry and say so). Recordings' Completer-based load drain became the same whenComplete coalescing shape the screen's channel loader already uses.
2026-08-17 19:02:20 +02:00
edde746 4eb3ab4632 refactor(libraries): inline the one-caller filter/sort loader, drop the dead ItemUpdatable hookup, one grouping sheet
LibraryFilterSortLoader had one call site that constructed it with a constant client resolver and bypassed it for MediaBrowser anyway; the Plex branch now loads both futures inline with types instead of casts. LibrariesScreen's ItemUpdatable application ended in an empty no-op that would have burnt a network fetch had anything ever called it. The chips-bar grouping sheet re-built the browse-options grouping page's chrome by hand; both paths now render the same BottomSheetPageScaffold page.
2026-08-17 19:02:20 +02:00
edde746 a910aa95cb refactor(tvos): publish menu passthrough directly, trim untuned remote-touch knobs
TvosMenuPolicyPublisher batched an idempotent fire-and-forget publish behind a transaction-depth counter that could never exceed one across its three non-nesting call sites — the call sites now publish directly and the pure predicate keeps its test coverage. AppleTvRemoteTouchService exposed 14 injectable knobs of which only six are exercised anywhere (verified per knob); the other eight are private constants or internal construction now.
2026-08-17 19:02:20 +02:00
edde746 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.
2026-08-17 19:02:20 +02:00
edde746 c390e0fb0c refactor(detail): share the season-download filter and the download-retry pipeline
The downloaded-episodes-for-a-season filter+sort was written out three times in media_detail_screen (with drifting show-id expressions, preserved per call site), and the failed/cancelled download branches in the action buttons duplicated the same six-step retry pipeline verbatim. One helper each.
2026-08-17 19:02:20 +02:00
edde746 3665d416c4 refactor(providers): deduped peer disposal, hoisted discover outcome tail, live server-connection getter
CompanionRemoteProvider memoized peer disposals in an Expando even though CompanionRemotePeerService.dispose() already dedups concurrent and repeat calls — the wrapper now only keeps cleanup failures from escaping teardown, and the test fake mirrors the service's idempotent-dispose contract. DiscoverProvider._loadOnce carried the same outcome/generation/boundary tail copy-pasted in three branches, now one local settlePassOutcome. OfflineModeProvider cached _hasServerConnection in five write sites when it always equalled a two-term expression — now a live getter.
2026-08-17 19:02:20 +02:00
edde746 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.
2026-08-17 19:02:20 +02:00
edde746 55382deaf9 refactor(android): drop the dead mpv-command emulation on the ExoPlayer backend
PlayerAndroid.command() emulated four mpv commands ('loadfile', 'seek', 'stop', 'sub-add') that no caller ever sends through the interface, while the commands actually dispatched ('change-list', 'drop-buffers', 'sub-seek', 'screenshot') fell into the default branch and vanished. The override is now a documented no-op, which is what every dispatched command already observed.
2026-08-17 19:02:19 +02:00
edde746 4436efea72 refactor(models): drop parse-only wire fields nothing reads
Four model surfaces parsed, coerced, and re-serialized data with zero readers: PlayQueueResponse's selectedItemIndex/offset/sourceURI/version (version stays as a parse-time validity gate), PlexUserProfile's seven non-track-selection account fields, MediaSubscriptionCreateRequest's hints/params/providers, and the whole CatalogPlayState pipeline (parsed by the Plex catalog source, merged and round-tripped on every CatalogItem, read by nobody).
2026-08-17 19:02:19 +02:00
edde746 8e9c81fe47 refactor(media): plain LocalPlayQueue, trimmed MediaItem factory, one library-content entry point
PlayQueue was a freezed union whose Plex variant was never constructed (server queues flow through PlayQueueResponse), whose pattern getters had zero call sites, and whose backendId was written but never read — it is now a plain four-field LocalPlayQueue. The MediaItem compatibility factory re-listed the entire ~65-field union surface twice while every caller passes at most 22 fields; it now declares exactly that union. fetchLibraryContent had zero production callers (the UI pages through fetchLibraryPagedContent), so the interface method and both implementations are gone, with Jellyfin's drain folded into its paged entry point.
2026-08-17 19:02:19 +02:00
edde746 093c0596f1 refactor(jellyfin): declare shared client internals once, make live-TV negotiation private
Two part mixins re-declared members owned by siblings (five playback methods with full 17-parameter signatures, _safeFetchItemsArray with four lint suppressions), violating jellyfin_client.dart's own 'declared exactly once' contract — they now live on _JellyfinClientInternals, with _safeFetchItemsArray declared as its positional core so no suppressions survive. LiveTvSupport.resolveStreamUrl's only caller anywhere was Jellyfin's own startPlayback, so it is now a private negotiation step and Plex drops its permanent null stub; the negotiation tests observe the same contract through startPlayback and the heartbeat wire. JellyfinApiCache's unpinForOffline/isPinnedItemId (zero callers, kept only for Plex symmetry) are gone, as are MediaSubscriptionCreateRequest's never-populated hints/params/providers expansions in the Plex live-TV serializer.
2026-08-17 19:02:19 +02:00
edde746 908b4a8dac refactor(database): serialize durable mutations through one queue, keep only guarded watch-action mutators
Every identity/pending mutation nested a per-instance SerialFutureQueue inside the static tvOS-recovery queue, so the per-instance layer added no serialization domain (reentrancy is already prevented by the durability Zone check). The unguarded updateSyncAttempt/deleteWatchAction pair had zero production callers — the sync service deliberately uses the revision-guarded variants.

Tests migrated to the IfUnchanged pair by passing the row's current revision.
2026-08-17 19:02:18 +02:00
edde746 8033a308ce refactor(connection): drop dead status/kind enums, share the Plex account build pipeline
Connection.status was write-only health state (only the Jellyfin refresh wrote it, nothing read it — MultiServerManager owns real server status), ConnectionKind duplicated MediaBackend line for line, the token-to-PlexAccountConnection pipeline existed three times (sign-in, dev-token seed, legacy migration) with drifting label/dedup policy, and JellyfinConnectionAuthService.validate/refresh/signOut had no production callers.

Connection.kind is now MediaBackend (persisted 'plex'/'jellyfin'/'emby' strings are identical, no migration); buildPlexAccountConnection in plex_account_setup.dart owns identity resolution with the three callers keeping only their genuine deltas; the test-only auth trio and its jellyfinSignOut timeout constant are gone.
2026-08-17 19:02:18 +02:00
edde746 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
2026-08-17 17:13:26 +02:00
edde746 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.
2026-08-17 13:47:00 +02:00
edde746 0f016e4e13 fix(player): start the playback fetch before player construction
The playback data resolve — the one network request that must land
before open() — was kicked off only after the SharedPreferences load,
the Windows display-mode sync, the music-session teardown in
claimVideo(), Player construction, and (Android/Exo) a native
setLogLevel, so none of that setup overlapped the round trip.

Move the kickoff to immediately after the settings reads; the resolve
depends only on settings and provider lookups, so display sync, player
construction, and the whole mpv property chain now hide behind the
network latency instead of preceding it.

Also remove redundant work elsewhere on the start path: memoize
PlayerAndroid.getHeapSize (asked again on every open for an immutable
device value), skip the ten subtitle-style settings reads on mpv
backends where setSubtitleStyle is a no-op, and stop navigateToVideoPlayer
awaiting SettingsService.getInstance twice per launch — the external-player
read now sits behind the supportsExternalPlayers guard.
2026-08-17 13:46:51 +02:00
edde746 953acedcab fix(player): pipeline mpv http-header commands at open
Every mpv open rebuilt the HTTP header list with one awaited channel
round trip per change-list command — with Plex's 9-13 identity headers
that is ~12 serialized round trips sitting between the playback resolve
landing and loadfile, on every mpv backend.

Dispatch the clr and append commands without awaiting between sends and
await them together: the method channel delivers messages in send order
and the native side executes them in arrival order, so the
clr-before-append contract holds while the latency collapses to one
round trip. Failures still propagate out of open() unswallowed.
2026-08-17 13:46:41 +02:00
edde746 be9197eda6 fix(playback): serve playback metadata from fresh cache instead of refetching
Videos take noticeably long to start. On Plex, tapping Play refetched
/library/metadata/{id} network-first even though the detail screen wrote
a strict superset of that exact payload under the same cache key seconds
earlier. On Jellyfin/Emby, playback start issued a full-detail item GET
before the PlaybackInfo negotiation, and the video controls issued the
same heavy GET again while the stream was opening (#1784) — the most
expensive queries a small home server serves, paid two to three times
per start.

Add ApiCache.getIfFresh over the existing cachedAt column and serve
rows younger than playbackMetadataCacheFreshness (5 min) without a
round trip. Plex guards with the strict stream-detail check
(_plexMetadataHasStreamDetail) so a thin row written by
getPlaybackExtras' lean fetch still goes to the network; Jellyfin's
row has a single full-shape writer, so fetchPlaybackBundle and
fetchPlaybackExtras share the new fetchItemFreshCacheFirst. Any miss,
stale, thin, or malformed row falls through to the unchanged
network-first path, and offline behavior is untouched.
2026-08-17 13:46:33 +02:00
edde746 a2b3377d91 fix(libraries): stop d-pad up snapping collection and playlist grids to top
On TV, pressing UP in the library Collections or Playlists grid snapped
the list back to the top and dropped focus on the tab chips, making long
lists impossible to navigate. Default directional focus traversal scrolls
the found card into view via Scrollable.ensureVisible, whose
outer-scrollable pass routes through the NestedScrollView coordinator and
resets the inner grid position to zero on every UP press.

Give the shared paginated card grid explicit per-card d-pad navigation,
matching the browse tab: managed per-index focus nodes, row/column moves
that request focus directly, first-row UP to the tab bar, and
first-column LEFT to the sidebar. Focus changes now scroll only through
FocusableWrapper's delta-based auto-scroll.

close #1977
2026-08-17 08:58:00 +02:00
edde746 e1b488f860 fix(lifecycle): force-close IOClients stuck in unabortable TCP connects
tvOS logs flood with "HTTP client drain timed out" / "close deferred"
warning pairs after every Plex endpoint race, and each sweep leaks the
losing probes' sockets for the OS connect timeout (~75 s of SYN
retries on Darwin).

A request stuck in TCP connect cannot be aborted: package:http's
IOClient only registers the abort handler once openUrl completes, so
the graceful drain never finishes and ManagedHttpClient defers the
inner close that would have reclaimed the socket.

Fix at both layers: every IOClient now gets an explicit
HttpClient.connectionTimeout matching MediaServerTimeouts.connect, and
ManagedHttpClient gains an opt-in forceCloseOnDrainTimeout escalation
used by all dart:io-backed clients — close(force: true) promptly fails
in-flight requests, unlike the native-callback clients (CupertinoClient)
the deferral exists for.

close #1972
2026-08-17 08:08:45 +02:00
edde746 a79084b0a5 chore(player): link upstream issue androidx/media#3377 in seek workaround 2026-08-17 04:47:06 +02:00
edde746 75704c7a9b test(player): cover tracks-after-clusters MKV seeking on device
Canary asserts stock media3 1.11.0 still reports these files unseekable
and snaps seeks to the start — when a media3 upgrade makes it fail, the
TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired.
The second test drives the production wrapper stack and requires the
seek to hold.
2026-08-17 04:47:06 +02:00
edde746 f2ce587a85 fix(player): seek via per-track cues in MKV files with Tracks after Clusters
media3 1.11.0 builds the Matroska seek map while parsing Cues, which for
files whose Tracks element follows the Clusters is before any track is
known — the map permanently reports unseekable and ExoPlayer coerces
every seek to t=0, snapping playback to the start. Route seeks through
TrackAwareSeekMap's per-track cue lookups using the track IDs observed
on the extractor output.
2026-08-17 04:39:59 +02:00
edde746 947fddf540 fix(ci): resolve the repository root after the symbols script moved
The scripts reorganization moved upload_symbols.dart from scripts/ into
scripts/release/ but kept the single-.parent root resolution, so the
script searched scripts/build and scripts/debug-info and every CI symbol
upload failed with "no symbols found". The sibling checks scripts were
updated to .parent.parent in the same move; match them.
2026-08-17 02:46:36 +02:00
edde746 50a9365be8 fix(linux): keep the Wayland display away from mpv on software GL
On a Wayland session whose compositor hands clients no GPU device
(Cinnamon/Muffin 6.6.3 on Mint 22.3), Mesa falls back to llvmpipe and
libva-wayland's vaInitialize segfaults inside mpv_render_context_create
the moment mpv is given the wl_display - the app silently closes when
playback starts. 2.14 was the first release to hit this because its
bundled libmpv is the first with VAAPI compiled in, and the device init
runs eagerly at render-context creation, so the hardware-decoding
toggle cannot avoid it.

Skip MPV_RENDER_PARAM_WL_DISPLAY when GL_RENDERER names a software
rasterizer: zero-copy Wayland interop does not exist on llvmpipe anyway,
and mpv's hwdec=auto still reaches the GPU through a DRM render node,
which works even on these sessions. Accelerated sessions keep the
zero-copy handoff unchanged. Verified in a container against Muffin and
weston on llvmpipe: the handle is withheld and playback works.
2026-08-17 02:20:43 +02:00
edde746 06ecba5fb5 fix(player): render Dolby Vision via gpu-next on the Android mpv backend
Dolby Vision Profile 5 files play with pink and purple colors on Android
devices without DV support (#1902): vo=gpu cannot apply DV RPU reshaping,
so the raw IPTPQc2 base layer reaches the screen. The shipped libmpv AAR
builds mpv 0.41 with libplacebo, where gpu-next is the upstream default VO
and reshapes DV correctly.

Prefer vo=gpu-next with an explicit vo=gpu fallback on both the primary
mpv backend and the ExoPlayer failure-fallback path, drop the
vd-lavc-film-grain=cpu override so film grain applies on the GPU under
gpu-next, and report current-vo in player stats.

Verified on a Pixel 7 (no DV display or decoder): gpu-next initializes on
Mali-G710/GLES 3.2, SDR hardware decode is unchanged, and a 4K DV stream
decodes to dolbyvision/bt.2020/pq with correct colors under software
decode. Reshaping still needs software decode: FFmpeg 8.0's mediacodec
wrapper exports no DOVI side data, so hardware-decoded DV keeps playing
the base layer untouched.
2026-08-17 01:41:46 +02:00
edde746 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.
2026-08-17 01:41:46 +02:00
edde746 aeb6108654 feat(scripts): add the deploy.py multi-channel release pipeline
One command releases to Play, Amazon, App Store Connect (iOS + tvOS), the GitHub build farm, Microsoft Store, and the GitHub release + cask, with checkpointed resume. Replaces the fastlane release flow; first used for 2.14.0.
2026-08-17 01:40:54 +02:00
edde746 f622ba8efe chore(scripts): group scripts into checks, codegen, maestro and release subdirectories
scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
2026-08-17 01:40:54 +02:00
edde746 e47b4e0a73 fix(macos): fall back to AVFoundation audio when CoreAudio fails to open
On macOS 27 beta, CoreAudio rejects ao_coreaudio's channel-layout setup
with paramErr (-50), and since 2.14.0 pinned ao=coreaudio as the only
output, the failed init left every video playing with no audio and no
selectable audio track. Append avfoundation as the fallback, mirroring
upstream mpv's macOS probe order: every format still opens through the
HAL-backed CoreAudio path when it works, and the fallback only engages
when CoreAudio's init fails outright.

close #1964
2026-08-16 22:40:53 +02:00
edde746 9dd8aa4679 fix(macos): remove the audio passthrough option
Enabling audio passthrough on macOS 2.14.0 silenced every AC3/EAC3/DTS
item: the macOS player now pins ao=coreaudio, where audio-spdif redirects
to coreaudio_exclusive. That needs an IEC61937-capable device Mac setups
essentially never have, and with a restricted ao list mpv has no PCM
fallback, so the failed AO init stalls playback with no audio. The toggle
never delivered real bitstreaming on macOS anyway (2.13 decoded through
AVPlayer), so stop offering and applying it there.
2026-08-16 22:40:53 +02:00
edde746 3f6e4c0716 fix(windows): render black letterbox bars in HDR via newer bundled libmpv
With HDR playback enabled on Windows, letterbox bars rendered dark gray
instead of black on HDR displays, for both SDR and HDR content. The
bundled libmpv (20251228-git-a58dd8a) carries a libplacebo that maps the
gpu-next background clear color through the display's reported black
point in HDR mode; libplacebo ff2799a67 (2026-01-07) fixes it by using
infinite contrast for the background. The pin had already been past the
fix (20260303) but was downgraded to 20251228 when downloads moved to
SourceForge.

Bump to 20260809-git-dd5d17d328 (mpv v0.41.0-920, libplacebo v7.371.0),
verified to contain the fix commit.

close #1965
2026-08-16 22:40:52 +02:00
edde746 49c117db83 fix(music): open the gapless next track ahead of the boundary
Gapless playback still gapped between tracks on network streams: mpv
only opened the armed next track's stream at the moment the current one
ended, so any server whose connect+probe outlasts the audio output's
buffered tail (~0.5s) - remote Plex over TLS, a transcode session
starting up - produced an audible dropout at every transition.

Enable mpv's prefetch-playlist when arming a network track so the open
happens while the current track still plays. Measured on a Pixel 7 the
boundary goes from 60-233ms of inline network work to 9-16ms with no
network activity at all; a failed or superseded prefetch falls back to
the old boundary open. Local fdclose:// arms keep prefetch off: an
early open would consume the fd while playlist-pos still reads 0,
breaking _clearArmedNext's "provably never opened" close proof.

close #1869
2026-08-16 22:40:52 +02:00
github-actions[bot] d77836ef22 chore: update cask to 2.14.0 2026-08-16 16:38:32 +00:00
936 changed files with 70394 additions and 15110 deletions
+2 -2
View File
@@ -3,5 +3,5 @@
!.maestro/jellyfin-demo/
!.maestro/jellyfin-demo/**
!scripts/
!scripts/maestro_fixtures.py
!scripts/maestro_real_jellyfin.py
!scripts/maestro/maestro_fixtures.py
!scripts/maestro/maestro_real_jellyfin.py
+2 -1
View File
@@ -77,11 +77,12 @@ body:
id: media-server
attributes:
label: Media Server
description: Are you using Jellyfin or Plex?
description: Are you using Jellyfin, Plex or Emby?
multiple: true
options:
- Jellyfin
- Plex
- Emby
validations:
required: true
@@ -71,6 +71,7 @@ body:
options:
- Jellyfin
- Plex
- Emby
validations:
required: true
+3 -3
View File
@@ -5,7 +5,7 @@ description: >-
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
in the stable manifest) and `channel: master` would clone master HEAD, whose
engine is not the patched revision install-patched-engine.ps1 asserts
(4c525dac). This file is the only place that pin lives: the tag is fetched so
(5d531788). This file is the only place that pin lives: the tag is fetched so
the SDK reports its own version, then verified against the immutable commit
and against the version Flutter itself reports, so a moved tag fails the job
instead of quietly changing SDKs.
@@ -16,8 +16,8 @@ runs:
- name: Clone Flutter from its immutable commit
shell: pwsh
run: |
$version = "3.44.0"
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
$version = "3.47.1"
$expectedCommit = "6655482ec06e547f90abf8ae7590466f4415978d"
$root = "$env:RUNNER_TEMP\flutter"
git init $root
git -C $root remote add origin https://github.com/flutter/flutter.git
+26 -3
View File
@@ -1,8 +1,13 @@
name: Build
run-name: ${{ inputs.release_tag != '' && format('Release {0}', inputs.release_tag) || format('Build {0}', github.sha) }}
on:
workflow_dispatch:
inputs:
release_tag:
description: Tag for a draft release; omit to build Actions artifacts only
default: ''
type: string
build_android:
description: Build Android
default: true
@@ -26,7 +31,7 @@ on:
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
FLUTTER_VERSION: "3.47.1"
# Shared by every release build command; SENTRY_DIST stays per-platform.
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
@@ -67,7 +72,7 @@ jobs:
persist-credentials: false
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
with:
distribution: "temurin"
java-version: "21"
@@ -860,7 +865,7 @@ jobs:
create-release:
needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }}
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux && inputs.release_tag != '' }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -879,6 +884,21 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
- name: Validate release tag
if: ${{ inputs.release_tag != '' }}
env:
RELEASE_TAG: ${{ inputs.release_tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Release tag must use semantic versioning: $RELEASE_TAG" >&2
exit 1
fi
if [[ "$RELEASE_TAG" != "$VERSION" ]]; then
echo "Release tag $RELEASE_TAG does not match pubspec version $VERSION." >&2
exit 1
fi
- name: Download Android artifacts
if: ${{ inputs.build_android }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -1043,5 +1063,8 @@ jobs:
files: ${{ steps.release-files.outputs.files }}
draft: true
prerelease: false
name: ${{ inputs.release_tag }}
tag_name: ${{ inputs.release_tag }}
target_commitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+8 -7
View File
@@ -20,7 +20,7 @@ on:
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
FLUTTER_VERSION: "3.47.1"
jobs:
analyze:
@@ -62,7 +62,7 @@ jobs:
run: scripts/codegen.sh --check
- name: Verify translation hygiene
run: python3 scripts/clean_translations.py --check --strict
run: python3 scripts/checks/clean_translations.py --check --strict
- name: Verify workflow and script guards
run: bash scripts/ci_guard_checks.sh
@@ -75,10 +75,10 @@ jobs:
xargs -0 -r dart format --output=none --set-exit-if-changed
- name: Verify icon consistency
run: dart run scripts/check_icon_consistency.dart
run: dart run scripts/checks/check_icon_consistency.dart
- name: Analyze code
run: dart run scripts/check_analyzer.dart
run: dart run scripts/checks/check_analyzer.dart
- name: Check for unused code
run: |
@@ -175,7 +175,7 @@ jobs:
persist-credentials: false
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
with:
distribution: "temurin"
java-version: "21"
@@ -244,7 +244,7 @@ jobs:
persist-credentials: false
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
with:
distribution: "temurin"
java-version: "17"
@@ -317,7 +317,8 @@ jobs:
mpv_property_result_contract_test \
hdr_metadata_test \
plane_geometry_test \
video_params_test
video_params_test \
plane_render_executor_test
- name: Run Linux native reliability tests
run: |
+1 -1
View File
@@ -10,7 +10,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/plezy-demo
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
COPY scripts/maestro/maestro_fixtures.py scripts/maestro/maestro_real_jellyfin.py scripts/maestro/prepare_maestro_media.py ./
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
+11 -7
View File
@@ -16,6 +16,10 @@
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
- Test your changes thoroughly
### AI-assisted contributions
AI-assisted pull requests must state in the PR description which model(s) were used (e.g. Claude Sonnet 4.5, GPT-5, Gemini 3 Pro).
### Code Quality Checks
The project includes automated CI checks that run on all pull requests:
@@ -51,22 +55,22 @@ Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Androi
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
```bash
python3 scripts/run_maestro.py basic # Basic user flows
python3 scripts/run_maestro.py catalog # Catalog and music flows
python3 scripts/run_maestro.py media # Codec playback and track selection
python3 scripts/maestro/run_maestro.py basic # Basic user flows
python3 scripts/maestro/run_maestro.py catalog # Catalog and music flows
python3 scripts/maestro/run_maestro.py media # Codec playback and track selection
```
Run one flow with `--flow`:
```bash
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
python3 scripts/maestro/run_maestro.py basic --flow .maestro/flows/04_search.yaml
```
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/maestro/run_maestro_ci.py::GROUPS`. Every top-level
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
independent tests. A manual-only classification must state why the flow cannot run automatically.
@@ -75,7 +79,7 @@ manual target rather than an automatic group. Run them only against the pre-seed
emulator, using the required opt-in:
```bash
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
python3 scripts/maestro/run_maestro_ci.py profile-regressions --disposable-emulator
```
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
@@ -102,7 +106,7 @@ Update a production image only through a reviewed change:
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
with the prior digest and pre-change backup, not by changing the image reference alone.
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
5. Run `python3 scripts/checks/check_container_image_pins.py`, `python3 scripts/checks/test_check_container_image_pins.py`, and
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
a digest is unavailable.
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.13.0"
sha256 "045f632883b3418a509c95170fc7767b619280873a66c8d96aa71e01cd35408d"
version "2.18.0"
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+2 -2
View File
@@ -148,7 +148,7 @@ Package managers:
## Building from Source
### Prerequisites
- Flutter SDK 3.44.0+
- Flutter SDK 3.47.0+
- A Plex account, or a Jellyfin or Emby server with user credentials
### Setup
@@ -190,7 +190,7 @@ scripts/setup_hooks.sh
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
```bash
python3 scripts/run_maestro.py basic
python3 scripts/maestro/run_maestro.py basic
```
## Contributing
+7
View File
@@ -4,6 +4,13 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
+23 -10
View File
@@ -58,12 +58,18 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.0.7"
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
val mpvVersion = "v1.2.2"
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
// Dev-only escape hatch: point the build at a locally built libmpv AAR
// (libmpv-android's build-local.sh output) to test fork patches before a
// release is pinned. The URL + sha256 above stay authoritative otherwise.
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
val media3Version = "1.11.0"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
@@ -76,6 +82,8 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
inputs.property("version", mpvVersion)
inputs.property("sourceUrl", mpvUrl)
inputs.property("sha256", mpvSha256)
inputs.property("localOverride", localMpvAar ?: "")
localMpvAar?.let { inputs.file(it) }
outputs.files(aar, manifest)
doLast {
mpvDir.parentFile.mkdirs()
@@ -83,15 +91,20 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
try {
staging.mkdirs()
val stagedAar = File(staging, mpvAar)
try {
providers.exec {
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
if (localMpvAar != null) {
File(localMpvAar).copyTo(stagedAar, overwrite = true)
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
} else {
try {
providers.exec {
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
}
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
}
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
promoteDirectory(staging, mpvDir)
} finally {
staging.deleteRecursively()
+17
View File
@@ -18,3 +18,20 @@
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
# extractorOutput the same way to wrap LATM tracks.
#
# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
# them in its companion object, so the throw surfaces as ExceptionInInitializerError
# while constructing the extractor — every MKV direct-play, release builds only. Only
# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
# shrinking through the compile-time subclass references.
-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
private androidx.media3.extractor.ExtractorOutput extractorOutput;
private androidx.media3.common.util.ParsableByteArray subtitleSample;
}
@@ -0,0 +1,171 @@
package com.edde746.plezy.exoplayer
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import androidx.test.platform.app.InstrumentationRegistry
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
*
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
* be retired. The second test drives the production wrapper stack and requires the seek to
* actually land.
*/
class MatroskaLateTracksSeekDeviceTest {
private companion object {
const val TAG = "MkvLateTracksSeek"
const val SEEK_TARGET_MS = 500L
const val SETTLE_MS = 2_000L
}
private class Session(
val handler: Handler,
val thread: HandlerThread,
val player: AtomicReference<ExoPlayer?>,
val fixture: File
)
@Test
fun stockMatroskaExtractorSnapsSeeksToStart() {
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
assertFalse(
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
result.seekable
)
assertTrue(
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
)
}
@Test
fun wrappedExtractorKeepsSeekPosition() {
val result = runSeekScenario("wrapped") {
val assHandler = AssHandler()
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
}
assertTrue("wrapped extractor must report the item seekable", result.seekable)
assertTrue(
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
)
}
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
val session = openPaused(label, extractorFactory)
try {
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
// The masked position is the seek target; the snap surfaces once the period resolves the
// seek, so give the load a moment before sampling the settled position.
Thread.sleep(SETTLE_MS)
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
return ScenarioResult(seekable, positionAfterSeekMs)
} finally {
teardown(session)
}
}
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixture = copyFixture(context)
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
val handler = Handler(thread.looper)
val player = AtomicReference<ExoPlayer?>(null)
val ready = CountDownLatch(1)
val failed = AtomicBoolean(false)
handler.post {
val exo = ExoPlayer.Builder(context).build()
player.set(exo)
exo.addListener(
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) ready.countDown()
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "$label player error", error)
failed.set(true)
ready.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
ExtractorsFactory { arrayOf(extractorFactory()) }
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.playWhenReady = false
exo.prepare()
}
val session = Session(handler, thread, player, fixture)
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
teardown(session)
error("$label playback never became ready")
}
return session
}
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
val value = AtomicReference<T>()
val done = CountDownLatch(1)
session.handler.post {
session.player.get()?.let { value.set(read(it)) }
done.countDown()
}
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
return checkNotNull(value.get())
}
private fun teardown(session: Session) {
val done = CountDownLatch(1)
session.handler.post {
session.player.get()?.release()
done.countDown()
}
done.await(10, TimeUnit.SECONDS)
session.thread.quitSafely()
session.thread.join(5_000)
session.fixture.delete()
}
private fun copyFixture(context: android.content.Context): File {
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets
.open("ffmpeg/matroska_tracks_at_end.mkv")
.use { input -> output.outputStream().use { input.copyTo(it) } }
return output
}
}
@@ -107,7 +107,7 @@ class TrueHdSpeedTransitionTest {
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
// therefore the 192kHz carrier rate with no decoder instantiated.
if (carrierEncoding != TrueHdMatPacker.CARRIER_SAMPLE_RATE || decoderBefore != null) {
if (carrierEncoding != IecCarrier.SAMPLE_RATE || decoderBefore != null) {
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
teardown(handler, player, thread, fixture)
return
@@ -137,12 +137,12 @@ class TrueHdSpeedTransitionTest {
assertEquals(
"returning to 1x must put TrueHD back on the carrier",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
rateRestored
)
assertNotEquals(
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
encodingAfter
)
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
@Test
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
if (!supportsTrueHdMatCarrier()) {
if (!supportsIecCarrier(context)) {
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
return
}
@@ -297,13 +297,13 @@ class TrueHdSpeedTransitionTest {
// AudioTrack is ever opened and the rate sequence alone cannot tell the two apart.
assertTrue(
"the carrier must have been entered and then left at runtime, saw: $diagnostics",
diagnostics.any { it.contains("MAT/IEC 61937 carrier") } &&
diagnostics.any { it.contains("via IEC 61937 carrier") } &&
diagnostics.any { it.contains("44.1kHz-family rate its container did not") }
)
assertTrue("a decoder must take the stream over instead of the carrier", decoder != null)
assertNotEquals(
"the stream must not still be riding the carrier",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
rate
)
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
@@ -32,6 +32,7 @@ import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.ThemeHelper
import com.edde746.plezy.watchnext.WatchNextPlugin
import io.flutter.embedding.android.FlutterActivity
@@ -735,6 +736,7 @@ class MainActivity : FlutterActivity() {
"getTvDetection" -> result.success(getAndroidTvDetection())
"getDeviceName" -> result.success(getDeviceName())
"getPerformanceSignals" -> result.success(getPerformanceSignals())
"getVideoDecodeCapabilities" -> result.success(MediaCodecQuery.hardwareVideoDecodeSupport())
"getBackgroundWorkSignals" -> result.success(
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
)
@@ -882,7 +884,8 @@ class MainActivity : FlutterActivity() {
} catch (e: IllegalStateException) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
} catch (e: Exception) {
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error")))
Log.w(TAG, "Failed to enter PiP", e)
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to e.message))
}
}
"setAutoPipReady" -> {
@@ -1,6 +1,7 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
@@ -30,6 +31,38 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
/**
* The DTS-family mimes the bundled FFmpeg decoder claims (`FfmpegLibrary` maps both to `dca`).
*
* DTS Express (`audio/vnd.dts.hd;profile=lbr`) and DTS:X (`audio/vnd.dts.uhd`) are deliberately
* excluded: FFmpeg does not claim them, so hiding their platform decoders would leave those
* streams with no decoder at all.
*/
internal fun isFfmpegDtsMimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_DTS || mimeType == MimeTypes.AUDIO_DTS_HD
/**
* Whether a DTS stream should decode in the app's FFmpeg decoder instead of a platform
* MediaCodec decoder.
*
* Platform DTS decoders cannot be trusted with decode. On Amlogic-based Google TV boxes (the
* Onn family) DTS decode is license-gated in firmware, so `c2.amlogic.audio.decoder.dtshd`
* initialises, drains and advances the playback position while rendering silence — and collapses
* 5.1 to stereo where it does produce sound (#1995). FFmpeg decodes the whole `dca` family to
* full multichannel PCM everywhere, which is what mpv and Kodi ship on the same hardware.
*
* Scoped to streams that are actually going to decode: [directOutputBlocked] (passthrough off,
* downmix, normalization, the AudioTrack-failure blocklist) or a route that cannot bitstream DTS
* in any shape ([routeCanBitstreamDts] false — the Android TV default leaves passthrough on, so
* the setting alone cannot identify the decode path). Bitstream-capable routes are left exactly
* alone: media3 selects direct output before it ever consults the decoder list, and leaving the
* platform decoder visible there keeps the hardware-decoder tunneling gate unchanged.
*/
internal fun shouldForceFfmpegDtsDecode(
mimeType: String,
directOutputBlocked: () -> Boolean,
routeCanBitstreamDts: () -> Boolean
): Boolean = isFfmpegDtsMimeType(mimeType) && (directOutputBlocked() || !routeCanBitstreamDts())
/**
* Linear PCM output encodings, i.e. the sink decoded the bitstream instead of
* passing it through. Mirrors the platform's `AudioFormat.ENCODING_PCM_*` set.
@@ -43,36 +76,88 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
else -> false
}
/**
* mpv `audio-spdif` codec names and the exact platform encoding a route must
* advertise to carry that bitstream.
*/
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
"ac3" to C.ENCODING_AC3,
"eac3" to C.ENCODING_E_AC3,
"dts" to C.ENCODING_DTS,
"dts-hd" to C.ENCODING_DTS_HD,
"truehd" to C.ENCODING_DOLBY_TRUEHD
/** The IEC 61937 track shape a codec's spdif burst rides. */
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
private class MpvSpdifCodec(
val name: String,
val encoding: Int,
val shape: MpvIecShape,
/** Whether the fork's audiotrack AO opens this codec as a raw bitstream track first. */
val raw: Boolean = false
)
/**
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
* bitstream, and the track shape the fork's `ao_audiotrack` opens for it.
*
* Since the raw-passthrough patch (0102), the AO feeds AC3, E-AC3 and the DTS core to a raw
* `ENCODING_AC3`/`E_AC3`/`DTS` track at 48kHz/stereo — the transport ExoPlayer and Kodi use —
* and only falls back to the IEC 61937 carrier when the route rejects the raw encoding
* outright. Raw tracks keep the platform's Dolby/DTS transcoder in the path; a pre-packed IEC
* track bypasses it and drains into silence on routes whose sink cannot decode the codec
* itself (#2177's Shield in front of a Dolby-Digital-only Sonos). TrueHD (MAT) and DTS-HD MA
* stay on the 8-channel 192kHz IEC carrier the multichannel patch (0101) added: their raw
* forms are not recoverable from the burst stream.
*
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
* still chosen per file for tracks that are not HD (`ad_spdif.c:240-249`, `:400-418`). HRA rides
* a 2ch/192kHz burst under the same name, so gating it on the 8-channel carrier is the
* conservative choice.
*/
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K, raw = true),
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K, raw = true),
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K, raw = true),
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
)
/**
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
* advertises the codec's encoding *and* a transport the AO's ladder can open exists —
* [supportsRawTrack] for the raw bitstream track it tries first, or [supportsShape] for the
* IEC 61937 burst it falls back to. Plain `dts` is dropped whenever `dts-hd` qualifies, which
* already covers the core burst.
*
* mpv force-passes through every codec named here and has no decode fallback, so an
* unsupported name leaves the file rendering video against a dead audio output (#1703).
*
* The gate is the exact encoding rather than media3's passthrough probe on purpose.
* That probe answers DTS-HD by downgrading to the DTS core (and E-AC3 JOC to E-AC3)
* for receivers that decode only the base layer, and it also rejects channel counts
* above the route's PCM maximum, which does not apply to an IEC 61937 carrier. mpv
* additionally treats `dts,dts-hd` as `dts-hd` alone, so accepting the downgrade would
* name DTS-HD MA to a core-only receiver and lose DTS as well.
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
* channel counts above the route's PCM maximum, neither of which describes what a
* passthrough track carries.
*/
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
.filter { (_, encoding) -> supportsEncoding(encoding) }
.joinToString(",") { (codec, _) -> codec }
internal fun mpvSpdifCodecs(
supportsEncoding: (Int) -> Boolean,
supportsShape: (MpvIecShape) -> Boolean,
supportsRawTrack: (Int) -> Boolean = { false }
): String {
val carried = MPV_SPDIF_CODECS.filter {
supportsEncoding(it.encoding) &&
((it.raw && supportsRawTrack(it.encoding)) || supportsShape(it.shape))
}
val dtsHd = carried.any { it.name == "dts-hd" }
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
}
/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */
/**
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
*
* Two conditions per codec, both required:
* - The route must accept a track shape the AO's ladder actually opens. For AC3, E-AC3 and the
* DTS core that is the raw bitstream track probed by [supportsMpvRawTrack], with the IEC
* stereo shapes ([supportsMpvIecShape], [supportsMpvHighRateIecShape]) as the AO's fallback
* transport; TrueHD and DTS-HD MA need the 192kHz/7.1 carrier ([supportsIecCarrier]).
* Advertising the raw encoding only says the receiver decodes it, not that the HAL takes the
* track: #1991's Shield strands playback on every mpv IEC attempt while bitstreaming AC3 raw.
* The probes are independent, so none of them may veto the whole list: a route that takes the
* 192kHz carrier but no raw track still bitstreams TrueHD and DTS-HD MA.
* - The receiver must decode the codec itself — the raw encoding on the current
* [AudioCapabilities] — because passthrough is transport, not transcoding. (On a raw track
* the platform may additionally transcode, which is exactly why the AO prefers it.)
*/
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
@Suppress("DEPRECATION")
@@ -88,11 +173,93 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
return ""
}
return mpvSpdifCodecs(capabilities::supportsEncoding)
// Every probe costs real route calls and may be shared by more than one codec, so probe once.
val shapeProbed = HashMap<MpvIecShape, Boolean>(3)
val rawProbed = HashMap<Int, Boolean>(3)
val codecs = mpvSpdifCodecs(
capabilities::supportsEncoding,
{ shape -> shapeProbed.getOrPut(shape) { routeTakesIecShape(context, shape) } },
{ encoding -> rawProbed.getOrPut(encoding) { supportsMpvRawTrack(context, encoding) } }
)
if (codecs.isEmpty()) {
Log.i(TAG, "Route takes no passthrough track mpv can fill; mpv will decode instead of bitstreaming")
} else {
Log.i(TAG, "mpv will bitstream: $codecs")
}
return codecs
}
private fun routeTakesIecShape(context: Context, shape: MpvIecShape): Boolean = when (shape) {
MpvIecShape.STEREO_48K -> supportsMpvIecShape(context)
MpvIecShape.STEREO_192K -> supportsMpvHighRateIecShape(context)
MpvIecShape.SURROUND_192K -> supportsIecCarrier(context)
}
/** The track shape mpv's audiotrack AO opens for an AC3 or DTS-core burst: stereo at the mixer rate. */
private const val MPV_IEC_SAMPLE_RATE = 48_000
private const val MPV_IEC_CHANNEL_COUNT = 2
private const val MPV_IEC_HIGH_SAMPLE_RATE = 192_000
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
)
/**
* Whether this route can carry TrueHD as MAT inside IEC 61937 (#1804).
* Whether the route takes a raw bitstream `AudioTrack` for [encoding] at the shape the fork's
* `ao_audiotrack` opens first: the codec frame rate with a stereo mask (Kodi's raw shape; the
* HAL reads the real channel layout from the bitstream). Same probe tiering as the IEC shapes,
* except below API 29, where no runtime oracle exists for raw tracks and media3 gates its raw
* path on the advertised encoding alone — which [supportedMpvSpdifCodecs] already requires via
* [AudioCapabilities]. #1991's API 28 Shield bitstreams AC3 exactly this way.
*/
internal fun supportsMpvRawTrack(context: Context, encoding: Int): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeDirectBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, encoding) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
hdmiRouteAdvertised = { true }
)
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_HIGH_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
)
/**
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
*
* This is Kodi's test, and deliberately not media3's. Kodi asks the AudioTrack layer whether it can
* size a buffer for one exact tuple — `getMinBufferSize(rate, mask, encoding) > 0` — and gates the
@@ -100,8 +267,8 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
* layer about the encoding, which on the boxes measured for this issue answers "TrueHD is
* offload-capable" and says nothing about whether a raw TrueHD track will ever drain.
*
* Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle
* proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
* Both are consulted: `getMinBufferSize` proves a track can be built, and a route oracle proves
* the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
* not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to
* initialise.
*
@@ -112,72 +279,105 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
* for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way
* and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails
* AudioTrack initialisation, which the audio recovery path answers by force-decoding.
* - Below API 29 there is no oracle at all, so the carrier is not offered and TrueHD decodes as
* before.
* - API 24–28: no runtime oracle exists, so the HDMI `AudioDeviceInfo` must explicitly advertise
* IEC 61937 at 192kHz/8ch. Shield Experience 8.x is API 28, and the previous flat `false` on
* this tier force-decoded TrueHD on routes that genuinely carry it (#1991). A route that
* advertises and still refuses the track fails AudioTrack initialisation into the same
* recovery path as the tier above.
*/
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeCarrierBuffer = {
try {
AudioTrack.getMinBufferSize(
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
AudioFormat.ENCODING_IEC61937
) > 0
} catch (error: Exception) {
false
}
},
// The SDK_INT guards repeat trueHdMatCarrierSupported's tiering only because lint's NewApi
canSizeBuffer = { canSizeIecBuffer(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && iecCarrierDirectPlaybackSupported()
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
},
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) }
)
/**
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
* API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
* [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32.
* [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only
* consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`)
* on 33+, [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32, and
* [hdmiRouteAdvertised] (explicit HDMI `AudioDeviceInfo` advertisement) on 24–28 (#1991).
*/
internal fun trueHdMatCarrierSupported(
internal fun iecRouteSupported(
sdkInt: Int,
canSizeCarrierBuffer: () -> Boolean,
canSizeBuffer: () -> Boolean,
bitstreamSupported: () -> Boolean,
directPlaybackSupported: () -> Boolean
directPlaybackSupported: () -> Boolean,
hdmiRouteAdvertised: () -> Boolean
): Boolean = when {
sdkInt < Build.VERSION_CODES.Q -> false
!canSizeCarrierBuffer() -> false
// ENCODING_IEC61937 itself only exists from API 24.
sdkInt < Build.VERSION_CODES.N -> false
!canSizeBuffer() -> false
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
else -> directPlaybackSupported()
sdkInt >= Build.VERSION_CODES.Q -> directPlaybackSupported()
else -> hdmiRouteAdvertised()
}
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
} catch (error: Exception) {
false
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun iecCarrierBitstreamSupported(): Boolean = try {
val support = AudioManager.getDirectPlaybackSupport(iecCarrierProbeFormat(), movieAudioAttributes())
private fun iecBitstreamSupported(format: AudioFormat): Boolean = try {
val support = AudioManager.getDirectPlaybackSupport(format, movieAudioAttributes())
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
false
}
@RequiresApi(Build.VERSION_CODES.Q)
@Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses.
private fun iecCarrierDirectPlaybackSupported(): Boolean = try {
AudioTrack.isDirectPlaybackSupported(iecCarrierProbeFormat(), movieAudioAttributes())
private fun iecDirectPlaybackSupported(format: AudioFormat): Boolean = try {
AudioTrack.isDirectPlaybackSupported(format, movieAudioAttributes())
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
false
}
/** The exact tuple the carrier's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
.setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
/**
* Whether an HDMI output *explicitly* advertises IEC 61937 at [sampleRate]/[channelCount] —
* the only oracle below API 29 (#1991).
*
* Empty `AudioDeviceInfo` capability arrays mean "unspecified" and deliberately fail this
* check: an unvouched IEC track that initialises on a route that then renders it as PCM plays
* the carrier as full-scale noise, which the AudioTrack-init recovery path cannot catch.
*/
private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCount: Int): Boolean = try {
val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device ->
(device.type == AudioDeviceInfo.TYPE_HDMI || device.type == AudioDeviceInfo.TYPE_HDMI_ARC) &&
device.encodings.contains(AudioFormat.ENCODING_IEC61937) &&
device.sampleRates.contains(sampleRate) &&
device.channelCounts.contains(channelCount)
}
} catch (error: Exception) {
Log.w(TAG, "HDMI route inspection failed; not offering IEC 61937 output", error)
false
}
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat =
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
.setEncoding(encoding)
.setChannelMask(channelMask)
.setSampleRate(sampleRate)
.build()
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
@@ -8,14 +8,21 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.SeekPoint
import androidx.media3.extractor.TrackAwareSeekMap
import androidx.media3.extractor.TrackOutput
import java.util.concurrent.CopyOnWriteArrayList
/**
* Extractor wrapper that enables approximate seeking for MKV files without Cues.
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
*
* When the underlying extractor reports an [SeekMap.Unseekable] seek map (i.e. the MKV
* has no Cues element), this wrapper replaces it with a proportional byte-position
* estimate and scans for the nearest Cluster boundary after seeking.
* Two repairs, in priority order:
* - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a
* proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking.
* - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska
* seek map while parsing Cues, which for files whose Tracks element follows the Clusters is
* before any track is known — the primary seek track stays unset even though the Cues parsed):
* wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on
* this output.
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
@@ -38,6 +45,9 @@ class CuelessSeekExtractorWrapper(
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
/** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */
private val registeredTracks = CopyOnWriteArrayList<Pair<Int, Int>>()
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
@@ -125,7 +135,13 @@ class CuelessSeekExtractorWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun track(id: Int, type: Int): TrackOutput {
if (registeredTracks.none { it.first == id }) {
registeredTracks.add(id to type)
}
return delegate.track(id, type)
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
@@ -137,6 +153,11 @@ class CuelessSeekExtractorWrapper(
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
} else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) {
Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking")
isApproximateSeeking = false
delegate.seekMap(TrackCueSeekMap(seekMap))
return
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
@@ -166,4 +187,40 @@ class CuelessSeekExtractorWrapper(
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
}
/**
* Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports
* unseekable overall. The per-track queries read the live cue data (populated once the Cues
* element parsed), so they resolve correctly even when the map was constructed before the
* Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377).
*/
private inner class TrackCueSeekMap(
private val delegate: TrackAwareSeekMap
) : SeekMap {
override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null
override fun getDurationUs(): Long = delegate.durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
if (delegate.isSeekable) return delegate.getSeekPoints(timeUs)
val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs)
return delegate.getSeekPoints(timeUs, trackId)
}
/** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */
private fun seekableTrackId(): Int? {
var audio: Int? = null
var fallback: Int? = null
for ((id, type) in registeredTracks) {
if (!delegate.isSeekable(id)) continue
when (type) {
C.TRACK_TYPE_VIDEO -> return id
C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id
else -> if (fallback == null) fallback = id
}
}
return audio ?: fallback
}
}
}
@@ -124,6 +124,17 @@ object DoviBridge {
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
}
/**
* Whether this device can natively render single-layer Dolby Vision
* Profile 5 (IPT-PQ-c2): it needs both a decoder advertising DvheStn and a
* Dolby Vision display pipeline. P5 has no compatible base layer, so a
* device that fails either check decodes it as plain HEVC with garbage
* colors; callers route those sessions to software decode + gpu-next,
* where libplacebo applies the RPU reshaping instead.
*/
fun canPlayDolbyVisionP5(context: Context): Boolean = deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn) &&
displaySupportsDolbyVision(context)
fun displaySupportsDolbyVision(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
@@ -145,6 +156,17 @@ object DoviBridge {
return supported
}
/** Whether the active display advertises any HDR output type. */
fun displaySupportsHdr(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val display = getCurrentDisplay(context) ?: return false
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
Log.w(TAG, "Display HDR support: failed to query HDR types", error)
return false
}
return hdrTypes.isNotEmpty()
}
fun describeDisplayHdrCapabilities(context: Context): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
@@ -0,0 +1,308 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* Packs DTS-HD (Master Audio) access units into IEC 61937 "DTS type IV" bursts (#1988).
*
* Some HDMI routes advertise `ENCODING_DTS_HD` but cannot actually carry Master Audio: Amazon
* specifies the Fire TV Stick 4K Max as "DTS-HD passthrough, basic profile", and Android has one
* ambiguous encoding constant for both profiles until API 34. Handing media3's raw path full MA
* frames there initialises an AudioTrack that drains normally and renders silence. The same
* devices do bitstream the 192kHz/7.1 `ENCODING_IEC61937` carrier — the split Kodi models with
* its "AudioTrack (IEC)" sink, and the one TrueHD already rides here (#1804, #1863) — so DTS-HD
* is packed onto that carrier instead.
*
* The algorithm is a port of FFmpeg's `spdif_header_dts`/`spdif_header_dts4`
* (libavformat/spdifenc.c, n8.1) at `dtshd_rate=768000`, the rate that fills the 8-channel/192kHz
* carrier; Kodi's `CAEBitstreamPacker::PackDTSHD` produces the same bytes.
*
* Output is one complete IEC 61937 burst per access unit:
*
* - 8-byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x11|subtype<<8 Pd=aligned bytes`
* - 10-byte DTS-HD start code, 16-bit big-endian payload size, then the access unit, all 16-bit
* byte-swapped
* - zero padding to the burst repetition period the core frame duration maps to
* (512 samples at 48kHz — the shape of essentially all Master Audio — is 32768 bytes)
*
* Unlike MAT's fixed frames, a Master Audio peak can genuinely exceed the carrier. FFmpeg answers
* by stripping such units to the always-fitting core substream and holding that for
* `dtshd_fallback_time` (60s) so receivers do not flap between core and MA decoding; that
* behavior is ported as-is and pinned by the golden fixture.
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class DtsHdIecPacker : IecCarrierPacker {
internal companion object {
private const val BURST_HEADER_SIZE = 8
private const val SYNCWORD1 = 0xF872
private const val SYNCWORD2 = 0x4E1F
private const val IEC61937_DTSHD = 0x11
/** Core and extension-substream sync words, big-endian raw framing. */
private const val SYNC_CORE = 0x7FFE8001
private const val SYNC_EXSS = 0x64582025
/**
* Little-endian and 14-bit core framings, from S/PDIF and DTS-in-WAV captures. They have no
* period mapping here (FFmpeg's HD path refuses them too), so they latch the stream
* unsupported and it decodes.
*/
private const val SYNC_CORE_LE = 0xFE7F0180.toInt()
private const val SYNC_CORE_14B_BE = 0x1FFFE800
private const val SYNC_CORE_14B_LE = 0xFF1F00E8.toInt()
/**
* The carrier's IEC 60958 frame rate as FFmpeg's two-channel model counts it: the burst
* repetition period is `period = 768000 * coreSamples / coreRate` IEC 60958 frames of 4 bytes,
* which is the same byte rate as [IecCarrier]'s 8 channels at 192kHz.
*/
private const val CARRIER_IEC958_RATE = 768_000
/** Core SFREQ index to Hz (`ff_dca_sample_rates`); zero marks invalid indices. */
private val CORE_SAMPLE_RATES = intArrayOf(
0, 8000, 16000, 32000, 0, 0, 11025, 22050, 44100, 0, 0, 12000, 24000, 48000, 96000, 192000
)
/** Precedes every burst payload; `spdifenc.c`'s `dtshd_start_code`. */
private val START_CODE = byteArrayOf(0x01, 0, 0, 0, 0, 0, 0, 0, 0xFE.toByte(), 0xFE.toByte())
/** Seconds of core-only output after an overflow; FFmpeg's `dtshd_fallback_time` default. */
private const val HD_STRIP_SECONDS = 60
/** Minimum bytes needed to read a core header through its SFREQ field. */
private const val MIN_CORE_HEADER_LENGTH = 9
/** IEC 61937-11 subtype for a burst repetition period in IEC 60958 frames, or -1. */
private fun subtypeForPeriod(period: Long): Int = when (period) {
512L -> 0
1024L -> 1
2048L -> 2
4096L -> 3
8192L -> 4
16384L -> 5
else -> -1
}
private fun readSyncWord(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 24) or
((data[offset + 1].toInt() and 0xFF) shl 16) or
((data[offset + 2].toInt() and 0xFF) shl 8) or
(data[offset + 3].toInt() and 0xFF)
/** Reads [count] (≤ 24) bits big-endian starting at absolute bit [bitPosition]. */
private fun readBits(data: ByteArray, bitPosition: Int, count: Int): Int {
var result = 0
var position = bitPosition
var remaining = count
while (remaining > 0) {
val byte = data[position ushr 3].toInt() and 0xFF
val bitsLeftInByte = 8 - (position and 7)
val take = minOf(bitsLeftInByte, remaining)
result = (result shl take) or ((byte shr (bitsLeftInByte - take)) and ((1 shl take) - 1))
position += take
remaining -= take
}
return result
}
/** `NBLKS + 1`: PCM blocks of 32 samples in the core frame at [offset]. */
private fun coreBlocks(data: ByteArray, offset: Int): Int {
val word = ((data[offset + 4].toInt() and 0xFF) shl 8) or (data[offset + 5].toInt() and 0xFF)
return ((word shr 2) and 0x7F) + 1
}
/** `FSIZE + 1`: the core frame's byte length. */
private fun coreFrameSize(data: ByteArray, offset: Int): Int {
val bits = ((data[offset + 5].toInt() and 0xFF) shl 16) or
((data[offset + 6].toInt() and 0xFF) shl 8) or
(data[offset + 7].toInt() and 0xFF)
return ((bits shr 4) and 0x3FFF) + 1
}
/** The core `SFREQ` field mapped to Hz; 0 for reserved indices. */
private fun coreSampleRate(data: ByteArray, offset: Int): Int = CORE_SAMPLE_RATES[((data[offset + 8].toInt() and 0xFF) shr 2) and 0x0F]
/**
* Total byte length of the extension substream at [offset] (`nuBits4ExSSFsize + 1`), or 0
* when the header does not fit in [limit].
*
* Header layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
* bHeaderSizeType(1), then a header-size field of 8 or 12 bits and this size field of 16 or
* 20 bits — at most 75 bits, so 10 bytes cover every shape.
*/
private fun extensionSubstreamSize(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 10 > limit) return 0
val base = offset shl 3
val wide = readBits(data, base + 42, 1) == 1
val sizeFieldPosition = base + 43 + if (wide) 12 else 8
return readBits(data, sizeFieldPosition, if (wide) 20 else 16) + 1
}
}
/** Burst geometry learned from the stream's core framing; zero until the first unit packs. */
private var burstBytes = 0
private var burstBacking = Array(2) { ByteArray(0) }
private var burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]) }
private var burstIndex = 0
/** Bytes each reusable buffer was dirtied to, so padding only clears what a prior burst wrote. */
private val dirtyEnd = intArrayOf(0, 0)
/** Assembles start code + size + access unit before the byte swap; sized with the bursts. */
private var payloadScratch = ByteArray(0)
/** Access units still to strip to their core after an overflow. */
private var hdStripRemaining = 0
/**
* Latched when a burst had to be stripped to the core substream, so the sink can log the
* downgrade once. Cleared by [reset].
*/
var strippedToCore = false
private set
override var unsupportedStream = false
private set
override fun reset() {
// Buffers and the learned burst geometry survive; only per-stream state drops. The flag is
// re-learned from the next unit; leaving it latched would silence every later stream.
hdStripRemaining = 0
strippedToCore = false
unsupportedStream = false
}
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 4 > limit) return 0
when (readSyncWord(data, offset)) {
SYNC_CORE -> Unit
SYNC_EXSS -> {
// A stray HD frame without its core, seen at stream starts. Its size is walkable, so it
// is consumed as a unit and dropped by packAccessUnit; FFmpeg discards these too.
val size = extensionSubstreamSize(data, offset, limit)
return if (size < 4 || offset + size > limit) 0 else size
}
SYNC_CORE_LE, SYNC_CORE_14B_BE, SYNC_CORE_14B_LE -> {
unsupportedStream = true
return 0
}
else -> return 0
}
if (offset + MIN_CORE_HEADER_LENGTH > limit) return 0
val coreSize = coreFrameSize(data, offset)
var end = offset + coreSize
if (coreSize < MIN_CORE_HEADER_LENGTH || end > limit) return 0
// Master Audio glues one or more extension substreams to the core; they belong to this unit.
while (end + 4 <= limit && readSyncWord(data, end) == SYNC_EXSS) {
val size = extensionSubstreamSize(data, end, limit)
if (size < 4 || end + size > limit) return 0
end += size
}
return end - offset
}
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
if (length < MIN_CORE_HEADER_LENGTH) return null
when (readSyncWord(data, offset)) {
SYNC_CORE -> Unit
// The stray leading HD frame accessUnitLength admitted; there is no core to derive a
// period from, so it is dropped rather than carried.
SYNC_EXSS -> return null
else -> return null
}
val blocks = coreBlocks(data, offset)
val coreSize = coreFrameSize(data, offset)
val sampleRate = coreSampleRate(data, offset)
if (sampleRate == 0) {
unsupportedStream = true
return null
}
val coreSamples = blocks shl 5
val periodProduct = CARRIER_IEC958_RATE.toLong() * coreSamples
val period = periodProduct / sampleRate
val newSubtype = if (periodProduct % sampleRate != 0L) -1 else subtypeForPeriod(period)
if (newSubtype < 0) {
// 44.1kHz-family cores and exotic frame lengths map to no IEC 61937-11 repetition period at
// this carrier rate; FFmpeg refuses them the same way, so the stream decodes instead.
unsupportedStream = true
return null
}
setBurstGeometry((period * 4).toInt())
// FFmpeg's overflow answer: a Master Audio peak the carrier cannot hold strips this and the
// next ~60 seconds of units to the always-fitting core substream, so the receiver does not
// flap between core and MA decoding.
if (START_CODE.size + 2 + length > burstBytes - BURST_HEADER_SIZE) {
hdStripRemaining = sampleRate * HD_STRIP_SECONDS / coreSamples
}
var payloadSize = length
if (hdStripRemaining > 0 && coreSize <= length) {
payloadSize = coreSize
hdStripRemaining--
strippedToCore = true
}
val payloadBytes = START_CODE.size + 2 + payloadSize
if (payloadBytes > burstBytes - BURST_HEADER_SIZE) {
// Even the bare core overflows this period (only possible for very short core frames);
// nothing can be carried.
unsupportedStream = true
return null
}
START_CODE.copyInto(payloadScratch, 0)
payloadScratch[START_CODE.size] = (payloadSize ushr 8).toByte()
payloadScratch[START_CODE.size + 1] = (payloadSize and 0xFF).toByte()
data.copyInto(payloadScratch, START_CODE.size + 2, offset, offset + payloadSize)
// A final lone byte goes out MSB-aligned; its swap partner must be zero, not stale scratch.
if (payloadBytes and 1 == 1) payloadScratch[payloadBytes] = 0
burstIndex = burstIndex xor 1
val backing = burstBacking[burstIndex]
putLittleEndianShort(backing, 0, SYNCWORD1)
putLittleEndianShort(backing, 2, SYNCWORD2)
putLittleEndianShort(backing, 4, IEC61937_DTSHD or (newSubtype shl 8))
// Aligned so (Pd & 0xF) == 0x8, which some receivers reportedly require; FFmpeg and Kodi
// both apply the same quirk.
putLittleEndianShort(backing, 6, ((payloadBytes + 0x17) and 0x0F.inv()) - BURST_HEADER_SIZE)
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
var source = 0
var destination = BURST_HEADER_SIZE
val swappedPayload = payloadBytes + (payloadBytes and 1)
while (source < swappedPayload) {
backing[destination] = payloadScratch[source + 1]
backing[destination + 1] = payloadScratch[source]
source += 2
destination += 2
}
if (destination < dirtyEnd[burstIndex]) {
java.util.Arrays.fill(backing, destination, dirtyEnd[burstIndex], 0)
}
dirtyEnd[burstIndex] = destination
val burst = burstBuffers[burstIndex]
burst.limit(burstBytes)
burst.position(0)
return burst
}
private fun setBurstGeometry(newBurstBytes: Int) {
if (newBurstBytes == burstBytes) return
burstBytes = newBurstBytes
burstBacking = Array(2) { ByteArray(newBurstBytes) }
burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN) }
dirtyEnd[0] = 0
dirtyEnd[1] = 0
payloadScratch = ByteArray(newBurstBytes)
}
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
}
}
@@ -229,6 +229,12 @@ class ExoPlayerCore(private val activity: Activity) :
private var trackSelector: DefaultTrackSelector? = null
private var tunnelingUserEnabled: Boolean = true
private var tunnelingDisabledForAudioCodec: Boolean = false
// Tunnelled playback is driven by the audio codec's clock, so without a
// hardware audio decoder media3 never tunnels regardless of the flag —
// used to skip selector churn for no-op flips (see
// updateCurrentTunnelingState). True until an evaluation says otherwise.
private var selectedAudioHasHwDecoder: Boolean = true
private var tunnelingDisabledForVideoCodec: Boolean = false
private var tunnelingDisabledForDecodedPcm: Boolean = false
private var tunnelingDisabledForAudioRecovery: Boolean = false
@@ -314,6 +320,7 @@ class ExoPlayerCore(private val activity: Activity) :
private var lastAudioRecoveryReason: String? = null
private var lastAudioSinkError: String? = null
private var loggedEwasteEac3Workaround: Boolean = false
private val loggedDtsAppDecoderMimes = mutableSetOf<String>()
private var lastTrueHdDirectOutputLogKey: String? = null
private var loggedDecodedPcmTunnelingGuard: Boolean = false
private var hasRenderedVideoFrameForMedia: Boolean = false
@@ -564,8 +571,6 @@ class ExoPlayerCore(private val activity: Activity) :
}
fun initialize(
bufferSizeBytes: Int? = null,
bufferSizeAuto: Boolean = false,
tunnelingEnabled: Boolean = true,
audioPassthroughEnabled: Boolean = false,
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
@@ -734,8 +739,8 @@ class ExoPlayerCore(private val activity: Activity) :
// composition object and blanks palette-only fade updates (#1953).
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
// Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
// wrap MP4 extractors with DV converter when enabled.
// Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM
// variant, wrap MP4 extractors with the DV converter when enabled.
// Reads this.dvMode each time (not captured) so DV7→8.1 retry can
// change mode and reload without reinitializing the player.
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
@@ -780,21 +785,15 @@ class ExoPlayerCore(private val activity: Activity) :
.toTypedArray()
}
// Buffer budget. `bufferSizeBytes` carries the user's explicit Buffer Size choice; on
// Auto it still arrives (Dart derives it for mpv's demuxer, which shares the property)
// but `bufferSizeAuto` says to ignore it here, because mpv's demuxer and ExoPlayer's
// sample allocator have different shapes and different failure modes.
// Buffer budget. Derived natively from device memory (LoadControlPolicy);
// mpv's demuxer sizes itself the same way in MpvPlayerCore.
val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
val availableMB = (memoryInfo.availMem / (1024 * 1024)).toInt()
val largeHeapMB = activityManager.largeMemoryClass
val targetBufferBytes = if (!bufferSizeAuto && bufferSizeBytes != null && bufferSizeBytes > 0) {
bufferSizeBytes
} else {
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
}
val targetBufferBytes = LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
@@ -817,8 +816,8 @@ class ExoPlayerCore(private val activity: Activity) :
emitLog(
"info",
"init",
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit " +
"(heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
"(${resolvedTier.name.lowercase()}), " +
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
@@ -2318,7 +2317,20 @@ class ExoPlayerCore(private val activity: Activity) :
}
return true
}
return false
// DTS that is going to decode must not decode in a platform codec: on license-gated
// Amlogic boxes (the Onn family) the platform decoder drains normally while rendering
// silence (#1995). Bitstream-capable routes are untouched — media3 selects direct output
// before consulting the decoder list, and the visible platform decoder keeps the
// tunneling gate as it was.
val forceDts = shouldForceFfmpegDtsDecode(
mimeType,
directOutputBlocked = { shouldBlockDirectAudioOutput(dtsProbeFormat(mimeType), "decoder selection") },
routeCanBitstreamDts = { routeCanBitstreamDts(mimeType) }
)
if (forceDts && loggedDtsAppDecoderMimes.add(mimeType)) {
emitLog("info", "decoder", "Using app decoder for $mimeType; the stream will decode and platform DTS decoders render silence on license-gated devices")
}
return forceDts
}
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
@@ -2427,6 +2439,33 @@ class ExoPlayerCore(private val activity: Activity) :
.build()
}
/**
* Whether the current route can bitstream [mimeType] at all: media3's raw direct path
* ([AudioCapabilities]) or, for DTS-HD, the IEC 61937 carrier ([IecCarrierSink]). When this
* is false the stream decodes regardless of the passthrough setting.
*/
private fun routeCanBitstreamDts(mimeType: String): Boolean {
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
val audioAttributes = buildMovieAudioAttributes()
return try {
AudioCapabilities
.getCapabilities(activity, audioAttributes, null)
.isPassthroughPlaybackSupported(dtsProbeFormat(mimeType), audioAttributes)
} catch (e: Exception) {
// An unanswerable probe biases toward FFmpeg decode. A wrong "can't bitstream" is benign
// (bypass still wins before decoder selection); a wrong "can" leaves the silent platform
// decode path reachable.
false
}
}
/** DTS selection probe at the family's common shape; decoder selection only knows the mime. */
private fun dtsProbeFormat(mimeType: String): Format = Format.Builder()
.setSampleMimeType(mimeType)
.setChannelCount(6)
.setSampleRate(48_000)
.build()
@RequiresApi(Build.VERSION_CODES.Q)
@Suppress("DEPRECATION")
private fun isDirectPlaybackSupportedApi29(
@@ -2658,6 +2697,18 @@ class ExoPlayerCore(private val activity: Activity) :
private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean {
if (shouldTunnel == currentTunneledPlayback) return false
// A switch to "off" that the selected audio decoder could never have
// honored anyway is transparent to media3: tunnelled playback needs a
// tunneling-capable audio codec (it owns the AV-sync clock), so a
// software decoder already ignored the flag. Writing the selector
// parameter regardless forces a renderer rebuild that can tear down a
// live codec mid-queueInputBuffer — observed as
// "queueInputBuffer ... Released state" right after tracks arrive from
// the ffmpeg demuxer. Record the state without the churn.
if (!shouldTunnel && !selectedAudioHasHwDecoder) {
currentTunneledPlayback = false
return false
}
currentTunneledPlayback = shouldTunnel
val speed = exoPlayer?.playbackParameters?.speed ?: 1f
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
@@ -2685,6 +2736,7 @@ class ExoPlayerCore(private val activity: Activity) :
}
val newDisabled = !hasHardwareAudioDecoder(mimeType)
selectedAudioHasHwDecoder = !newDisabled
if (newDisabled != tunnelingDisabledForAudioCodec) {
tunnelingDisabledForAudioCodec = newDisabled
emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}")
@@ -4070,6 +4122,7 @@ class ExoPlayerCore(private val activity: Activity) :
extraDelayMs: Long,
videoWidth: Int,
videoHeight: Int,
matchResolution: Boolean,
onComplete: (switched: Boolean) -> Unit
) {
val mgr = frameRateManager
@@ -4077,11 +4130,17 @@ class ExoPlayerCore(private val activity: Activity) :
onComplete(false)
return
}
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, matchResolution, onComplete)
}
override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate()
// HDR content on an HDR display means the decoder's dataspace put the
// display into HDR signaling; defer the rate restore past the HDR exit
// (see FrameRateManager.clearVideoFrameRate).
val transfer = currentVideoFormat?.colorInfo?.colorTransfer
val hdrActive = (transfer == C.COLOR_TRANSFER_ST2084 || transfer == C.COLOR_TRANSFER_HLG) &&
DoviBridge.displaySupportsHdr(activity)
frameRateManager?.clearVideoFrameRate(hdrActive = hdrActive)
}
private fun computeFrameRate(timestamps: LongArray): Float {
@@ -4172,7 +4231,7 @@ class ExoPlayerCore(private val activity: Activity) :
"audioMimeType" to audioFormat?.sampleMimeType,
"audioSampleRate" to audioFormat?.sampleRate,
"audioChannels" to audioFormat?.channelCount,
"audioBitrate" to audioFormat?.bitrate,
"audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 },
"audioDecoderName" to audioDecoderInitName,
"audioOutputEncoding" to audioTrackConfig?.encoding,
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
@@ -1,8 +1,6 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.mpv.MpvPlayerCore
@@ -40,6 +38,11 @@ class ExoPlayerPlugin :
private val mainHandler get() = channels.mainHandler
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
private var playerCore: ExoPlayerCore? = null
// The Dart instanceId that created the current core. A `dispose` carrying a
// different token lost the ownership race to a successor and must not tear
// down that successor's session; it is acknowledged without touching it.
private var coreInstanceId: Long? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false
@@ -108,8 +111,6 @@ class ExoPlayerPlugin :
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
private var configuredBufferSizeBytes: Int? = null
private var sessionGeneration = 0
private var mediaGeneration = 0
private var fallbackMediaGeneration: Int? = null
@@ -119,7 +120,8 @@ class ExoPlayerPlugin :
private var mpvForwardGeneration: Int? = null
private var mpvSignalGate: MpvSignalGate? = null
private var mpvCoreNeedsReplacement = false
internal var createMpvCore: (Activity) -> MpvPlayerCore = { MpvPlayerCore(it) }
internal var createMpvCore: (Activity) -> MpvPlayerCore =
{ MpvPlayerCore(it, hardwareDecoding = fallbackHardwareDecoding()) }
internal var initializeMpvCore: (MpvPlayerCore, (Boolean) -> Unit) -> Unit = { core, onInitialized ->
core.initialize(onInitialized)
}
@@ -139,6 +141,12 @@ class ExoPlayerPlugin :
// every codec in audio-spdif with no decode fallback, so the fallback core's value is
// derived from the audio route at the moment mpv actually starts (#1703).
private var audioPassthroughRequested = false
// `dv-conversion-mode` is not an mpv property and never reaches
// pendingMpvProperties: Dart routes it through setDvConversionMode (see
// PlayerAndroid.setProperty), so the fallback core has to be seeded from the
// last configured value or it loses the fork's P7/P5 handling entirely.
private var dvConversionMode = "auto"
private var currentExternalSubtitles: List<Map<String, Any?>>? = null
// FlutterPlugin
@@ -158,6 +166,7 @@ class ExoPlayerPlugin :
val exoCore = playerCore
val fallbackCore = mpvCore
playerCore = null
coreInstanceId = null
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
@@ -174,6 +183,7 @@ class ExoPlayerPlugin :
currentExternalSubtitles = null
pendingMpvProperties.clear()
audioPassthroughRequested = false
dvConversionMode = "auto"
if (clearActivity) {
activity = null
activityBinding = null
@@ -224,7 +234,7 @@ class ExoPlayerPlugin :
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(result)
"dispose" -> handleDispose(call, result)
"open" -> handleOpen(call, result)
"play" -> handlePlay(result)
"pause" -> handlePause(result)
@@ -250,10 +260,6 @@ class ExoPlayerPlugin :
)
"getStats" -> handleGetStats(result)
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
"getHeapSize" -> {
val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
result.success(am?.largeMemoryClass ?: 0)
}
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
"setVideoZoom" -> handleSetVideoZoom(call, result)
@@ -291,20 +297,14 @@ class ExoPlayerPlugin :
return
}
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
// Auto sizing is decided natively (LoadControlPolicy). `bufferSizeBytes` still arrives
// on Auto because Dart derives one for mpv's demuxer, which shares the property, and
// the fallback replay below needs it.
val bufferSizeAuto = call.argument<Boolean>("bufferSizeAuto") ?: false
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
val dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: false
dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
configuredBufferSizeBytes = bufferSizeBytes
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
audioPassthroughRequested = audioPassthroughEnabled
@@ -343,9 +343,8 @@ class ExoPlayerPlugin :
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
}
playerCore = core
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
val success = core.initialize(
bufferSizeBytes = bufferSizeBytes,
bufferSizeAuto = bufferSizeAuto,
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled,
bufferTier = bufferTier
@@ -373,8 +372,15 @@ class ExoPlayerPlugin :
}
}
private fun handleDispose(result: MethodChannel.Result) {
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
val token = call.argument<Number>("instanceId")?.toLong()
runOnMain {
val owner = coreInstanceId
if ((playerCore != null || mpvCore != null) && token != null && owner != null && token != owner) {
Log.d(TAG, "Ignoring stale dispose (token=$token, core owner=$owner)")
result.success(null)
return@runOnMain
}
teardownSession(clearActivity = false)
Log.d(TAG, "Disposed")
result.success(null)
@@ -1015,14 +1021,19 @@ class ExoPlayerPlugin :
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
val matchResolution = call.argument<Boolean>("matchResolution") ?: false
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
Log.d(
TAG,
"setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, " +
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution"
)
val core = activeSurfaceCore
if (core == null) {
result.success(false)
return
}
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
result.success(switched)
}
}
@@ -1123,12 +1134,24 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
result.success(false)
// The fallback core owns the property now; dropping the write would
// strand it on the mode ExoPlayer started with.
val core = mpvCore
if (core == null) {
result.success(false)
return
}
dvConversionMode = mode
core.setProperty("dv-conversion-mode", mode) { outcome ->
completeMpvPropertyResult(result, outcome, successValue = true)
}
return
}
activity?.runOnUiThread {
val handled = playerCore?.setDebugDvConversionMode(mode) == true
if (handled) {
// Keep the fallback seed on the value ExoPlayer is actually running.
dvConversionMode = mode
result.success(true)
} else {
result.error("INVALID_ARGS", "Invalid DV conversion mode: $mode", null)
@@ -1301,6 +1324,16 @@ class ExoPlayerPlugin :
}
}
/**
* Decode intent for a fallback mpv core, read from the `hwdec` value Dart
* already wrote for this session. It picks the core's initial vo chain
* ([MpvPlayerCore.initialVideoOutput]), so a software-decoding session must
* not inherit the hardware default: only gpu-next applies Dolby Vision RPU
* reshaping, and a session that asked for software decode is usually the
* one that needs it.
*/
private fun fallbackHardwareDecoding(): Boolean = pendingMpvProperties["hwdec"]?.let { it != "no" } ?: true
/**
* Configure a freshly initialized MPV fallback core: replay the properties
* and observers Dart registered against the ExoPlayer session, then resume
@@ -1310,15 +1343,13 @@ class ExoPlayerPlugin :
private fun prepareMpvFallback(core: MpvPlayerCore) {
val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList()
val observedProps = observedProperties.toList()
val bufferSize = configuredBufferSizeBytes
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
// hwdec is not seeded here — Dart's write in pendingMpvProperties is the
// single source of the fallback core's chain.
core.setProperty("ao", "audiotrack")
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Dart routes dv-conversion-mode through setDvConversionMode, so unlike
// hwdec it never reaches pendingMpvProperties.
core.setProperty("dv-conversion-mode", dvConversionMode)
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue) { outcome ->
@@ -0,0 +1,50 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* The IEC 61937 carrier tuple every packed bitstream rides: 192kHz, 7.1, PCM-16 shaped.
*
* This is the high-bitrate HDMI shape Kodi uses for both TrueHD/MAT and DTS-HD Master Audio, and
* the one [supportsIecCarrier] probes. The 44.1kHz family would need a 176.4kHz sibling, which is
* deliberately not built; streams from that family decode instead.
*/
internal object IecCarrier {
const val SAMPLE_RATE = 192_000
const val CHANNEL_COUNT = 8
const val BYTES_PER_FRAME = CHANNEL_COUNT * 2
}
/**
* Splits one codec's bitstream into access units and packs them into IEC 61937 bursts for
* [IecCarrierSink].
*
* Implementations are not thread safe; the sink drives them from the playback thread only.
*/
internal interface IecCarrierPacker {
/**
* Length in bytes of the access unit starting at [offset], or 0 when no unit boundary is
* recognised there. May latch [unsupportedStream] when the boundary is recognisable but names a
* framing this carrier cannot ride.
*/
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int
/**
* Packs one access unit, returning a completed burst or null when this unit did not finish one.
*
* Returned buffers are owned by the packer and reused, alternating between two so a burst handed
* downstream stays valid while the next one fills. Callers must submit or copy it before the
* second following call.
*/
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer?
/**
* True when the bitstream announced a shape this carrier cannot ride. The packer emits nothing
* in that state; the sink latches the stream onto the decoder instead.
*/
val unsupportedStream: Boolean
/** Drops all carrier state. Called on flush/seek: bursts must not straddle a discontinuity. */
fun reset()
}
@@ -16,13 +16,16 @@ import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicInteger
/**
* Routes Dolby TrueHD through a MAT/IEC 61937 carrier, and everything else through the normal sink
* (#1804).
* Routes Dolby TrueHD (#1804) and DTS-HD Master Audio (#1988) through an IEC 61937 carrier, and
* everything else through the normal sink.
*
* Android will not bitstream raw TrueHD on the TV routes measured for this issue: the platform
* reports `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1
* as bitstream-capable. Kodi models the same split and packs the carrier itself; media3 only ever
* hands Android raw TrueHD, at the stream rate. This sink adds the missing path.
* Android will not bitstream raw TrueHD on the TV routes measured for #1804: the platform reports
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
* bitstream-capable. Raw `ENCODING_DTS_HD` is worse on Fire OS: the route advertises it, the
* AudioTrack initialises and drains, and the receiver hears silence, because the encoding only
* means "basic profile" there (#1988). Kodi models the same split and packs the carrier itself;
* media3 only ever hands Android the raw encodings, at the stream rate. This sink adds the
* missing path, with one [IecCarrierPacker] per codec.
*
* **Why two delegates rather than one sink with the processors held inactive.** The carrier is a
* bit-exact byte stream that happens to be shaped like PCM. Any sample mutation — downmix, Sonic,
@@ -39,7 +42,7 @@ import java.util.concurrent.atomic.AtomicInteger
* delegates so either can be activated later; per-stream calls go to the active one alone.
*/
@OptIn(UnstableApi::class)
internal class TrueHdCarrierSink(
internal class IecCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
@@ -49,39 +52,39 @@ internal class TrueHdCarrierSink(
private val log: ((String, String, String) -> Unit)? = null
) : AudioSink {
private companion object {
/** One MAT frame is exactly one carrier period: 3840 frames at 192kHz. */
const val CARRIER_BURST_DURATION_US =
TrueHdMatPacker.MAT_PKT_OFFSET.toLong() / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME *
1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
}
private val matPacker = TrueHdMatPacker()
private val dtsHdPacker = DtsHdIecPacker()
private val packer = TrueHdMatPacker()
/** The packer for the configured stream; chosen by mime type in [configure]. */
private var activePacker: IecCarrierPacker = matPacker
private var active: AudioSink = defaultSink
private var carrierActive = false
private var loggedCoreStrip = false
/** A burst the delegate refused; it must be placed before any further input is consumed. */
private var pendingBurst: ByteBuffer? = null
private var pendingBurstTimeUs: Long = 0
/**
* Anchor for carrier timestamps, and how many bursts have been emitted since it.
* Anchor for carrier timestamps, and how many carrier frames have been emitted since it.
*
* Each MAT frame is exactly one carrier period of audio, so timestamps are derived from the
* cadence rather than from whichever access unit happened to close the frame. Handing the sink
* the closing unit's own presentation time drifts against the time it derives from written
* frames, which it reports as a discontinuity on nearly every frame.
* Every burst is a whole number of carrier frames, so timestamps are derived from the cadence
* rather than from whichever access unit happened to close the burst. Handing the sink the
* closing unit's own presentation time drifts against the time it derives from written frames,
* which it reports as a discontinuity on nearly every burst. Counting frames rather than bursts
* keeps the arithmetic exact for burst durations that are not whole microseconds (a DTS-HD
* burst is 10666.67us).
*/
private var carrierAnchorUs: Long = C.TIME_UNSET
private var burstsSinceAnchor: Long = 0
private var carrierFramesSinceAnchor: Long = 0
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var sinkListener: AudioSink.Listener? = null
/**
* Latched when a stream's bitstream contradicts the rate its container announced, which selection
* was made from.
* Latched when a stream's bitstream contradicts the shape its container announced, which
* selection was made from.
*
* Deliberately outlives [flush] and [reset]: media3 resets every renderer disabled by a new
* selection before enabling the replacement (ExoPlayerImplInternal.enableRenderers), and both
@@ -114,11 +117,11 @@ internal class TrueHdCarrierSink(
* [directOutputBlocked] already reports.
*
* The rate family is decided from the format rather than from the packer, which only learns it
* from a major sync once buffers are already flowing — far too late for a selection that happens
* before configure.
* from the bitstream once buffers are already flowing — far too late for a selection that
* happens before configure.
*/
private fun shouldUseCarrier(format: Format): Boolean {
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false
if (!isTrueHd(format) && !isDtsHd(format)) return false
if (mismatchGeneration == mediaGeneration.get()) return false
if (!isCarrierRateFamily(format.sampleRate)) return false
if (playbackParameters.speed != 1f) return false
@@ -136,15 +139,15 @@ internal class TrueHdCarrierSink(
private fun carrierFormat(): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setPcmEncoding(C.ENCODING_PCM_16BIT)
.setChannelCount(TrueHdMatPacker.CARRIER_CHANNEL_COUNT)
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
.setChannelCount(IecCarrier.CHANNEL_COUNT)
.setSampleRate(IecCarrier.SAMPLE_RATE)
.build()
/**
* TrueHD is deliberately binary: the carrier, or decoded PCM. Never media3's own raw TrueHD path.
*
* That path builds an `ENCODING_DOLBY_TRUEHD` track at the *stream* rate, which is the
* configuration this issue is about — one box takes a single write and never advances its
* configuration #1804 is about — one box takes a single write and never advances its
* playback head, another freezes for ten seconds, and the third declines it and decodes anyway.
* Even Kodi's raw fallback is a different thing: it only offers raw TrueHD after verifying it at
* 192kHz, which media3 never requests. So when the carrier is unavailable — no IEC route, a speed
@@ -153,15 +156,30 @@ internal class TrueHdCarrierSink(
*/
private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD
/**
* DTS-HD is binary only while the carrier route exists: the carrier, or decoded PCM. Falling
* through to media3's raw `ENCODING_DTS_HD` path on such a route would land on exactly the
* silent configuration #1988 is about — the route that advertises the carrier and raw DTS-HD at
* once is the one whose raw path renders silence. Without a carrier route the raw path is the
* pre-carrier behavior and is left alone: those routes never had the carrier to lose, and some
* of them bitstream raw DTS-HD genuinely.
*
* DTS Express deliberately stays off the carrier: its mime carries a `;profile=lbr` suffix, so
* the equality below excludes it and it keeps decoding as before.
*/
private fun isDtsHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_DTS_HD
override fun supportsFormat(format: Format): Boolean = when {
shouldUseCarrier(format) -> true
isTrueHd(format) -> false
isDtsHd(format) && carrierRouteAvailable() -> false
else -> defaultSink.supportsFormat(format)
}
override fun getFormatSupport(format: Format): Int = when {
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
else -> defaultSink.getFormatSupport(format)
}
@@ -173,16 +191,18 @@ internal class TrueHdCarrierSink(
"info",
"audio",
if (useCarrier) {
"TrueHD via MAT/IEC 61937 carrier at ${TrueHdMatPacker.CARRIER_SAMPLE_RATE}Hz/" +
"${TrueHdMatPacker.CARRIER_CHANNEL_COUNT}ch"
"${carrierCodecName(inputFormat)} via IEC 61937 carrier at ${IecCarrier.SAMPLE_RATE}Hz/" +
"${IecCarrier.CHANNEL_COUNT}ch"
} else {
"Leaving the MAT carrier; audio returns to the normal sink"
"Leaving the IEC 61937 carrier; audio returns to the normal sink"
}
)
}
carrierActive = useCarrier
configuredGeneration = mediaGeneration.get()
activePacker = if (isDtsHd(inputFormat)) dtsHdPacker else matPacker
active = if (useCarrier) carrierSink else defaultSink
loggedCoreStrip = false
discardCarrierState()
if (useCarrier) {
@@ -197,6 +217,8 @@ internal class TrueHdCarrierSink(
}
}
private fun carrierCodecName(format: Format): String = if (isDtsHd(format)) "DTS-HD" else "TrueHD (MAT)"
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
@@ -217,37 +239,30 @@ internal class TrueHdCarrierSink(
var offset = 0
while (offset < remaining.size) {
val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size)
val length = activePacker.accessUnitLength(remaining, offset, remaining.size)
if (activePacker.unsupportedStream) return latchMismatch()
if (length == 0) {
// Not a unit boundary we recognise. Consuming the tail keeps the stream moving; trying to
// resynchronise mid-carrier would splice a frame.
buffer.position(buffer.limit())
return true
}
val burst = packer.packAccessUnit(remaining, offset, length)
if (packer.unsupportedRateFamily) {
// Selection is made from Format.sampleRate, so the bitstream disagrees with its container.
// The packer emits nothing in that state; consuming here would turn the stream into
// silence. Leave this unit in the buffer, latch the carrier off, and ask for reselection so
// the decoder takes over and receives it.
if (mismatchGeneration != configuredGeneration) {
mismatchGeneration = configuredGeneration
log?.invoke(
"warn",
"audio",
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
"leaving the carrier so it decodes"
)
sinkListener?.onAudioCapabilitiesChanged()
}
return false
}
val burst = activePacker.packAccessUnit(remaining, offset, length)
if (activePacker.unsupportedStream) return latchMismatch()
offset += length
buffer.position(buffer.position() + length)
if (burst == null) continue
if (activePacker === dtsHdPacker && dtsHdPacker.strippedToCore && !loggedCoreStrip) {
loggedCoreStrip = true
log?.invoke(
"warn",
"audio",
"DTS-HD MA bitrate exceeds the IEC 61937 carrier; sending the DTS core substream for ~60s"
)
}
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
burstsSinceAnchor++
val burstTimeUs = carrierAnchorUs + carrierFramesSinceAnchor * 1_000_000L / IecCarrier.SAMPLE_RATE
carrierFramesSinceAnchor += burst.remaining().toLong() / IecCarrier.BYTES_PER_FRAME
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
pendingBurst = burst
pendingBurstTimeUs = burstTimeUs
@@ -257,10 +272,35 @@ internal class TrueHdCarrierSink(
return true
}
/**
* The bitstream announced a shape the carrier cannot ride, which selection could not see in the
* container's Format. The packer emits nothing in that state; consuming here would turn the
* stream into silence. Leave the unit in the buffer, latch the carrier off, and ask for
* reselection so the decoder takes over and receives it.
*/
private fun latchMismatch(): Boolean {
if (mismatchGeneration != configuredGeneration) {
mismatchGeneration = configuredGeneration
log?.invoke(
"warn",
"audio",
if (activePacker === dtsHdPacker) {
"DTS-HD bitstream cannot ride the 192kHz carrier (44.1kHz-family core or non-standard framing); " +
"leaving the carrier so it decodes"
} else {
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
"leaving the carrier so it decodes"
}
)
sinkListener?.onAudioCapabilitiesChanged()
}
return false
}
override fun getCurrentPositionUs(sourceEnded: Boolean): Long = active.getCurrentPositionUs(sourceEnded)
override fun playToEndOfStream() {
// A partially filled MAT frame cannot be emitted; up to 20ms is dropped at the end of a stream.
// A partially filled burst cannot be emitted; up to one burst is dropped at the end of a stream.
active.playToEndOfStream()
}
@@ -285,11 +325,12 @@ internal class TrueHdCarrierSink(
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
private fun discardCarrierState() {
packer.reset()
matPacker.reset()
dtsHdPacker.reset()
pendingBurst = null
// Re-anchor on the next burst: after a seek the carrier restarts from a new media time.
carrierAnchorUs = C.TIME_UNSET
burstsSinceAnchor = 0
carrierFramesSinceAnchor = 0
}
// --- Persistent state: mirrored, so either delegate can be activated later ---
@@ -321,23 +362,23 @@ internal class TrueHdCarrierSink(
if (isUnitSpeed) playbackParameters else PlaybackParameters.DEFAULT
)
// Crossing 1x changes whether TrueHD may ride the carrier, but nothing re-asks on its own:
// the renderer only consults the sink when capabilities are invalidated. Rebuilding the track
// selector parameters is not enough either — DefaultTrackSelector skips invalidation when the
// rebuilt parameters compare equal. This is the path media3 itself uses for a route change,
// and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and TrueHD moves
// between the carrier and the decoder.
// Crossing 1x changes whether a bitstream may ride the carrier, but nothing re-asks on its
// own: the renderer only consults the sink when capabilities are invalidated. Rebuilding the
// track selector parameters is not enough either — DefaultTrackSelector skips invalidation
// when the rebuilt parameters compare equal. This is the path media3 itself uses for a route
// change, and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and the
// stream moves between the carrier and the decoder.
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
log?.invoke(
"info",
"audio",
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the TrueHD carrier"
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the bitstream carrier"
)
sinkListener?.onAudioCapabilitiesChanged()
}
}
/** Whether TrueHD is currently riding the carrier. Read by the core when speed changes. */
/** Whether the configured stream is currently riding the carrier. */
val isCarrierActive: Boolean
get() = carrierActive
@@ -345,7 +386,7 @@ internal class TrueHdCarrierSink(
* While the carrier is live the delegate is deliberately pinned to 1x, but the player polls this
* through the media clock and adopts whatever it reads. Reporting the delegate's value would push
* the pinned 1x back and silently undo the user's speed change, so the requested parameters are
* reported instead; the reselection triggered above then moves TrueHD onto the decoder, which
* reported instead; the reselection triggered above then moves the stream onto the decoder, which
* really can apply them.
*/
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
@@ -407,7 +448,7 @@ internal class TrueHdCarrierSink(
/**
* Called by the owner immediately before a new media item is set, which is the only point where
* "this stream lied about its rate" stops being true and the carrier can be offered again.
* "this stream lied about its shape" stops being true and the carrier can be offered again.
*/
fun beginMediaItem() {
mediaGeneration.incrementAndGet()
@@ -151,14 +151,14 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
)
}
private var trueHdCarrierSink: TrueHdCarrierSink? = null
private var iecCarrierSink: IecCarrierSink? = null
/**
* Clears per-stream carrier state that must survive renderer resets but not a new media item.
* Call before setting a new source; see [TrueHdCarrierSink.beginMediaItem].
* Call before setting a new source; see [IecCarrierSink.beginMediaItem].
*/
fun beginMediaItem() {
trueHdCarrierSink?.beginMediaItem()
iecCarrierSink?.beginMediaItem()
}
override fun buildAudioSink(
@@ -204,17 +204,17 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
audioDiagnosticsLogger
)
return TrueHdCarrierSink(
return IecCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
carrierRouteAvailable = { supportsIecCarrier(context) },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
).also { trueHdCarrierSink = it }
).also { iecCarrierSink = it }
}
/**
* The delegate that carries packed TrueHD (#1804).
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
*
* Deliberately separate from the processed sink, and deliberately barren: an empty
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
@@ -24,7 +24,7 @@ import java.nio.ByteBuffer
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class TrueHdMatPacker {
internal class TrueHdMatPacker : IecCarrierPacker {
internal companion object {
/** Payload bytes in one MAT frame. */
@@ -33,11 +33,6 @@ internal class TrueHdMatPacker {
/** Bytes from the start of one burst to the next, including preamble and trailing gap. */
const val MAT_PKT_OFFSET = 61440
/** Carrier the packed stream must be played at. */
const val CARRIER_SAMPLE_RATE = 192_000
const val CARRIER_CHANNEL_COUNT = 8
const val CARRIER_BYTES_PER_FRAME = CARRIER_CHANNEL_COUNT * 2
private const val BURST_HEADER_SIZE = 8
private const val SYNCWORD1 = 0xF872
private const val SYNCWORD2 = 0x4E1F
@@ -99,7 +94,7 @@ internal class TrueHdMatPacker {
private var samplesPerFrame = 0
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
fun reset() {
override fun reset() {
matBufferIndex = 0
matBufferFilled = 0
previousTiming = 0
@@ -107,7 +102,7 @@ internal class TrueHdMatPacker {
samplesPerFrame = 0
// The family is re-learned from the next major sync. Leaving it latched here would make every
// later stream on this packer emit nothing.
unsupportedRateFamily = false
unsupportedStream = false
java.util.Arrays.fill(matBuffers[0], 0)
java.util.Arrays.fill(matBuffers[1], 0)
}
@@ -119,9 +114,11 @@ internal class TrueHdMatPacker {
* whole path is built around. Rather than carry a second carrier configuration for a combination
* that is essentially absent from real media, the sink reads this and falls back to decoding.
*/
var unsupportedRateFamily: Boolean = false
override var unsupportedStream: Boolean = false
private set
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int = Companion.accessUnitLength(data, offset, limit)
/**
* Packs one access unit, returning a completed burst when this unit finished a MAT frame.
*
@@ -137,7 +134,7 @@ internal class TrueHdMatPacker {
* At most one burst can complete per access unit: padding is bounded below half a MAT frame and
* an access unit is far smaller, so a single unit cannot span two frame boundaries.
*/
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
if (length < MIN_ACCESS_UNIT_LENGTH) return null
if (hasMajorSync(data, offset, offset + length)) {
@@ -148,11 +145,11 @@ internal class TrueHdMatPacker {
else -> return null
}
// Bit 3 selects the 44.1kHz family, which rides a 176.4kHz carrier we do not build.
unsupportedRateFamily = (rateBits and 8) != 0
if (unsupportedRateFamily) return null
unsupportedStream = (rateBits and 8) != 0
if (unsupportedStream) return null
samplesPerFrame = 40 shl (rateBits and 3)
}
if (unsupportedRateFamily || samplesPerFrame == 0) return null
if (unsupportedStream || samplesPerFrame == 0) return null
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
var paddingRemaining = 0
@@ -1,6 +1,9 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
@@ -8,6 +11,9 @@ import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.text.SubtitleParser
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
import java.io.EOFException
import java.util.zip.DataFormatException
import java.util.zip.Inflater
/**
* Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
@@ -16,8 +22,19 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
* stripping). This subclass intercepts the compression algorithm during track
* header parsing:
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
* - Skips ContentCompSettings for zlib tracks (not applicable)
* - For text subtitle tracks, inflates the block payload *before* the parent
* parses it (see below). For every other zlib track, wraps TrackOutputs with
* ZlibInflatingTrackOutput to decompress per-sample data.
*
* Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput
* level: MatroskaExtractor rewrites their samples in-place before any TrackOutput
* runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to
* the still-compressed payload and truncates the sample at the first NUL byte,
* which deflate streams routinely contain (#2023). AssTrackOutput additionally
* feeds that same internal buffer straight to libass. So for zlib text tracks the
* frame payload is inflated at the block level, before the parent's sample
* assembly, and the TrackOutput wrapper is left inactive.
*
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
@@ -36,15 +53,43 @@ class ZlibMatroskaExtractor(
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
private const val ID_CONTENT_COMPRESSION = 0x5034
private const val ID_SIMPLE_BLOCK = 0xA3
private const val ID_BLOCK = 0xA1
/**
* Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix
* plus NUL truncation) before any TrackOutput runs — the authority is the
* prefix list in MatroskaExtractor.writeSampleData.
*/
private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT")
/** Subtitle blocks are tiny; anything above this passes through untouched. */
private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var latmOutput: LatmExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
/** Track numbers whose blocks are inflated before the parent parses them. */
private val zlibTextTrackNumbers = mutableSetOf<Int>()
private val blockInflater = Inflater()
private var blockBuf = ByteArray(0)
private val peekBuf = ByteArray(8)
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for
// zlib tracks entirely — the mere presence of ContentCompression means zlib
// until an explicit ContentCompAlgo says otherwise. media3 ignores an empty
// ContentCompression and would silently emit compressed samples.
if (id == ID_CONTENT_COMPRESSION) {
currentTrackUsesZlib = true
Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise")
}
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
// LATM layers (zlib outermost so inflation runs before LATM parsing).
if (id == ID_SEGMENT && zlibOutput == null) {
@@ -59,12 +104,17 @@ class ZlibMatroskaExtractor(
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
if (id == ID_CONTENT_COMPRESSION_ALGORITHM) {
if (value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
// Explicit non-zlib algorithm: header stripping (3) is handled by the
// parent; anything else makes the parent throw, matching stock behavior.
currentTrackUsesZlib = false
}
super.integerElement(id, value)
}
@@ -76,27 +126,47 @@ class ZlibMatroskaExtractor(
input.skipFully(contentSize)
return
}
if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) &&
zlibTextTrackNumbers.isNotEmpty() &&
contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES &&
peekBlockTrackNumber(input) in zlibTextTrackNumbers
) {
inflateTextBlock(id, contentSize, input)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
var zlibTextTrackNumber: Int? = null
if (id == ID_TRACK_ENTRY) {
// Must mark before super — the track output is created inside super's
// endMasterElement, and the x-unknown format must never reach the queue.
// Must inspect before super — the track output is created inside super's
// endMasterElement, and the current track is cleared afterwards.
val track = getCurrentTrack(id)
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
latmOutput?.markNextTrackLatm()
}
if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) {
zlibTextTrackNumber = track.number
}
}
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
zlibOutput?.activateLast()
currentTrackUsesZlib = false
Log.i(TAG, "Activated zlib inflation for track")
if (zlibTextTrackNumber != null) {
// Text subtitle samples are rewritten inside the parent before any
// TrackOutput runs, so the TrackOutput wrapper stays inactive and the
// block payload is inflated in binaryElement instead.
zlibTextTrackNumbers.add(zlibTextTrackNumber)
Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level")
} else {
zlibOutput?.activateLast()
Log.i(TAG, "Activated zlib inflation for track")
}
}
}
@@ -106,6 +176,64 @@ class ZlibMatroskaExtractor(
super.seek(position, timeUs)
}
/**
* Peeks the EBML varint at the block start — the block's track number — without
* consuming input. Returns null for malformed varints or truncated input; the
* parent then produces the canonical failure for the untouched stream.
*/
private fun peekBlockTrackNumber(input: ExtractorInput): Int? {
try {
input.peekFully(peekBuf, 0, 1)
val first = peekBuf[0].toInt() and 0xFF
if (first == 0) return null
val length = Integer.numberOfLeadingZeros(first) - 23
var value = (first and (0xFF ushr length)).toLong()
if (length > 1) {
input.peekFully(peekBuf, 1, length - 1)
for (i in 1 until length) {
value = (value shl 8) or (peekBuf[i].toLong() and 0xFF)
}
}
return if (value <= Int.MAX_VALUE) value.toInt() else null
} catch (_: EOFException) {
return null
} finally {
input.resetPeekPosition()
}
}
/**
* Buffers one text-subtitle block, inflates its frame payload, and hands the
* parent a block whose payload is plaintext. A block that cannot be rewritten
* (laced, corrupt, or over-bound) passes through byte-identical.
*/
private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) {
val basePosition = input.position
if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2))
input.readFully(blockBuf, 0, contentSize)
val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater)
if (rewritten == null) {
Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)")
super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition))
} else {
super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition))
}
}
private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit)
private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader {
private var position = 0
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position == limit) return C.RESULT_END_OF_INPUT
val count = minOf(length, limit - position)
System.arraycopy(data, position, buffer, offset, count)
position += count
return count
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
@@ -138,3 +266,50 @@ class ZlibMatroskaExtractor(
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
}
/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */
internal const val MIN_BLOCK_BYTES = 5
/**
* Rewrites one unlaced Matroska block whose frame payload is zlib-compressed:
* returns the unchanged block header followed by the inflated payload, or null
* when the block must pass through unchanged — lacing (never produced for text
* subtitle tracks), a truncated or corrupt deflate stream, or an inflated size
* beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening.
*/
internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? {
if (limit < MIN_BLOCK_BYTES) return null
val first = data[0].toInt() and 0xFF
if (first == 0) return null // track-number varint longer than 8 bytes
val varintLength = Integer.numberOfLeadingZeros(first) - 23
val headerLength = varintLength + 3 // varint + 2-byte timecode + flags
if (limit <= headerLength) return null
if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced
val payloadLength = limit - headerLength
val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024)
val maxInflatedBytes = 16 * 1024 * 1024
inflater.reset()
inflater.setInput(data, headerLength, payloadLength)
var buf = ByteArray(maxOf(4096, payloadLength * 4))
var written = 0
try {
while (true) {
if (written == buf.size) {
if (buf.size >= maxInflatedBytes) return null
buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2))
}
val count = inflater.inflate(buf, written, buf.size - written)
written += count
if (written > ratioBound) return null
if (inflater.finished()) break
if (count == 0) return null // truncated stream or preset-dictionary request
}
} catch (_: DataFormatException) {
return null
}
return ByteArray(headerLength + written).also {
System.arraycopy(data, 0, it, 0, headerLength)
System.arraycopy(buf, 0, it, headerLength, written)
}
}
@@ -0,0 +1,28 @@
package com.edde746.plezy.mpv
/**
* Demuxer cache budget derived from the device heap class.
*
* mpv has no device-memory awareness: `demuxer-max-bytes` defaults to a fixed
* 150 MiB forward (+50 MiB back) on every device and the demuxer fills
* whatever it is allowed, which crowds a 1 GB TV box until Android's
* low-memory killer takes the whole app. Tiered off
* [android.app.ActivityManager.getLargeMemoryClass], the same signal
* `stream_buffer_sizing.dart` and ExoPlayer's `LoadControlPolicy` use.
*
* Applied as pre-init *options* in [MpvPlayerCore], so a `demuxer-max-bytes`
* line in the user's mpv.conf still wins.
*/
data class DemuxerBudget(val aheadBytes: Long, val backBytes: Long) {
companion object {
private const val MIB = 1024L * 1024L
/** Null for an unknown class (<= 0): callers keep mpv's own defaults. */
fun forHeapClassMB(largeMemoryClassMB: Int): DemuxerBudget? = when {
largeMemoryClassMB <= 0 -> null
largeMemoryClassMB <= 256 -> DemuxerBudget(aheadBytes = 32 * MIB, backBytes = 16 * MIB)
largeMemoryClassMB <= 512 -> DemuxerBudget(aheadBytes = 64 * MIB, backBytes = 32 * MIB)
else -> DemuxerBudget(aheadBytes = 100 * MIB, backBytes = 48 * MIB)
}
}
}
@@ -0,0 +1,74 @@
package com.edde746.plezy.mpv
import android.opengl.EGL14
/**
* Which mpv `egl-output-format` this device can pair with a BT.2020 PQ window
* surface, or null when HDR GL output is unavailable. 10-bit fixed point is
* preferred; fp16 is the fallback because some drivers (Tegra among them)
* expose the PQ colorspace but no 1010102 window config.
*
* Both halves are required by the mpv side: the fork's android GL context asks
* for `EGL_EXT_gl_colorspace_bt2020_pq` on the window surface, and the app
* pairs it with an `egl-output-format` that makes mpv's EGL config selection
* fail outright when no matching config exists - so this probe must be
* consulted before those options are ever set.
*/
internal object EglHdrCaps {
private const val EGL_COLOR_COMPONENT_TYPE_EXT = 0x3339
private const val EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT = 0x333B
private object Unprobed
@Volatile private var cached: Any? = Unprobed
fun pqOutputFormat(): String? {
val value = cached
if (value !== Unprobed) return value as String?
return probe().also { cached = it }
}
private fun probe(): String? {
val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (display == EGL14.EGL_NO_DISPLAY) return null
val version = IntArray(2)
// Deliberately no eglTerminate: the default display is process-global and
// Flutter's renderer shares it; terminating would invalidate its state.
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
val extensions = EGL14.eglQueryString(display, EGL14.EGL_EXTENSIONS) ?: return null
if (!extensions.contains("EGL_EXT_gl_colorspace_bt2020_pq")) return null
if (hasWindowConfig(display, intArrayOf(EGL14.EGL_RED_SIZE, 10, EGL14.EGL_GREEN_SIZE, 10, EGL14.EGL_BLUE_SIZE, 10, EGL14.EGL_ALPHA_SIZE, 2))) {
return "rgb10_a2"
}
if (extensions.contains("EGL_EXT_pixel_format_float") &&
hasWindowConfig(
display,
intArrayOf(
EGL14.EGL_RED_SIZE,
16,
EGL14.EGL_GREEN_SIZE,
16,
EGL14.EGL_BLUE_SIZE,
16,
EGL_COLOR_COMPONENT_TYPE_EXT,
EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT
)
)
) {
return "rgba16f"
}
return null
}
private fun hasWindowConfig(display: android.opengl.EGLDisplay, extra: IntArray): Boolean {
val attribs = intArrayOf(
EGL14.EGL_SURFACE_TYPE,
EGL14.EGL_WINDOW_BIT,
EGL14.EGL_RENDERABLE_TYPE,
EGL14.EGL_OPENGL_ES2_BIT
) + extra + intArrayOf(EGL14.EGL_NONE)
val numConfigs = IntArray(1)
if (!EGL14.eglChooseConfig(display, attribs, 0, null, 0, 0, numConfigs, 0)) return false
return numConfigs[0] > 0
}
}
@@ -0,0 +1,60 @@
package com.edde746.plezy.mpv
/**
* Pure policy for when an mpv session must leave the video plane
* (vo=mediacodec) for a GL video output, and which one. Kept free of player
* and platform state so the routing matrix is unit-testable.
*/
internal object GpuVoPolicy {
/**
* Single-layer Dolby Vision Profile 5 (IPT-PQ-c2) has no compatible base
* layer: on a device without native DV support it decodes as plain HEVC
* with garbage colors, and the video plane applies no reshaping. gpu-next
* (libplacebo) under software decode is the only Android path that
* composites the RPU metadata (#1902). [dvProfile] comes from mpv's
* track-list — the bitstream's DOVI configuration record — never from
* server metadata, which mis-tags DV routinely. Only `auto` routes; the
* other modes are explicit user choices.
*/
fun needsDvReshaping(dvProfile: Long?, conversionMode: String, canPlayP5Natively: Boolean): Boolean = dvProfile == 5L && conversionMode == "auto" && !canPlayP5Natively
/**
* Whether an HDR signal has nowhere to tone-map: the video plane hands
* PQ/HLG straight to a display pipeline that advertises no HDR output, so
* it renders washed out (#2121). The GL vo tone-maps in the render chain.
*/
fun needsHdrToneMapping(gamma: String?, displaySupportsHdr: Boolean): Boolean = (gamma == "pq" || gamma == "hlg") && !displaySupportsHdr
/**
* Whether the decoder is handing mpv software frames, from `hwdec-current`.
*
* The plane refuses every format but MediaCodec buffers, so a per-file
* decode fallback (AV1 on Tegra, Hi10 without a profile match) has to move
* to a GL vo. Routing on this gets there before mpv fails the chain and
* [REASON_CHAIN_FAILURE] has to catch it.
*/
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
/**
* The vo a session with these active requirements should run, or null for
* the video plane.
*
* dv-reshape is the only reason that needs gpu-next, since libplacebo is
* what composites the RPU. Everything else takes gpu, the battle-tested
* GLES renderer on the Android device zoo. (The magenta field gpu-next
* used to render for 10-bit software frames on Tegra was its AV1 film
* grain shader overrunning the driver's uniform register budget; grain is
* decoder-applied now, but gpu-next buys this path nothing over gpu.)
*/
fun targetFor(reasons: Set<String>): String? = when {
reasons.isEmpty() -> null
REASON_DV_RESHAPE in reasons -> "gpu-next"
else -> "gpu"
}
const val REASON_DV_RESHAPE = "dv-reshape"
const val REASON_SHADERS = "shaders"
const val REASON_CHAIN_FAILURE = "chain-failure"
const val REASON_HDR_SDR = "hdr-sdr"
const val REASON_SW_DECODE = "sw-decode"
}
@@ -1,6 +1,7 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.graphics.PixelFormat
import android.media.AudioAttributes
@@ -15,6 +16,7 @@ import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.edde746.plezy.exoplayer.DoviBridge
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
@@ -39,20 +41,65 @@ import kotlinx.coroutines.sync.withLock
class MpvPlayerCore private constructor(
private val context: Context,
private val audioOnly: Boolean,
private val hardwareDecoding: Boolean,
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
initializedForTesting: Boolean
) : SurfaceHolder.Callback,
SurfacePlayerCore {
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
constructor(
context: Context,
audioOnly: Boolean = false,
hardwareDecoding: Boolean = true
) : this(context, audioOnly, hardwareDecoding, null, false)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?
) : this(context, audioOnly, propertyWriter, true)
) : this(context, audioOnly, true, propertyWriter, true)
companion object {
private const val TAG = "MpvPlayerCore"
/**
* The initial `vo` chain, decided by whether this session will hardware-
* decode.
*
* Hardware sessions use the fork vo=mediacodec: decoded buffers go from
* MediaCodec straight to the compositor with per-frame presentation
* timestamps - no GLES pass, 10-bit and the decoder's dataspace
* (HDR10/HLG) intact - and subtitles/OSD render on the sibling OSD
* surface. The plane takes decoder buffers only and refuses the rest, so
* a per-file decode fallback moves to a GL vo
* ([GpuVoPolicy.needsSoftwareRender], with the chain-failure watchdog as
* the backstop). gpu stays in the chain for preinit failure.
*
* Software sessions run gpu,gpu-next: gpu is the battle-tested GLES
* renderer on the Android device zoo, and with film grain applied by the
* decoder nothing else on this path needs libplacebo. Dolby Vision RPU
* reshaping (#1902) is the one exception - it needs gpu-next, and the
* [GpuVoPolicy.REASON_DV_RESHAPE] observer moves the session there when a
* DV profile that needs reshaping appears. gpu-next under *hardware*
* decode is broken on Tegra (samplerExternalOES double declaration
* rejected by the GLES linker, blue screen on the Shield, #2010);
* vo=mediacodec sidesteps that entire class by never touching GLES.
*/
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "mediacodec,gpu" else "gpu,gpu-next"
/**
* The `-append` list-option suffixes are not exposed through the property
* interface, so the app's decoder options replace the whole list. FFmpeg
* keeps the last duplicate key, so any user mpv.conf entries go first.
*/
internal fun mergeDecoderOptions(current: String?, ours: String): String = if (current.isNullOrBlank()) ours else "$current,$ours"
/**
* Whether content with this transfer is worth an HDR (BT.2020 PQ) GL
* surface. PQ and HLG both render into a PQ target; everything else -
* including unknown - stays on the default sRGB surface, which renders
* every content correctly (HDR arrives tone-mapped, as before).
*/
internal fun wantsHdrSurface(transfer: String?): Boolean = transfer == "smpte2084" || transfer == "arib-std-b67"
}
/** Video-only paths. The plugin always constructs video cores with the
@@ -61,7 +108,57 @@ class MpvPlayerCore private constructor(
get() = context as Activity
private var surfaceView: SurfaceView? = null
private var osdSurfaceView: SurfaceView? = null
private var surfaceContainer: android.widget.FrameLayout? = null
@Volatile private var pendingOsdSurface: Surface? = null
@Volatile private var attachedOsdSurface: Surface? = null
/** Active reasons the session must render off the plane. */
private val gpuVoReasons = LinkedHashSet<String>()
/** The GL vo this session is running, or null for the video plane. Non-null
* gates off the plane-only machinery: OSD attach, aspect-fitted layout,
* chain-failure watchdog. Written under [gpuVoReasons]. */
@Volatile private var activeGpuVoTarget: String? = null
/** Whether the per-file DV policy is holding hwdec at `no`; the session's
* own hwdec value is parked in [hwdecBeforeDvReshape] meanwhile. */
@Volatile private var dvReshapeActive: Boolean = false
private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference<String?>()
/** Last `dv-conversion-mode` Dart applied; input to the per-file DV
* routing policy. */
@Volatile private var currentDvConversionMode: String = "auto"
/** Whether this core already decided its GL surface colorspace; set by the
* first `content-color-transfer` announcement ([applyContentColorTransfer]). */
@Volatile private var hdrSurfaceDecided: Boolean = false
/** Whether this session outputs HDR to an HDR-capable display — via the PQ
* GL surface or the MediaCodec plane's decoder dataspace. Gates the
* deferred display-mode restore on teardown (see
* [FrameRateManager.clearVideoFrameRate]). */
@Volatile private var hdrDisplayActive: Boolean = false
@Volatile private var videoDisplayWidth: Int = 0
@Volatile private var videoDisplayHeight: Int = 0
/** Latest `panscan` (0..1) and `video-zoom` (log2) the app applied. The
* plane owns scaling, so these are view geometry here; see
* [applyVideoRectLayout]. */
@Volatile private var videoPanscan: Float = 0f
@Volatile private var videoZoomLog2: Float = 0f
/** Hardware sessions render through the fork vo=mediacodec (see
* [initialVideoOutput]); the OSD surface and video-rect layout exist only
* there. */
private val usesMediaCodecVo: Boolean
get() = !audioOnly && hardwareDecoding
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@Volatile private var disposing: Boolean = false
@@ -232,6 +329,22 @@ class MpvPlayerCore private constructor(
lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 0
// Video-output state, so a re-initialized core does not start stranded
// off the plane with a stale reason set.
synchronized(gpuVoReasons) {
gpuVoReasons.clear()
activeGpuVoTarget = null
}
hwdecBeforeDvReshape.set(null)
dvReshapeActive = false
attachedOsdSurface = null
videoDisplayWidth = 0
videoDisplayHeight = 0
videoPanscan = 0f
videoZoomLog2 = 0f
currentDvConversionMode = "auto"
hdrSurfaceDecided = false
hdrDisplayActive = false
if (!audioOnly) ensurePlaceholderSurface()
// Initialize audio focus handling. mpv has none built in, so both modes
@@ -259,6 +372,10 @@ class MpvPlayerCore private constructor(
surfaceContainer = PlayerSurfaceHost.createContainer(activity)
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
surfaceContainer!!.addView(surfaceView)
if (usesMediaCodecVo) {
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
surfaceContainer!!.addView(osdSurfaceView)
}
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
@@ -267,6 +384,7 @@ class MpvPlayerCore private constructor(
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
applyVideoRectLayout()
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
@@ -280,6 +398,11 @@ class MpvPlayerCore private constructor(
return@launch
}
val displayFpsOverride = currentDisplayFpsOverride()
// Both core kinds cap their demuxer cache off the device heap class;
// rationale on DemuxerBudget. Null (unknown class) keeps mpv defaults.
val demuxerBudget = DemuxerBudget.forHeapClassMB(
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
)
val p = MpvPlayer.create(context.applicationContext) {
if (audioOnly) {
// Pure audio core (all set before mpv_initialize, mirroring the
@@ -293,14 +416,26 @@ class MpvPlayerCore private constructor(
setOption("audio-display", "no")
setOption("gapless-audio", "weak")
} else {
setOption("vo", "gpu")
// vo choice is decode-path-dependent; rationale on
// initialVideoOutput.
setOption("vo", initialVideoOutput(hardwareDecoding))
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
// Keep AV1 film grain inside the decoder (dav1d). `auto` hands it
// to any vo claiming VO_CAP_FILM_GRAIN, and gpu-next claims it on
// GLES where libplacebo's raster grain fallback fetches luma by
// fragcoord (bottom-up) but chroma by uv: the luma renders
// upside-down (measured on a Shield Pro; desktop GL is unaffected
// because grain runs as a compute pass there).
setOption("vd-lavc-film-grain", "cpu")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
}
if (demuxerBudget != null) {
setOption("demuxer-max-bytes", demuxerBudget.aheadBytes.toString())
setOption("demuxer-max-back-bytes", demuxerBudget.backBytes.toString())
}
setOption("ao", "audiotrack,opensles")
// Pause on the last frame at EOF instead of unloading the file, so a
// seek after the video ends still works (matches Linux/Windows).
@@ -312,6 +447,13 @@ class MpvPlayerCore private constructor(
// builtin script during mpv_initialize, hence an option here.
setOption("ytdl", "no")
}
if (demuxerBudget != null) {
Log.d(
TAG,
"Demuxer budget: ${demuxerBudget.aheadBytes / (1024 * 1024)}MB ahead, " +
"${demuxerBudget.backBytes / (1024 * 1024)}MB back"
)
}
if (displayFpsOverride != null) {
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
}
@@ -331,6 +473,12 @@ class MpvPlayerCore private constructor(
collectEvents(p)
collectPropertyChanges(p)
collectLogMessages(p)
if (usesMediaCodecVo) {
collectVideoDimensions(p)
collectShaderState(p)
collectHdrToneMapState(p)
collectDecoderState(p)
}
Log.d(TAG, "Initialized successfully")
onResult(true)
@@ -367,9 +515,27 @@ class MpvPlayerCore private constructor(
}
is MpvEvent.StartFile -> {
endFileDiagnostics.onStartFile()
// The trigger is per-file (an exotic pixel format, a gralloc
// refusal for that stream), so give the plane back to the next
// file. A genuine failure re-arms it, costing one switch per bad
// file instead of the whole session's HDR/10-bit scanout.
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, false)
delegate?.onEvent("start-file", null)
}
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
is MpvEvent.FileLoaded -> {
if (usesMediaCodecVo) {
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
try {
applyDvReshapePolicy(p)
} catch (e: CancellationException) {
Log.d(TAG, "Canceled DV routing policy")
} catch (e: Exception) {
Log.w(TAG, "DV routing policy failed", e)
}
}
}
delegate?.onEvent("file-loaded", null)
}
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
else -> {}
}
@@ -403,6 +569,18 @@ class MpvPlayerCore private constructor(
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.logFlow.collect { msg ->
endFileDiagnostics.onLogMessage(msg)
// A chain-init failure is the one runtime signal that frames cannot
// reach the video plane at all (exotic pixel formats, gralloc
// refusal). mpv is pinned in the fork, so the log line is a stable
// contract.
if (usesMediaCodecVo &&
activeGpuVoTarget == null &&
msg.prefix.startsWith("cplayer") &&
msg.text.contains("Could not initialize video chain")
) {
Log.w(TAG, "Video chain init failed under vo=mediacodec; leaving the video plane")
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, true)
}
emitLog(msg.level.name.lowercase(), msg.prefix, msg.text)
}
}
@@ -454,6 +632,300 @@ class MpvPlayerCore private constructor(
detachSurfaceInternal(reason = "surfaceDestroyed")
}
// OSD surface (the vo=mediacodec subtitle/OSD plane)
private val osdSurfaceCallback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
if (disposing) return
pendingOsdSurface = holder.surface.takeIf { it.isValid }
Log.d(TAG, "OSD surface created")
if (player != null && hasAttachedRealSurface()) {
refreshVideoOutput("osdSurfaceCreated")
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "OSD surface destroyed")
pendingOsdSurface = null
val wasAttached = attachedOsdSurface != null
attachedOsdSurface = null
val p = player ?: return
if (disposing || !wasAttached) return
// Ordered against every other mpv write: a detach that overtook the
// re-attach of a recreated surface would clear the option the attach
// just set, and nothing re-arms it — subtitles would stay dead for the
// rest of the session.
scope.launch(mpvWriteDispatcher) {
try {
p.detachOsdSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach OSD surface", e)
}
}
}
}
/**
* Hands the OSD Surface to the vo. The vo reads the option when it is
* created, so this has to run before the `vo` or `wid` write that creates
* it, never after.
*/
private fun attachOsdSurfaceIfNeeded(p: MpvPlayer) {
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
val osd = pendingOsdSurface?.takeIf { it.isValid } ?: return
if (osd === attachedOsdSurface) return
p.attachOsdSurface(osd)
attachedOsdSurface = osd
Log.d(TAG, "Attached OSD surface for vo=mediacodec")
}
private fun collectVideoDimensions(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeInt("dwidth").collect { value ->
val w = value.toInt()
if (w > 0 && w != videoDisplayWidth) {
videoDisplayWidth = w
applyVideoRectLayout()
}
}
}
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeInt("dheight").collect { value ->
val h = value.toInt()
if (h > 0 && h != videoDisplayHeight) {
videoDisplayHeight = h
applyVideoRectLayout()
}
}
}
}
/**
* Arbiter for this session's video output: the active [GpuVoPolicy] reasons
* decide whether the session runs on the video plane or on a GL vo.
*/
private fun setGpuVoRequirement(reason: String, active: Boolean) {
if (!usesMediaCodecVo || disposing) return
val transition = synchronized(gpuVoReasons) {
val changed = if (active) gpuVoReasons.add(reason) else gpuVoReasons.remove(reason)
if (!changed) return
val desired = GpuVoPolicy.targetFor(gpuVoReasons)
if (desired == activeGpuVoTarget) return
val line = "${activeGpuVoTarget ?: "mediacodec"} -> ${desired ?: "mediacodec"} " +
"(reasons=[${gpuVoReasons.joinToString(",")}])"
activeGpuVoTarget = desired
line
}
Log.i(TAG, "Video output: $transition")
applyGpuVoTarget()
}
/**
* Moves the session to whatever the arbiter last decided.
*
* The decision is atomic under [gpuVoReasons], but the write cannot be:
* it has to leave the lock to reach mpv. Reasons are raised from different
* threads — per-file DV routing runs on [mpvWriteDispatcher], the gamma,
* shader and chain-failure observers on the main thread — so the order
* writes are *enqueued* is not the order decisions were *made*. Rather
* than trust the target its caller saw, every transition re-reads the
* current one here, which makes the last write the right one under any
* interleaving. Serialized on [mpvWriteDispatcher], so the paired main
* thread work stays in the same order too.
*/
private fun applyGpuVoTarget() {
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
runOnMain {
if (disposing) return@runOnMain
if (target == null) {
osdSurfaceView?.visibility = View.VISIBLE
} else {
// The gpu VOs draw their own OSD; a stale subtitle frame must not
// linger on the overlay plane.
osdSurfaceView?.visibility = View.GONE
resetVideoSurfaceToFullContainer()
}
}
try {
// Before the vo write, which recreates the VO: it reads the OSD
// surface option at creation.
val p = player
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
writeProperty("vo", target ?: "mediacodec")
if (p == null) return@launch
if (target != null) {
// A failed conversion chain makes mpv deselect the video track
// ("Could not initialize video chain" -> vid=no) before the VO
// switch lands. Re-select the explicit track id: mid-file "auto"
// resolves to no selection rather than re-running selection.
if (p.getString("vid").let { it == null || it == "no" }) {
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
if (videoTrackId != null) {
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
writeProperty("vid", videoTrackId.toString())
}
}
}
applySurfaceSizeInternal(p, force = true)
if (target == null) {
// Refit the surfaces to the aspect rectangle now that the plane
// owns scaling again (the gpu VOs letterboxed within full
// containers).
applyVideoRectLayout()
}
} catch (e: CancellationException) {
Log.d(TAG, "Canceled vo transition write")
} catch (e: Exception) {
Log.w(TAG, "Failed to move the video output to ${target ?: "mediacodec"}", e)
}
}
}
/**
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
* the DOVI configuration record's profile on the track list (never trust
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
* every file-loaded, so a following non-P5 file restores hardware decode
* and returns to the video plane.
*/
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
val profile = selectedVideoDvProfile(p)
val needs = GpuVoPolicy.needsDvReshaping(
dvProfile = profile,
conversionMode = currentDvConversionMode,
canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context)
)
if (needs == dvReshapeActive) return
dvReshapeActive = needs
if (needs) {
Log.i(TAG, "DV P5 (bitstream) without native support: software decode + gpu-next reshaping")
val current = p.getString("hwdec")
hwdecBeforeDvReshape.set(current ?: "no")
writeProperty("hwdec", "no")
} else {
val restore = hwdecBeforeDvReshape.getAndSet(null)
if (restore != null && restore != "no") writeProperty("hwdec", restore)
}
setGpuVoRequirement(GpuVoPolicy.REASON_DV_RESHAPE, needs)
}
/**
* Selected video track's Dolby Vision profile, or null for non-DV content
* (mpv omits the field when the bitstream carries no DOVI configuration
* record).
*/
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
?.takeIf { it.has("dolby-vision-profile") }
?.getLong("dolby-vision-profile")
/**
* Observed rather than derived from the hardware-decoding setting because
* the fallback is decided per file, inside mpv. Why it matters:
* [GpuVoPolicy.needsSoftwareRender].
*/
private fun collectDecoderState(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeString("hwdec-current").collect { value ->
setGpuVoRequirement(GpuVoPolicy.REASON_SW_DECODE, GpuVoPolicy.needsSoftwareRender(value))
}
}
}
/**
* User shaders need a GL vo; the video plane renders none. Observed
* natively so Dart's `glsl-shaders` change-list writes (ShaderService,
* ambient lighting) switch the session live, without a channel contract.
*/
private fun collectShaderState(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeString("glsl-shaders").collect { value ->
setGpuVoRequirement(GpuVoPolicy.REASON_SHADERS, value.isNotBlank())
}
}
}
/**
* Observed from video-params so the reason follows per-file transfer
* changes, and the display is re-queried per change so an HDMI mode switch
* mid-session is honoured on the next file. Why it matters:
* [GpuVoPolicy.needsHdrToneMapping].
*/
private fun collectHdrToneMapState(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeString("video-params/gamma").collect { value ->
val needsToneMap = GpuVoPolicy.needsHdrToneMapping(
gamma = value,
displaySupportsHdr = DoviBridge.displaySupportsHdr(context)
)
setGpuVoRequirement(GpuVoPolicy.REASON_HDR_SDR, needsToneMap)
}
}
}
/** Video tracks from mpv's track list, selected first; empty on any parse failure. */
private suspend fun videoTracks(p: MpvPlayer): List<org.json.JSONObject> {
val json = p.getString("track-list") ?: return emptyList()
return try {
val tracks = org.json.JSONArray(json)
(0 until tracks.length())
.map { tracks.getJSONObject(it) }
.filter { it.optString("type") == "video" }
.sortedByDescending { it.optBoolean("selected") }
} catch (e: Exception) {
Log.w(TAG, "Failed to parse track-list", e)
emptyList()
}
}
/**
* Sizes the video surface to the rectangle the image should occupy, per
* [VideoRectPolicy], and lets the container clip the overflow.
*
* The OSD surface is left full-container: the vo builds its `mp_osd_res`
* from the OSD window's own size, so libass keeps the whole window as its
* canvas — subtitles sit in the letterbox bars as they did under vo=gpu,
* and stay on screen when a zoomed image runs past the container.
*/
private fun applyVideoRectLayout() {
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
runOnMain {
if (disposing) return@runOnMain
val container = surfaceContainer ?: return@runOnMain
val size = VideoRectPolicy.sizeFor(
containerWidth = container.width,
containerHeight = container.height,
videoWidth = videoDisplayWidth,
videoHeight = videoDisplayHeight,
panscan = videoPanscan,
videoZoomLog2 = videoZoomLog2
) ?: return@runOnMain
// The guard matters: this runs from an OnGlobalLayoutListener, so an
// unconditional write would re-trigger layout forever.
surfaceView?.let { view ->
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
if (lp.width != size.width || lp.height != size.height || lp.gravity != android.view.Gravity.CENTER) {
lp.width = size.width
lp.height = size.height
lp.gravity = android.view.Gravity.CENTER
view.layoutParams = lp
}
}
}
}
private fun resetVideoSurfaceToFullContainer() {
val view = surfaceView ?: return
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
if (lp.width == android.widget.FrameLayout.LayoutParams.MATCH_PARENT) return
lp.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
lp.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
lp.gravity = android.view.Gravity.NO_GRAVITY
view.layoutParams = lp
}
private fun rememberSurfaceSize(width: Int, height: Int) {
if (width <= 0 || height <= 0) return
lastKnownSurfaceWidth = width
@@ -529,10 +1001,15 @@ class MpvPlayerCore private constructor(
return@withLock
}
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
// An unattached OSD Surface forces a wid re-attach: the VO reads the
// OSD surface option at creation, and setting wid recreates the VO.
// Suppressed off the plane, where the GL vo draws its own OSD.
val osdNeedsAttach = activeGpuVoTarget == null && pendingOsdSurface !== attachedOsdSurface
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osdNeedsAttach
val wasAttachedToPlaceholder = attachedToPlaceholder
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
if (needsAttach) {
attachOsdSurfaceIfNeeded(p)
p.attachSurface(surface)
attachedSurface = surface
hasAttachedSurface = true
@@ -850,12 +1327,136 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "Load pause intent updated: paused=$paused")
}
/**
* `dv-conversion-mode` is an app-level property shared with the ExoPlayer
* and Apple cores, not an mpv one. It maps onto the fork FFmpeg
* hevc_mediacodec decoder options, mirroring the ExoPlayer DoviBridge
* decision tree. Single-layer profiles (5/8) use the Dolby Vision decoder
* whenever the path is enabled and the decoder advertises the profile.
*/
private fun applyDvConversionMode(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
val displayDv = DoviBridge.displaySupportsDolbyVision(context)
val (dolbyVision, p7Mode) = when (value.trim().lowercase()) {
"auto" -> if (displayDv) "1" to "auto" else "0" to "strip"
"disabled", "native" -> "1" to "native"
"dv81" -> "1" to "convert"
"hevc", "hevc_strip" -> "1" to "strip"
else -> {
onComplete?.invoke(Result.failure(IllegalArgumentException("Invalid DV conversion mode: $value")))
return
}
}
currentDvConversionMode = value.trim().lowercase()
Log.i(TAG, "DV conversion mode '$value' (displayDV=$displayDv) -> dolby_vision=$dolbyVision dv_p7_mode=$p7Mode")
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val completion = try {
val ours = "dolby_vision=$dolbyVision,dv_p7_mode=$p7Mode"
val merged = mergeDecoderOptions(player?.getString("vd-lavc-o"), ours)
writeProperty("vd-lavc-o", merged)
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "DV conversion mode write failed")
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(completion)
}
}
}
/**
* `content-color-transfer` is an app-level property: Dart announces the
* selected stream's transfer (server metadata) before playback so an HDR
* session can get a BT.2020 PQ 10-bit GL surface instead of tone-mapped
* SDR. Consumed by whichever android GL context the session ever creates -
* up front for a software session, or at the fallback transition when a
* plane session leaves vo=mediacodec (the plane itself carries HDR via the
* decoder's dataspace and ignores all of this).
*
* The first announcement decides for the whole core: the surface colorspace
* is fixed at EGL-surface creation, and both latched states stay correct
* for later files (a PQ target renders SDR content correctly, an sRGB
* surface tone-maps HDR as before) - re-deciding mid-session could pair a
* live sRGB surface with a PQ render target, which is wrong everywhere.
*/
private fun applyContentColorTransfer(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
val transfer = value.trim().lowercase()
if (hdrSurfaceDecided) {
onComplete?.invoke(Result.success(Unit))
return
}
hdrSurfaceDecided = true
val wants = wantsHdrSurface(transfer)
val displayHdr = wants && DoviBridge.displaySupportsHdr(context)
// Independent of the GL surface outcome: on the MediaCodec plane the
// decoder's dataspace carries HDR to the display without a PQ GL surface.
hdrDisplayActive = displayHdr
val outputFormat = if (wants) EglHdrCaps.pqOutputFormat() else null
if (!wants || !displayHdr || outputFormat == null) {
if (wants) {
Log.i(TAG, "HDR GL surface unavailable (transfer=$transfer displayHdr=$displayHdr eglFormat=$outputFormat)")
}
onComplete?.invoke(Result.success(Unit))
return
}
Log.i(TAG, "HDR GL surface engaged: BT.2020 PQ / $outputFormat for transfer=$transfer")
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val completion = try {
writeProperty("android-surface-colorspace", "bt2020-pq")
writeProperty("egl-output-format", outputFormat)
writeProperty("target-trc", "pq")
writeProperty("target-prim", "bt.2020")
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "HDR surface property write failed")
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(completion)
}
}
}
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return
}
if (name == "dv-conversion-mode") {
applyDvConversionMode(value, onComplete)
return
}
if (name == "content-color-transfer") {
applyContentColorTransfer(value, onComplete)
return
}
// View geometry on the plane (see VideoRectPolicy), but both still fall
// through to mpv, which is what makes them work unchanged on the GL vos.
if (name == "panscan" || name == "video-zoom") {
val parsed = value.toFloatOrNull()
if (parsed != null) {
if (name == "panscan") videoPanscan = parsed else videoZoomLog2 = parsed
applyVideoRectLayout()
}
}
// While the per-file DV policy holds hwdec at `no`, park writes instead
// of applying them: a hardware value under gpu-next would lose the RPU
// side data (and blue-screen the Tegra class, #2010). The parked value
// is restored when a non-P5 file drops the requirement.
if (name == "hwdec" && dvReshapeActive) {
hwdecBeforeDvReshape.set(value)
onComplete?.invoke(Result.success(Unit))
return
}
val paused = if (name == "pause") normalizePauseValue(value) else null
val pauseIntent = paused?.let {
synchronized(publicPauseIntentLock) {
@@ -1038,9 +1639,13 @@ class MpvPlayerCore private constructor(
"estimated-vf-fps" to getProperty("estimated-vf-fps"),
"video-bitrate" to getProperty("video-bitrate"),
"hwdec-current" to getProperty("hwdec-current"),
"current-vo" to getProperty("current-vo"),
"audio-codec-name" to getProperty("audio-codec-name"),
"audio-params/samplerate" to getProperty("audio-params/samplerate"),
"audio-params/hr-channels" to getProperty("audio-params/hr-channels"),
"audio-params/format" to getProperty("audio-params/format"),
"current-tracks/audio/demux-samplerate" to getProperty("current-tracks/audio/demux-samplerate"),
"current-tracks/audio/demux-channel-count" to getProperty("current-tracks/audio/demux-channel-count"),
"audio-bitrate" to getProperty("audio-bitrate"),
"total-avsync-change" to getProperty("total-avsync-change"),
"cache-used" to getProperty("cache-used"),
@@ -1171,6 +1776,7 @@ class MpvPlayerCore private constructor(
extraDelayMs: Long,
videoWidth: Int,
videoHeight: Int,
matchResolution: Boolean,
onComplete: (switched: Boolean) -> Unit
) {
val mgr = frameRateManager
@@ -1178,7 +1784,7 @@ class MpvPlayerCore private constructor(
onComplete(false)
return
}
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight) { switched ->
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
player?.let {
updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") {
onComplete(switched)
@@ -1188,7 +1794,7 @@ class MpvPlayerCore private constructor(
}
override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate()
frameRateManager?.clearVideoFrameRate(hdrActive = hdrDisplayActive)
}
// Cleanup
@@ -1239,11 +1845,15 @@ class MpvPlayerCore private constructor(
// Capture locals for deferred cleanup (audio-only has no views)
val sv = surfaceView
val osdSv = osdSurfaceView
val container = surfaceContainer
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
surfaceContainer = null
surfaceView = null
osdSurfaceView = null
pendingOsdSurface = null
attachedOsdSurface = null
// Remove layout listener synchronously
overlayLayoutListener?.let { listener ->
@@ -1295,6 +1905,7 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "Disposed (native)")
Handler(Looper.getMainLooper()).post {
sv?.holder?.removeCallback(this)
osdSv?.holder?.removeCallback(osdSurfaceCallback)
if (container?.parent != null) {
contentView?.removeView(container)
}
@@ -1305,6 +1916,7 @@ class MpvPlayerCore private constructor(
// No player — safe to remove views immediately
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
osdSv?.holder?.removeCallback(osdSurfaceCallback)
if (container?.parent != null) {
contentView?.removeView(container)
}
@@ -1,10 +1,12 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import com.edde746.plezy.exoplayer.supportedMpvSpdifCodecs
import com.edde746.plezy.shared.PlayerChannelBinding
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -13,6 +15,7 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
@@ -61,6 +64,17 @@ open class MpvPlayerPlugin(
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// The Dart instanceId that created the current core. A `dispose` carrying a
// different token lost the ownership race to a successor and must not tear
// down that successor's core; it is acknowledged without touching anything.
private var coreInstanceId: Long? = null
// How long a Dart `dispose` waits for the native teardown before being
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
// session's surface/audio release); small against the alternative, which
// is wedging every subsequent playback session behind a hung teardown.
private val disposeWatchdogMs = 6_000L
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
@@ -94,6 +108,7 @@ open class MpvPlayerPlugin(
++sessionGeneration
val core = playerCore
playerCore = null
coreInstanceId = null
cancelPendingInits()
return core
}
@@ -147,8 +162,8 @@ open class MpvPlayerPlugin(
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
"dispose" -> handleDispose(result)
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(call, result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"getStats" -> handleGetStats(result)
@@ -159,16 +174,38 @@ open class MpvPlayerPlugin(
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"getAudioSpdifCodecs" -> handleGetAudioSpdifCodecs(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"closeContentFd" -> handleCloseContentFd(call, result)
"getHeapSize" -> {
// Device heap class for Dart-side memory tiering (stream ring cache).
// Lives on the always-registered mpv channel so it survives backends.
val context: Context? = activity ?: applicationContext
val am = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
result.success(am?.largeMemoryClass ?: 0)
}
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> handleSetLogLevel(call, result)
else -> result.notImplemented()
}
}
private fun handleInitialize(result: MethodChannel.Result) {
/**
* Derives the `audio-spdif` value for the current audio route. mpv force-passthroughs
* every codec named there with no decode fallback, so with no context to inspect the
* route the conservative answer is the empty list — decode everything (#1703, #1991).
*/
private fun handleGetAudioSpdifCodecs(result: MethodChannel.Result) {
val context: Context? = activity ?: applicationContext
result.success(context?.let(::supportedMpvSpdifCodecs) ?: "")
}
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
// Whether the session will hardware-decode; decides the initial vo chain
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
// from older callers; hardware decode is the setting's default.
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
// Video cores need the Activity (surface/view hierarchy); the audio-only
// core is built on the application context so it can outlive it.
val coreContext: Context? = if (audioOnly) applicationContext else activity
@@ -227,10 +264,11 @@ open class MpvPlayerPlugin(
}
gen = ++sessionGeneration
core = MpvPlayerCore(coreContext, audioOnly).apply {
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
} catch (e: Exception) {
Log.e(tag, "Failed to initialize: ${e.message}", e)
completePendingInits(attempt, success = false, errorMessage = e.message)
@@ -242,7 +280,10 @@ open class MpvPlayerPlugin(
playerCore !== core ||
!isCurrentInitAttempt(attempt)
if (stale || !success) {
if (playerCore === core) playerCore = null
if (playerCore === core) {
playerCore = null
coreInstanceId = null
}
core.dispose()
if (stale) {
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
@@ -299,13 +340,37 @@ open class MpvPlayerPlugin(
}
}
private fun handleDispose(result: MethodChannel.Result) {
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
val token = call.argument<Number>("instanceId")?.toLong()
runOnMain {
val core = takeCoreForTeardown()
core?.dispose {
Log.d(tag, "Disposed")
val owner = coreInstanceId
if (playerCore != null && token != null && owner != null && token != owner) {
// This dispose lost the ownership race: a successor already created
// the current core. Acknowledge without touching it.
Log.d(tag, "Ignoring stale dispose (token=$token, core owner=$owner)")
result.success(null)
} ?: result.success(null)
return@runOnMain
}
val core = takeCoreForTeardown()
if (core == null) {
result.success(null)
return@runOnMain
}
// A hung native teardown must not wedge the Dart-side release chain:
// answer after the watchdog even if the teardown thread is stuck, so
// the next session can start on a fresh core. The stuck core leaks its
// resources until the process ends — recoverable, unlike the wedge.
val completed = AtomicBoolean(false)
fun completeOnce(reason: String) {
if (completed.compareAndSet(false, true)) {
Log.d(tag, reason)
result.success(null)
}
}
channels.mainHandler.postDelayed({
completeOnce("Dispose watchdog fired after ${disposeWatchdogMs}ms; teardown continues in background")
}, disposeWatchdogMs)
core.dispose { completeOnce("Disposed") }
}
}
@@ -448,14 +513,19 @@ open class MpvPlayerPlugin(
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
val matchResolution = call.argument<Boolean>("matchResolution") ?: false
Log.d(tag, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
Log.d(
tag,
"setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, " +
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution"
)
val core = playerCore
if (core == null) {
result.success(false)
return
}
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
result.success(switched)
}
}
@@ -0,0 +1,53 @@
package com.edde746.plezy.mpv
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* Pure geometry for the video plane's surface rectangle.
*
* The fork `vo=mediacodec` scales decoded buffers to the whole Surface and
* implements none of mpv's src/dst rect math, so aspect, cover and zoom are
* view geometry: the video surface is sized to the rectangle the image should
* occupy and the container clips whatever falls outside it. That mirrors the
* ExoPlayer path, which scales its own surface and keeps the subtitle overlay
* on the full-size container.
*/
internal object VideoRectPolicy {
/**
* `video-zoom` is reachable from the user's mpv.conf, where an absurd value
* is a compositor allocation rather than just arithmetic. Bounds it well
* outside the range the player's own zoom control offers.
*/
private const val MAX_ZOOM_LOG2 = 2f
data class Size(val width: Int, val height: Int)
/**
* Size for the video surface, or null when a dimension is not known yet.
*
* [panscan] is mpv's 0..1 property: 0 fits the image inside the container
* (letterbox), 1 fills it (crop), values between interpolate the scale.
* [videoZoomLog2] is mpv's `video-zoom`, a log2 factor, applied on top.
*/
fun sizeFor(
containerWidth: Int,
containerHeight: Int,
videoWidth: Int,
videoHeight: Int,
panscan: Float = 0f,
videoZoomLog2: Float = 0f
): Size? {
if (containerWidth <= 0 || containerHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) return null
val fit = min(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
val cover = max(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
val pan = panscan.coerceIn(0f, 1f)
val zoom = 2.0f.pow(videoZoomLog2.coerceIn(-MAX_ZOOM_LOG2, MAX_ZOOM_LOG2))
val scale = (fit + (cover - fit) * pan) * zoom
return Size(
width = (videoWidth * scale).toInt().coerceAtLeast(1),
height = (videoHeight * scale).toInt().coerceAtLeast(1)
)
}
}
@@ -0,0 +1,145 @@
package com.edde746.plezy.shared
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Pure display-mode selection policy for content-adaptive display switching.
* Extracted from [FrameRateManager] so the policy is unit-testable on the
* JVM, where android.view.Display.Mode cannot be instantiated.
*/
object DisplayModeSelector {
const val RATE_TOLERANCE = 0.1f
/** JVM-testable mirror of android.view.Display.Mode. */
data class ModeInfo(val modeId: Int, val width: Int, val height: Int, val refreshRate: Float) {
val area: Long get() = width.toLong() * height
}
data class RefreshRateMatch(val reason: String, val priority: Int, val error: Float)
data class Selection(val mode: ModeInfo, val reason: String)
private data class Candidate(val mode: ModeInfo, val match: RefreshRateMatch)
/** How well [refreshRate] presents [fps] content: exact, an integer multiple, or not at all. */
fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
if (refreshRate <= 0f || fps <= 0f) return null
val exactError = abs(refreshRate - fps)
if (exactError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
}
val multiple = (refreshRate / fps).roundToInt()
if (multiple > 1) {
val multipleError = abs(refreshRate - (fps * multiple))
if (multipleError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
}
}
return null
}
/**
* Pick the display mode for the video, or null when no switch target exists.
* The caller compares the result against the current mode to decide whether
* an actual switch is needed.
*
* With [matchResolution] and known video dimensions, resolution wins over
* cadence: the target is the smallest mode that still contains the video
* (never downscaling it), rate-matched within that resolution when [fps] is
* known. Otherwise the cadence-only policy applies and requires [fps] > 0.
*/
fun findBestMode(
fps: Float,
currentMode: ModeInfo,
supportedModes: List<ModeInfo>,
videoWidth: Int,
videoHeight: Int,
matchResolution: Boolean
): Selection? {
if (matchResolution && videoWidth > 0 && videoHeight > 0) {
resolutionMatch(fps, currentMode, supportedModes, videoWidth, videoHeight)?.let { return it }
// No mode can contain the video (source larger than the panel):
// fall back to the cadence-only policy below.
}
return cadenceMatch(fps, currentMode, supportedModes, videoWidth, videoHeight)
}
private fun resolutionMatch(
fps: Float,
currentMode: ModeInfo,
supportedModes: List<ModeInfo>,
videoWidth: Int,
videoHeight: Int
): Selection? {
val candidates = supportedModes.filter { it.width >= videoWidth && it.height >= videoHeight }
if (candidates.isEmpty()) return null
// Native target: the smallest resolution that still contains the video,
// so the display (not the device) performs the upscale.
val targetArea = candidates.minOf { it.area }
val bucket = candidates.filter { it.area == targetArea }
// Rate-match within the target resolution when requested. Resolution
// wins over cadence: a missing rate match here deliberately does not
// widen back out to other resolutions.
if (fps > 0f) {
bucket
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
.minWithOrNull(
compareBy<Candidate> { it.match.priority }
.thenBy { it.match.error }
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
)
?.let { return Selection(it.mode, "resolution + ${it.match.reason} rate, error=${it.match.error}") }
}
// Resolution-only request, or no cadence match at the target resolution:
// stay as close to the current refresh rate as possible so the switch
// renegotiates only what it has to.
val fallback = bucket.minWithOrNull(
compareBy<ModeInfo> { abs(it.refreshRate - currentMode.refreshRate) }.thenByDescending { it.refreshRate }
)
return fallback?.let { Selection(it, "resolution only") }
}
private fun cadenceMatch(
fps: Float,
currentMode: ModeInfo,
supportedModes: List<ModeInfo>,
videoWidth: Int,
videoHeight: Int
): Selection? {
// Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only
// switch, the least disruptive (no resolution/HDMI renegotiation).
supportedModes.asSequence()
.filter { it.width == currentMode.width && it.height == currentMode.height }
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
.minWithOrNull(
compareBy<Candidate> { it.match.priority }
.thenBy { it.match.error }
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
)
?.let { return Selection(it.mode, "${it.match.reason}, error=${it.match.error}") }
// Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a
// 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one
// that downscales the video below its native size (trading detail for cadence).
// Requires known video dimensions; without them keep Tier-1-only behaviour.
if (videoWidth <= 0 || videoHeight <= 0) return null
return supportedModes.asSequence()
.filter { it.width >= videoWidth && it.height >= videoHeight }
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
.minWithOrNull(
// Prefer the resolution closest to the panel's current one (least change,
// keeps panel-native res when a high-res match exists), then refresh match.
compareBy<Candidate> { abs(it.mode.area - currentMode.area) }
.thenBy { it.match.priority }
.thenBy { it.match.error }
)
?.let { Selection(it.mode, "${it.match.reason}, error=${it.match.error}") }
}
}
@@ -9,8 +9,6 @@ import android.util.Log
import android.view.Display
import android.view.WindowManager
import androidx.annotation.RequiresApi
import kotlin.math.abs
import kotlin.math.roundToInt
class FrameRateManager(
private val activity: Activity,
@@ -21,38 +19,45 @@ class FrameRateManager(
private const val TAG = "FrameRateManager"
private const val DISPLAY_SETTLE_MS = 2000L
private const val WATCHDOG_MARGIN_MS = 3000L
private const val RATE_TOLERANCE = 0.1f
// How long to let the HDR-exit commit land before restoring the refresh
// rate. Restoring while the display is still signaling HDR folds the
// HDR-exit and the mode change into one HDMI renegotiation, which some
// sink chains take 8-30 s to complete (#2172); sequenced, the HDR
// infoframe clear is free and the SDR mode switch takes ~1 s. The
// player's surface teardown commits the HDR exit within ~50 ms of
// dispose, so 400 ms covers it with margin even on a busy main thread.
private const val HDR_EXIT_SETTLE_MS = 400L
}
private data class RefreshRateMatch(
val reason: String,
val priority: Int,
val error: Float
)
@RequiresApi(Build.VERSION_CODES.M)
private data class DisplayModeCandidate(
val mode: Display.Mode,
val match: RefreshRateMatch
)
private var currentVideoFps: Float = 0f
private var currentVideoWidth: Int = 0
private var currentVideoHeight: Int = 0
private var currentMatchResolution: Boolean = false
private var displayListener: DisplayManager.DisplayListener? = null
private var pendingSettleRunnable: Runnable? = null
private var watchdogRunnable: Runnable? = null
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
// Owns the deferred HDR-exit restore. Deliberately NOT the shared player
// [handler]: core dispose clears that one wholesale, and the restore must
// survive player disposal or the display stays at the content rate.
private val restoreHandler = Handler(android.os.Looper.getMainLooper())
private var pendingRestoreRunnable: Runnable? = null
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
// Request a display frame-rate switch. Invokes [onComplete] once, either:
// - immediately with `switched=false` when no switch is needed (invalid fps,
// no matching mode, or already matching); or
// Request a display mode switch for the video's frame rate and/or, with
// [matchResolution], its native resolution. Invokes [onComplete] once, either:
// - immediately with `switched=false` when no switch is needed (no usable
// fps/resolution target, no matching mode, or already matching); or
// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the caller's
// [extraDelayMs], with `switched=true`; or
// - via a watchdog if the real event never arrives, so the caller doesn't hang.
//
// fps <= 0 with [matchResolution] requests a resolution-only switch that
// keeps the refresh rate as close to the current one as possible.
//
// The caller is responsible for pausing playback before calling and resuming
// it after [onComplete] fires.
fun setVideoFrameRate(
@@ -61,20 +66,27 @@ class FrameRateManager(
extraDelayMs: Long,
videoWidth: Int = 0,
videoHeight: Int = 0,
matchResolution: Boolean = false,
onComplete: (switched: Boolean) -> Unit
) {
// A new session's switch must not be clobbered by a still-pending
// deferred restore from the previous session's teardown.
cancelPendingRestore()
currentVideoFps = fps
currentVideoWidth = videoWidth
currentVideoHeight = videoHeight
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
currentMatchResolution = matchResolution
val hasResolutionTarget = matchResolution && videoWidth > 0 && videoHeight > 0
if (fps <= 0f && !hasResolutionTarget) {
Log.d(TAG, "setVideoFrameRate: no usable target (fps=$fps, video=${videoWidth}x$videoHeight), skipping")
onComplete(false)
return
}
log(
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
"video=${videoWidth}x$videoHeight, API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution, " +
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -84,21 +96,47 @@ class FrameRateManager(
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
// [hdrActive]: the session was outputting HDR. The restore is then deferred
// by [HDR_EXIT_SETTLE_MS] so the caller's surface teardown can commit the
// HDR exit first — see [HDR_EXIT_SETTLE_MS] for why stacking them is slow.
fun clearVideoFrameRate(hdrActive: Boolean = false) {
Log.d(TAG, "clearVideoFrameRate(hdrActive=$hdrActive)")
currentVideoFps = 0f
// Resolve any pending setVideoFrameRate future as "not switched" so
// the Dart caller's await doesn't hang on player dispose.
firePendingCompletion("clear", switched = false)
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
cancelPendingRestore()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
// Nothing to restore when no preferred mode was ever applied.
if ((activity.window?.attributes?.preferredDisplayModeId ?: 0) == 0) return
if (hdrActive) {
val restore = Runnable {
pendingRestoreRunnable = null
// Log.d, not [log]: this fires after core dispose, when the
// Flutter-channel logger is already gone.
Log.d(TAG, "restoring default display mode after HDR exit")
restorePreferredDisplayMode()
}
pendingRestoreRunnable = restore
restoreHandler.postDelayed(restore, HDR_EXIT_SETTLE_MS)
} else {
restorePreferredDisplayMode()
}
}
private fun restorePreferredDisplayMode() {
// preferredDisplayModeId persists on the window; restore the default.
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
private fun cancelPendingRestore() {
pendingRestoreRunnable?.let { restoreHandler.removeCallbacks(it) }
pendingRestoreRunnable = null
}
// Release pending callbacks/listener without restoring the display mode.
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
@@ -129,7 +167,12 @@ class FrameRateManager(
cb(switched)
}
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
private fun registerDisplayListener(
fps: Float,
targetModeId: Int,
extraDelayMs: Long,
onComplete: (switched: Boolean) -> Unit
) {
// Resolve any previous pending op before starting a new one.
firePendingCompletion("superseded", switched = false)
pendingCompletion = onComplete
@@ -144,7 +187,9 @@ class FrameRateManager(
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
val settle = Runnable {
firePendingCompletion("display settled", switched = currentMatchesRequest(fps, targetModeId))
}
pendingSettleRunnable = settle
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
}
@@ -154,37 +199,20 @@ class FrameRateManager(
// Watchdog: if the TV never signals a display change (silently ignoring
// the mode request), still complete after a bounded wait so the caller
// doesn't hang.
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentMatchesRequest(fps, targetModeId)) }
watchdogRunnable = watchdog
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
}
private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
if (refreshRate <= 0f || fps <= 0f) return null
val exactError = abs(refreshRate - fps)
if (exactError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
}
val multiple = (refreshRate / fps).roundToInt()
if (multiple > 1) {
val multipleError = abs(refreshRate - (fps * multiple))
if (multipleError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
}
}
return null
}
private fun currentRateMatch(fps: Float): RefreshRateMatch? {
val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
currentDisplayMode()?.refreshRate
} else {
null
} ?: return null
return matchRefreshRate(current, fps)
// Whether the display landed on the requested mode: the exact target, or —
// for a rate request — any mode whose refresh presents [fps] (a TV may pick
// a different-but-equivalent mode). A resolution-only request (fps <= 0)
// only counts the exact target.
private fun currentMatchesRequest(fps: Float, targetModeId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
val current = currentDisplayMode() ?: return false
if (current.modeId == targetModeId) return true
return DisplayModeSelector.matchRefreshRate(current.refreshRate, fps) != null
}
private fun currentModeDescription(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -214,46 +242,11 @@ class FrameRateManager(
private fun describeSupportedModes(modes: Array<Display.Mode>): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) }
@RequiresApi(Build.VERSION_CODES.M)
private fun findBestModeMatch(
fps: Float,
currentMode: Display.Mode,
supportedModes: Array<Display.Mode>,
videoWidth: Int,
videoHeight: Int
): DisplayModeCandidate? {
// Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only
// switch, the least disruptive (no resolution/HDMI renegotiation).
supportedModes.asSequence()
.filter { it.physicalHeight == currentMode.physicalHeight && it.physicalWidth == currentMode.physicalWidth }
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
.minWithOrNull(
compareBy<DisplayModeCandidate> { it.match.priority }
.thenBy { it.match.error }
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
)
?.let { return it }
// Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a
// 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one
// that downscales the video below its native size (trading detail for cadence).
// Requires known video dimensions; without them keep Tier-1-only behaviour.
if (videoWidth <= 0 || videoHeight <= 0) return null
val currentArea = currentMode.physicalWidth.toLong() * currentMode.physicalHeight
return supportedModes.asSequence()
.filter { it.physicalWidth >= videoWidth && it.physicalHeight >= videoHeight }
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
.minWithOrNull(
// Prefer the resolution closest to the panel's current one (least change,
// keeps panel-native res when a high-res match exists), then refresh match.
compareBy<DisplayModeCandidate> { abs(it.mode.physicalWidth.toLong() * it.mode.physicalHeight - currentArea) }
.thenBy { it.match.priority }
.thenBy { it.match.error }
)
}
private fun Display.Mode.toModeInfo(): DisplayModeSelector.ModeInfo = DisplayModeSelector.ModeInfo(modeId, physicalWidth, physicalHeight, refreshRate)
@RequiresApi(Build.VERSION_CODES.M)
private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
log("setDisplayMode fps=$fps")
log("setDisplayMode fps=$fps, matchResolution=$currentMatchResolution")
val display = currentDisplay()
if (display == null) {
log("display unavailable")
@@ -270,34 +263,43 @@ class FrameRateManager(
val currentMode = display.mode
log("supported modes=${describeSupportedModes(supportedModes)}")
val modeMatch = findBestModeMatch(fps, currentMode, supportedModes, currentVideoWidth, currentVideoHeight)
if (modeMatch == null) {
val selection = DisplayModeSelector.findBestMode(
fps,
currentMode.toModeInfo(),
supportedModes.map { it.toModeInfo() },
currentVideoWidth,
currentVideoHeight,
currentMatchResolution
)
if (selection == null) {
log(
"no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight} " +
"(video=${currentVideoWidth}x$currentVideoHeight)"
"(video=${currentVideoWidth}x$currentVideoHeight, matchResolution=$currentMatchResolution)"
)
onComplete(false)
return
}
val modeToUse = modeMatch.mode
val modeToUse = supportedModes.firstOrNull { it.modeId == selection.mode.modeId }
if (modeToUse == null) {
log("selected mode #${selection.mode.modeId} disappeared from supported modes")
onComplete(false)
return
}
if (modeToUse.modeId == currentMode.modeId) {
log("current mode already matches ${fps}fps (${modeMatch.match.reason}), no switch needed")
log("current mode already matches ${fps}fps (${selection.reason}), no switch needed")
onComplete(false)
return
}
log(
"switching to ${describeMode(modeToUse)} for ${fps}fps " +
"(${modeMatch.match.reason}, error=${modeMatch.match.error})"
)
log("switching to ${describeMode(modeToUse)} for ${fps}fps (${selection.reason})")
val window = activity.window
if (window == null) {
log("window unavailable")
onComplete(false)
return
}
registerDisplayListener(fps, extraDelayMs, onComplete)
registerDisplayListener(fps, modeToUse.modeId, extraDelayMs, onComplete)
window.attributes = window.attributes.apply { preferredDisplayModeId = modeToUse.modeId }
}
}
@@ -7,6 +7,34 @@ import java.util.Locale
/** Canonical decoder lookup and hardware classification for native playback. */
internal object MediaCodecQuery {
/**
* Codecs whose advertised support is decided by hardware: both have a
* software decoder behind them, but a software HEVC or AV1 decode on
* phone/TV-class hardware drops frames.
*/
private val HARDWARE_GATED_VIDEO_MIME_TYPES = mapOf(
"hevc" to "video/hevc",
"av1" to "video/av01"
)
/** Codec name -> whether this device has a hardware decoder for it. */
fun hardwareVideoDecodeSupport(
hardwareMimeTypes: Set<String> = hardwareDecoderMimeTypes()
): Map<String, Boolean> = HARDWARE_GATED_VIDEO_MIME_TYPES.mapValues { (_, mimeType) -> mimeType in hardwareMimeTypes }
/**
* Every MIME type served by a hardware decoder, lowercased. One walk answers
* for all codecs, unlike [findHardwareDecoder], which rescans per lookup.
*/
private fun hardwareDecoderMimeTypes(): Set<String> {
val mimeTypes = HashSet<String>()
for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) {
if (info.isEncoder || !isHardwareAccelerated(info)) continue
for (type in info.supportedTypes) mimeTypes.add(type.lowercase(Locale.ROOT))
}
return mimeTypes
}
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
@@ -2,6 +2,7 @@ package com.edde746.plezy.shared
import android.app.Activity
import android.graphics.Color
import android.graphics.PixelFormat
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
@@ -14,8 +15,37 @@ internal object PlayerSurfaceHost {
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Fallback fill for the frames before the punch surface below is placed.
setBackgroundColor(Color.BLACK)
this.clipChildren = clipChildren
addView(createLetterboxPunchSurface(activity))
}
/**
* Fullscreen, buffer-less SurfaceView kept beneath the video surface.
*
* Its only job is taking the letterbox area off the app-window (graphics)
* plane: like any below-window SurfaceView it punches the parent canvas and
* registers its rect as a window transparent region, so the pixels around
* the video rect scan out as the SurfaceFlinger backdrop instead of
* window-plane black. Several TV compositors (Fire TV Stick 4K Max, some
* Sony/Philips models) raise SDR graphics-plane black while the display is
* in HDR/Dolby Vision mode, which shows as gray letterbox bars on OLED
* panels (issue #2163; same mechanism as ExoPlayer #8803 and Kodi #25300).
*
* No buffer is ever posted to it: SurfaceFlinger skips buffer-less layers,
* and drawing black into it would put the bars back onto an SDR layer —
* exactly the plane being avoided. Views drawn on the parent canvas after
* the punch (Media3 subtitle cues) still re-claim their own bounds.
*/
private fun createLetterboxPunchSurface(activity: Activity): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
@@ -29,6 +59,23 @@ internal object PlayerSurfaceHost {
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
/**
* Transparent plane directly above the video surface for the mpv
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
* the video SurfaceView but still beneath the Flutter window content.
*/
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(callback)
holder.setFormat(PixelFormat.TRANSLUCENT)
setZOrderOnTop(false)
setZOrderMediaOverlay(true)
FlutterOverlayHelper.applyCompositionOrder(this, -1)
}
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(container, 0)
@@ -22,6 +22,7 @@ interface SurfacePlayerCore {
extraDelayMs: Long,
videoWidth: Int,
videoHeight: Int,
matchResolution: Boolean,
onComplete: (switched: Boolean) -> Unit
)
}
@@ -48,83 +48,205 @@ class AudioOutputPolicyTest {
}
@Test
fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() {
assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true })
fun dtsDecodeIsForcedToFfmpegWhenDirectOutputIsBlocked() {
// Passthrough disabled (the #1995 report), downmix, normalization, or a failure block:
// the stream decodes, and platform DTS decoders render silence on license-gated devices.
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { true }, { true }))
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { true }, { true }))
}
@Test
fun spdifListDropsCodecsTheRouteCannotBitstream() {
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream,
// TrueHD and DTS do not (#1703).
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
assertEquals("ac3,eac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
fun dtsDecodeIsForcedToFfmpegWhenTheRouteCannotBitstream() {
// Onn 4K Plus over HDMI to a Dolby-only TV: passthrough enabled (the Android TV default)
// still decodes because the route carries no DTS shape (#1995).
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { false }))
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { false }))
}
@Test
fun spdifListOmitsDtsHdOnDtsCoreOnlyRoutes() {
// media3's passthrough probe would answer yes for DTS-HD here by downgrading to the
// DTS core. mpv reads `dts,dts-hd` as `dts-hd` alone and force-passes DTS-HD MA, so
// accepting that downgrade would break DTS too.
val dtsCoreOnlyRoute = setOf(C.ENCODING_DTS)
assertEquals("dts", mpvSpdifCodecs { encoding -> encoding in dtsCoreOnlyRoute })
fun bitstreamCapableDtsRoutesKeepThePlatformDecoderVisible() {
// A bitstreaming session never engages a decoder; leaving the platform decoder visible
// keeps the hardware-decoder tunneling gate exactly as it was.
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { true }))
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { true }))
}
@Test
fun spdifListKeepsDtsHdWhenTheRouteAdvertisesIt() {
val dtsHdRoute = setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD)
assertEquals("dts,dts-hd", mpvSpdifCodecs { encoding -> encoding in dtsHdRoute })
fun dtsVariantsFfmpegCannotClaimAreLeftAlone() {
// DTS Express and DTS:X are not in FfmpegLibrary's mime map; hiding their platform
// decoders would leave those streams with no decoder at all.
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd;profile=lbr", { true }, { false }))
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.uhd", { true }, { false }))
assertFalse(shouldForceFfmpegDtsDecode(MimeTypes.AUDIO_TRUEHD, { true }, { false }))
}
@Test
fun spdifListIsEmptyForPcmOnlyRoutes() {
assertEquals("", mpvSpdifCodecs { false })
}
@Test
fun carrierIsNeverOfferedBelowApi29() {
// No direct-playback oracle exists there, and getMinBufferSize alone is known to lie
// (a Shield sizes the tuple, then the AudioTrack fails to initialise).
fun nonDtsMimesNeverConsultTheDtsProbes() {
// Decoder selection runs this predicate for every mime, video included; the probes make
// binder calls and must stay behind the mime gate.
assertFalse(
trueHdMatCarrierSupported(
sdkInt = 28,
canSizeCarrierBuffer = { true },
bitstreamSupported = { true },
directPlaybackSupported = { true }
shouldForceFfmpegDtsDecode(
MimeTypes.VIDEO_H265,
{ throw AssertionError("directOutputBlocked consulted for a non-DTS mime") },
{ throw AssertionError("routeCanBitstreamDts consulted for a non-DTS mime") }
)
)
}
@Test
fun carrierRequiresASizableBufferOnEveryTier() {
for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) {
fun spdifListNamesEveryCodecARouteWithAllShapesCanCarry() {
// libmpv v1.1.0's audiotrack AO opens each burst at its own rate and channel mask, so a route
// that takes all three shapes and advertises everything bitstreams the lossless codecs too.
// `dts-hd` supersedes plain `dts`: ad_spdif picks the core burst per file for non-HD tracks.
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes))
}
@Test
fun spdifListOnAStereo48kOnlyRouteNamesTheCoreCodecsOnly() {
// E-AC3 (192kHz), TrueHD MAT and DTS-HD MA (192kHz/8ch) have no track to ride here.
assertEquals("ac3,dts", spdifCodecs(allEncodings, setOf(MpvIecShape.STEREO_48K)))
}
@Test
fun dtsHdFallsBackToThePlainCoreWithoutTheCarrierShape() {
// Advertising ENCODING_DTS_HD says the receiver decodes it, not that the route takes the
// 192kHz/8ch track its burst needs (#1988); naming `dts-hd` anyway strands playback.
assertEquals(
"dts",
spdifCodecs(setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD), setOf(MpvIecShape.STEREO_48K))
)
}
@Test
fun eac3IsNotNamedWithoutThe192kStereoShape() {
assertEquals("", spdifCodecs(setOf(C.ENCODING_E_AC3), setOf(MpvIecShape.STEREO_48K)))
}
@Test
fun trueHdIsNotNamedWithoutTheCarrierShape() {
assertEquals(
"",
spdifCodecs(
setOf(C.ENCODING_DOLBY_TRUEHD),
setOf(MpvIecShape.STEREO_48K, MpvIecShape.STEREO_192K)
)
)
}
@Test
fun plainDtsSurvivesWhenTheRouteDoesNotAdvertiseDtsHd() {
// The core burst is stereo/48k, so it rides a full-shape route unchanged when only the
// lossless encoding is missing.
assertEquals("dts", spdifCodecs(setOf(C.ENCODING_DTS), allShapes))
}
@Test
fun spdifListDropsCodecsTheRouteCannotBitstream() {
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, DTS does not (#1703).
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
}
@Test
fun spdifListIsEmptyForPcmOnlyRoutes() {
assertEquals("", mpvSpdifCodecs({ false }, { false }))
}
@Test
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
// Every encoding advertised, but no raw track and no IEC 61937 shape opens: mpv has no
// decode fallback for a named codec, so nothing may be named (#1991).
assertEquals("", spdifCodecs(allEncodings, emptySet()))
}
@Test
fun rawCapableRouteBitstreamsTheCoreCodecsWithoutAnyIecShape() {
// #2177's Shield: every mpv IEC track opens and drains into silence, while raw
// ENCODING_AC3/E_AC3/DTS tracks (the ExoPlayer transport) play. The AO opens raw first,
// so raw support alone must qualify the core codecs.
assertEquals(
"ac3,eac3,dts",
spdifCodecs(allEncodings, emptySet(), raw = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3, C.ENCODING_DTS))
)
}
@Test
fun rawSupportNeverQualifiesTheLosslessCodecs() {
// TrueHD and DTS-HD MA have no raw transport in the AO; they ride the 192kHz/7.1 IEC
// carrier or decode. A route that takes every raw track but no carrier must not name them.
assertEquals("ac3,eac3,dts", spdifCodecs(allEncodings, emptySet(), raw = allEncodings))
}
@Test
fun rawProbeIsOnlyConsultedForRawCandidates() {
// The raw probe costs real route calls; the carrier-only codecs must never trigger it.
val codecs = mpvSpdifCodecs(
{ true },
{ true },
{ encoding ->
if (encoding == C.ENCODING_DOLBY_TRUEHD || encoding == C.ENCODING_DTS_HD) {
throw AssertionError("raw probe consulted for a carrier-only codec")
}
true
}
)
assertEquals("ac3,eac3,truehd,dts-hd", codecs)
}
@Test
fun dtsHdStillSupersedesPlainDtsWhenDtsBitstreamsRaw() {
// dts-hd selects the lossless spdif decoder for the whole dts codec; the raw core track
// must not resurrect the plain name beside it.
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes, raw = allEncodings))
}
@Test
fun iecRouteIsNeverOfferedBelowApi24() {
// ENCODING_IEC61937 does not exist there.
assertFalse(
iecRouteSupported(
sdkInt = 23,
canSizeBuffer = { true },
bitstreamSupported = { true },
directPlaybackSupported = { true },
hdmiRouteAdvertised = { true }
)
)
}
@Test
fun iecRouteRequiresASizableBufferOnEveryTier() {
for (sdkInt in intArrayOf(25, 28, 29, 30, 32, 33, 34)) {
assertFalse(
"api $sdkInt",
trueHdMatCarrierSupported(
iecRouteSupported(
sdkInt = sdkInt,
canSizeCarrierBuffer = { false },
canSizeBuffer = { false },
bitstreamSupported = { true },
directPlaybackSupported = { true }
directPlaybackSupported = { true },
hdmiRouteAdvertised = { true }
)
)
}
}
@Test
fun carrierOnApi29To32FollowsTheDirectPlaybackProbe() {
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
fun routeOnApi24To28FollowsTheHdmiAdvertisement() {
// No runtime oracle exists below API 29; only an HDMI AudioDeviceInfo that explicitly
// advertises the IEC tuple can vouch for it. Shield Experience 8.x is API 28, and a flat
// `false` on this tier force-decoded TrueHD on routes that genuinely carry it (#1991).
for (supported in booleanArrayOf(true, false)) {
for (sdkInt in intArrayOf(29, 30, 32)) {
for (sdkInt in intArrayOf(25, 28)) {
assertEquals(
"api $sdkInt supported=$supported",
supported,
trueHdMatCarrierSupported(
iecRouteSupported(
sdkInt = sdkInt,
canSizeCarrierBuffer = { true },
canSizeBuffer = { true },
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
directPlaybackSupported = { supported }
directPlaybackSupported = { throw AssertionError("isDirectPlaybackSupported does not exist below API 29") },
hdmiRouteAdvertised = { supported }
)
)
}
@@ -132,20 +254,55 @@ class AudioOutputPolicyTest {
}
@Test
fun carrierOnApi33UsesTheBitstreamProbe() {
fun routeOnApi29To32FollowsTheDirectPlaybackProbe() {
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
for (supported in booleanArrayOf(true, false)) {
for (sdkInt in intArrayOf(29, 30, 32)) {
assertEquals(
"api $sdkInt supported=$supported",
supported,
iecRouteSupported(
sdkInt = sdkInt,
canSizeBuffer = { true },
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
directPlaybackSupported = { supported },
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
)
)
}
}
}
@Test
fun routeOnApi33UsesTheBitstreamProbe() {
// getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29
// probe must not shadow it where the platform can answer precisely.
for (supported in booleanArrayOf(true, false)) {
assertEquals(
"supported=$supported",
supported,
trueHdMatCarrierSupported(
iecRouteSupported(
sdkInt = 33,
canSizeCarrierBuffer = { true },
canSizeBuffer = { true },
bitstreamSupported = { supported },
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") }
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") },
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
)
)
}
}
/** Every encoding the spdif table can ask for, i.e. a receiver that decodes all of them. */
private val allEncodings = setOf(
C.ENCODING_AC3,
C.ENCODING_E_AC3,
C.ENCODING_DOLBY_TRUEHD,
C.ENCODING_DTS,
C.ENCODING_DTS_HD
)
private val allShapes = MpvIecShape.values().toSet()
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String =
mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
}
@@ -0,0 +1,270 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the DTS-HD -> IEC 61937 "DTS type IV" carrier against FFmpeg (#1988).
*
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
* FFmpeg's own input and output at the 768kHz HD rate that fills the 192kHz/7.1 carrier.
*
* FFmpeg cannot encode Master Audio, so the access units are synthesized — real `dca` core frames,
* each glued to a hand-built extension substream — by `dtshd_fixture_generator.py` beside the
* fixtures; the golden is then FFmpeg's own packing:
*
* ```
* ffmpeg -f dts -i dtshd_ma_access_units.bin -c:a copy -f spdif -dtshd_rate 768000 \
* dtshd_ma_iec61937_golden.bin
* ```
*
* One mid-stream unit is oversized on purpose, so the fixtures also pin FFmpeg's overflow answer:
* strip to the core substream and hold that for ~60s.
*/
class DtsHdIecPackerTest {
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private val accessUnits by lazy { resource("dtshd_ma_access_units.bin") }
private val golden by lazy { resource("dtshd_ma_iec61937_golden.bin") }
/** 512 samples at the fixture's 48kHz core rate on the 768kHz carrier model, in bytes. */
private val burstSize = 32768
/** The whole point: our carrier and FFmpeg's are the same bytes. */
@Test
fun packedCarrierMatchesFfmpegByteForByte() {
val packed = packAll(accessUnits)
assertEquals(
"burst count differs from FFmpeg",
golden.size / burstSize,
packed.size / burstSize
)
// Compare per burst so a failure names the burst rather than dumping 480KB.
for (index in 0 until packed.size / burstSize) {
val from = index * burstSize
val to = from + burstSize
assertArrayEquals(
"burst $index differs from FFmpeg",
golden.copyOfRange(from, to),
packed.copyOfRange(from, to)
)
}
}
/** Each burst is a full IEC 61937 frame: preamble, then payload, then zero padding. */
@Test
fun everyBurstCarriesTheDtsHdPreamble() {
val packed = packAll(accessUnits)
for (index in 0 until packed.size / burstSize) {
val at = index * burstSize
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
// Data type 0x11 with subtype 4: an 8192 IEC 60958 frame repetition period.
assertEquals("burst $index Pc (IEC61937_DTSHD)", 0x0411, readLittleEndianShort(packed, at + 4))
val payloadSize = 10 + 2 + burstAccessUnitSize(packed, at)
assertEquals(
"burst $index Pd must carry FFmpeg's (length & 0xf) == 0x8 alignment",
((payloadSize + 0x17) and 0x0F.inv()) - 8,
readLittleEndianShort(packed, at + 6)
)
}
}
/**
* A Master Audio peak the carrier cannot hold is stripped to the always-fitting core substream,
* and stays stripped for ~60s so the receiver does not flap between core and MA decoding. The
* fixture's fifth unit is oversized on purpose; everything after it is core-only.
*/
@Test
fun anOversizedUnitStripsToTheCoreAndHolds() {
val packer = DtsHdIecPacker()
val packed = packAll(accessUnits, packer)
val fullUnit = 3904
val coreOnly = 1884
for (index in 0 until packed.size / burstSize) {
val expected = if (index < 4) fullUnit else coreOnly
assertEquals("burst $index payload size", expected, burstAccessUnitSize(packed, index * burstSize))
}
assertTrue("the strip must be observable for logging", packer.strippedToCore)
assertFalse("stripping is a downgrade, not an unsupported stream", packer.unsupportedStream)
}
/** Core frames and their glued extension substreams split as one access unit each. */
@Test
fun accessUnitsSpanTheCoreAndItsExtensionSubstreams() {
val packer = DtsHdIecPacker()
var units = 0
var offset = 0
while (offset < accessUnits.size) {
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
units++
offset += length
}
assertEquals("every byte of the fixture belongs to a unit", accessUnits.size, offset)
assertEquals(golden.size / burstSize, units)
}
/**
* Streams with core sometimes open with a stray HD frame that has no core (FFmpeg discards
* these). It must be consumed as a unit so the stream keeps moving, but never carried: there is
* no core to derive the burst period from.
*/
@Test
fun aStrayLeadingExtensionSubstreamIsConsumedButNotCarried() {
val packer = DtsHdIecPacker()
val firstUnit = packer.accessUnitLength(accessUnits, 0, accessUnits.size)
val exssSize = 2020
val stray = accessUnits.copyOfRange(firstUnit - exssSize, firstUnit)
val length = packer.accessUnitLength(stray, 0, stray.size)
assertEquals("the stray frame's own size field walks it", exssSize, length)
assertNull("a coreless frame cannot ride the carrier", packer.packAccessUnit(stray, 0, length))
assertFalse("a stray frame is dropped, not latched", packer.unsupportedStream)
}
/** The wide (bHeaderSizeType=1) extension-substream header carries 20-bit size fields. */
@Test
fun wideHeaderExtensionSubstreamSizesAreParsed() {
val packer = DtsHdIecPacker()
val core = accessUnits.copyOfRange(0, 1884)
val exssSize = 4100
val unit = core + wideHeaderExtensionSubstream(exssSize)
assertEquals(1884 + exssSize, packer.accessUnitLength(unit, 0, unit.size))
}
/**
* Little-endian and 14-bit core framings (S/PDIF and DTS-in-WAV captures) have no period mapping
* on this carrier; they must latch the stream unsupported so the sink hands it to the decoder,
* not silently consume it.
*/
@Test
fun nonBigEndianCoreFramingLatchesTheStreamUnsupported() {
for (sync in listOf(
byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()),
byteArrayOf(0x1F, 0xFF.toByte(), 0xE8.toByte(), 0x00),
byteArrayOf(0xFF.toByte(), 0x1F, 0x00, 0xE8.toByte())
)) {
val packer = DtsHdIecPacker()
val buffer = sync + ByteArray(64)
assertEquals(0, packer.accessUnitLength(buffer, 0, buffer.size))
assertTrue("sync ${sync.joinToString { "%02x".format(it) }}", packer.unsupportedStream)
}
}
/** A 44.1kHz-family core maps to no IEC 61937-11 repetition period at 192kHz; it must decode. */
@Test
fun aFortyFourFamilyCoreLatchesTheStreamUnsupported() {
val packer = DtsHdIecPacker()
val unit = accessUnits.copyOfRange(0, 1884)
// SFREQ sits in bits [5:2] of byte 8; index 8 is 44100Hz.
unit[8] = ((unit[8].toInt() and 0b11000011) or (8 shl 2)).toByte()
assertNull(packer.packAccessUnit(unit, 0, unit.size))
assertTrue(packer.unsupportedStream)
}
/**
* The flags gate every later call, so leaving them latched across a reset would make a packer
* that once saw a bad stream emit nothing (or log strips) for the rest of its life.
*/
@Test
fun resetClearsTheLatchesAndReproducesTheStream() {
val packer = DtsHdIecPacker()
val leSync = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
packer.accessUnitLength(leSync, 0, leSync.size)
assertTrue(packer.unsupportedStream)
packer.reset()
assertFalse("reset must clear the unsupported latch", packer.unsupportedStream)
assertFalse("reset must clear the strip latch", packer.strippedToCore)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
packAll(accessUnits, packer)
)
}
/** The packer must not allocate a burst per unit; buffers alternate and are reused. */
@Test
fun burstBuffersAreReusedRatherThanAllocated() {
val packer = DtsHdIecPacker()
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
var bursts = 0
var offset = 0
while (offset < accessUnits.size) {
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
packer.packAccessUnit(accessUnits, offset, length)?.let {
seen[it] = true
bursts++
}
offset += length
}
assertTrue("expected multiple bursts", bursts > 2)
assertEquals("buffers must alternate between exactly two", 2, seen.size)
}
private fun packAll(units: ByteArray, packer: DtsHdIecPacker = DtsHdIecPacker()): ByteArray {
val out = java.io.ByteArrayOutputStream()
var offset = 0
while (offset < units.size) {
val length = packer.accessUnitLength(units, offset, units.size)
if (length == 0) break
packer.packAccessUnit(units, offset, length)?.let { burst ->
val copy = ByteArray(burst.remaining())
burst.duplicate().get(copy)
out.write(copy)
}
offset += length
}
return out.toByteArray()
}
/** The 16-bit big-endian size field after the start code, read from the byte-swapped burst. */
private fun burstAccessUnitSize(packed: ByteArray, burstOffset: Int): Int {
// Payload bytes 10 and 11 land swapped within their 16-bit word: 10 -> +19, 11 -> +18.
val high = packed[burstOffset + 8 + 11].toInt() and 0xFF
val low = packed[burstOffset + 8 + 10].toInt() and 0xFF
return (high shl 8) or low
}
/** An extension substream whose header uses the wide 12/20-bit size fields. */
private fun wideHeaderExtensionSubstream(size: Int): ByteArray {
val frame = ByteArray(size)
frame[0] = 0x64
frame[1] = 0x58
frame[2] = 0x20
frame[3] = 0x25
// After UserDefinedBits(8): nExtSSIndex(2)=0, bHeaderSizeType(1)=1, then 12 header-size bits
// and 20 frame-size bits, all values stored minus one.
var bits = 0L
bits = (bits shl 2) or 0L
bits = (bits shl 1) or 1L
bits = (bits shl 12) or (32L - 1)
bits = (bits shl 20) or (size.toLong() - 1)
// 35 bits, MSB-aligned into bytes 5..9.
val aligned = bits shl (40 - 35)
for (i in 0 until 5) {
frame[5 + i] = ((aligned shr (32 - 8 * i)) and 0xFF).toByte()
}
return frame
}
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
}
@@ -24,13 +24,14 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Routing and back-pressure contract for the TrueHD MAT carrier (#1804).
* Routing and back-pressure contract for the IEC 61937 carrier: TrueHD/MAT (#1804) and DTS-HD
* (#1988).
*
* The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting
* the wrong sink handle TrueHD.
* the wrong sink handle a carrier codec.
*/
@OptIn(UnstableApi::class)
class TrueHdCarrierSinkTest {
class IecCarrierSinkTest {
private val accessUnits: ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
@@ -40,12 +41,28 @@ class TrueHdCarrierSinkTest {
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_iec61937_golden.bin"))
.use { it.readBytes() }
private val dtsHdAccessUnits: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_access_units.bin"))
.use { it.readBytes() }
}
private val dtsHdGolden: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_iec61937_golden.bin"))
.use { it.readBytes() }
}
private fun trueHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
.setChannelCount(6)
.setSampleRate(sampleRate)
.build()
private fun dtsHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_DTS_HD)
.setChannelCount(6)
.setSampleRate(sampleRate)
.build()
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
private fun sink(
@@ -53,7 +70,7 @@ class TrueHdCarrierSinkTest {
normal: FakeSink = FakeSink(),
routeAvailable: Boolean = true,
blocked: Boolean = false
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
) = IecCarrierSink(normal, carrier, { routeAvailable }, { blocked })
/**
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
@@ -75,6 +92,54 @@ class TrueHdCarrierSinkTest {
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
@Test
fun dtsHdWithACarrierRouteIsSupportedDirectly() {
val carrierSink = sink()
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/**
* The route that advertises the carrier and raw `ENCODING_DTS_HD` at once is the one whose raw
* path renders silence (#1988), so while the carrier route exists DTS-HD is the carrier or it is
* decoded — never the normal sink's raw path, even when policy declines the carrier.
*/
@Test
fun dtsHdOnACarrierRouteNeverFallsThroughToTheRawPath() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val blockedSink = sink(normal = normal, blocked = true)
assertFalse(blockedSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, blockedSink.getFormatSupport(dtsHdFormat()))
}
/**
* Routes without the IEC carrier never had it to lose, and some of them bitstream raw DTS-HD
* genuinely; the pre-carrier behavior is preserved there.
*/
@Test
fun dtsHdWithoutACarrierRouteFallsThroughToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal, routeAvailable = false)
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
@Test
fun dtsExpressIsLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal)
val express = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_DTS_EXPRESS)
.setSampleRate(48_000)
.build()
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(express))
assertTrue(carrierSink.supportsFormat(express))
}
/** Downmix and normalization already force decoding; the carrier must not override that. */
@Test
fun blockedDirectOutputDeclinesTheCarrier() {
@@ -83,8 +148,8 @@ class TrueHdCarrierSinkTest {
}
/**
* 44.1kHz-family TrueHD rides a 176.4kHz carrier this path does not build. It has to be decided
* from the format: the packer only learns the rate from a major sync, long after selection, and
* 44.1kHz-family streams ride a 176.4kHz carrier this path does not build. It has to be decided
* from the format: the packer only learns the rate from the bitstream, long after selection, and
* selecting the carrier for it would produce silence.
*/
@Test
@@ -92,6 +157,8 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink()
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 44_100)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 176_400)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 44_100)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 88_200)))
}
/** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */
@@ -100,6 +167,7 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink()
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat()))
}
/**
@@ -183,6 +251,33 @@ class TrueHdCarrierSinkTest {
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
}
/**
* The DTS-HD equivalent: a bitstream whose framing the carrier cannot ride (an S/PDIF-style
* little-endian capture) must latch onto the decoder, and the offending unit must survive.
*/
@Test
fun aDtsHdFramingMismatchLeavesTheCarrierInsteadOfGoingSilent() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
val littleEndianCapture = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
val buffer = ByteBuffer.wrap(littleEndianCapture)
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
assertFalse("a mismatch must apply back pressure, not report success", accepted)
assertEquals("the offending unit must stay in the buffer", 0, buffer.position())
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
assertEquals(
"DTS-HD must now decode rather than ride the carrier or the raw path",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(dtsHdFormat())
)
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
}
/**
* media3 resets every renderer disabled by a new selection before enabling the replacement, and
* both audio renderers share this sink. That reset lands mid-handover, so a latch cleared there
@@ -278,7 +373,7 @@ class TrueHdCarrierSinkTest {
)
}
/** Everything that is not TrueHD keeps going to the existing processed sink. */
/** Everything that is not a carrier codec keeps going to the existing processed sink. */
@Test
fun otherFormatsAreLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
@@ -303,7 +398,7 @@ class TrueHdCarrierSinkTest {
// Refuse every third burst once, so rejections land inside samples rather than between them.
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink)
val produced = feedWholeStream(carrierSink, accessUnits)
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
}
@@ -315,7 +410,53 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig())
assertArrayEquals(golden, feedWholeStream(carrierSink))
assertArrayEquals(golden, feedWholeStream(carrierSink, accessUnits))
}
/** A DTS-HD stream must select the DTS packer and come out matching FFmpeg's bytes. */
@Test
fun theDtsHdCarrierOutputMatchesTheGoldenStream() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
assertArrayEquals(dtsHdGolden, feedWholeStream(carrierSink, dtsHdAccessUnits))
}
/** Back pressure must not cost DTS-HD units either; bursts and units are one-to-one there. */
@Test
fun aRefusedDtsHdBurstLosesNoAccessUnits() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink, dtsHdAccessUnits)
assertArrayEquals(dtsHdGolden, produced)
assertTrue(carrier.refusals > 0)
}
/**
* Burst timestamps come from the carrier cadence, not the closing access unit. A DTS-HD burst is
* 10666.67us of carrier — not a whole number — so the cadence must be derived from cumulative
* carrier frames; per-burst rounding would drift ~62ms over a movie.
*/
@Test
fun dtsHdBurstTimestampsFollowTheExactCarrierCadence() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
feedWholeStream(carrierSink, dtsHdAccessUnits)
val framesPerBurst = 32768L / IecCarrier.BYTES_PER_FRAME
carrier.bufferTimesUs.forEachIndexed { index, timeUs ->
assertEquals(
"burst $index timestamp",
index * framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE,
timeUs
)
}
}
/** A refused burst must be re-offered before any further input is taken. */
@@ -380,8 +521,23 @@ class TrueHdCarrierSinkTest {
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.format.sampleRate)
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.format.channelCount)
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
}
/** DTS-HD rides the same PCM-shaped carrier tuple TrueHD does. */
@Test
fun theDtsHdCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
val configured = checkNotNull(carrier.configuredConfig)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
}
@Test
@@ -399,18 +555,20 @@ class TrueHdCarrierSinkTest {
assertEquals(null, carrier.configuredConfig)
}
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
val buffer = ByteBuffer.wrap(accessUnits)
private fun feedWholeStream(carrierSink: IecCarrierSink, units: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(units)
var guard = 0
while (buffer.hasRemaining() && guard++ < 10_000) {
// media3 retries handleBuffer until it returns true, even once the buffer is fully consumed;
// a refusal of the stream's final burst leaves it pending with nothing remaining to read.
while ((buffer.hasRemaining() || carrierSink.hasPendingData()) && guard++ < 10_000) {
carrierSink.handleBuffer(buffer, 0L, 1)
}
val carrier = carrierSinkDelegate(carrierSink)
return carrier.written.toByteArray()
}
private fun carrierSinkDelegate(sink: TrueHdCarrierSink): FakeSink {
val field = TrueHdCarrierSink::class.java.getDeclaredField("carrierSink")
private fun carrierSinkDelegate(sink: IecCarrierSink): FakeSink {
val field = IecCarrierSink::class.java.getDeclaredField("carrierSink")
field.isAccessible = true
return field.get(sink) as FakeSink
}
@@ -430,6 +588,7 @@ class TrueHdCarrierSinkTest {
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
private class FakeSink : AudioSink {
val written = ByteArrayOutputStream()
val bufferTimesUs = mutableListOf<Long>()
var configuredConfig: AudioSink.AudioSinkConfig? = null
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
var lastVolume: Float = -1f
@@ -454,6 +613,7 @@ class TrueHdCarrierSinkTest {
val copy = ByteArray(buffer.remaining())
buffer.duplicate().get(copy)
written.write(copy)
bufferTimesUs.add(presentationTimeUs)
buffer.position(buffer.limit())
return true
}
@@ -27,8 +27,11 @@ import org.robolectric.annotation.Config
class MatroskaLateTracksTest {
private class CapturedTrack(val type: Int) : TrackOutput {
val timesUs = mutableListOf<Long>()
var format: Format? = null
var sampleCount = 0
val sampleCount: Int
get() = timesUs.size
override fun format(format: Format) {
this.format = format
@@ -41,16 +44,19 @@ class MatroskaLateTracksTest {
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
sampleCount++
timesUs.add(timeUs)
}
}
private class CapturingExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, CapturedTrack>()
val seekMaps = mutableListOf<SeekMap>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
override fun endTracks() = Unit
override fun seekMap(seekMap: SeekMap) = Unit
override fun seekMap(seekMap: SeekMap) {
seekMaps.add(seekMap)
}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
@@ -69,22 +75,15 @@ class MatroskaLateTracksTest {
"fixture matroska_tracks_at_end.mkv missing from test resources"
}.use { it.readBytes() }
private fun extractFixture(): CapturingExtractorOutput {
val data = fixtureData()
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = CapturingExtractorOutput()
extractor.init(output)
val reader = ByteArrayDataReader(data)
var input: ExtractorInput = DefaultExtractorInput(reader, 0, data.size.toLong())
private fun readToEnd(extractor: Extractor, reader: ByteArrayDataReader, length: Long) {
var input: ExtractorInput = DefaultExtractorInput(reader, reader.position, length)
val seekPosition = PositionHolder()
repeat(100_000) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return output
Extractor.RESULT_END_OF_INPUT -> return
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
input = DefaultExtractorInput(reader, seekPosition.position, length)
}
}
}
@@ -93,7 +92,13 @@ class MatroskaLateTracksTest {
@Test
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
val output = extractFixture()
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = CapturingExtractorOutput()
extractor.init(output)
val data = fixtureData()
readToEnd(extractor, ByteArrayDataReader(data), data.size.toLong())
assertEquals(2, output.tracks.size)
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
@@ -102,4 +107,48 @@ class MatroskaLateTracksTest {
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
}
}
/**
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for
* tracks-after-clusters files is before the Tracks element parsed — the map then permanently
* reports unseekable and resolves every seek to byte 0, snapping playback to the start
* (#1969; upstream androidx/media #3377).
* The production stack must repair it through the per-track cue lookups.
*/
@Test
fun seeksViaCuesWhenSeekMapIsBuiltBeforeTracks() {
val assHandler = AssHandler()
val extractor = CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
val output = CapturingExtractorOutput()
extractor.init(output)
val data = fixtureData()
val reader = ByteArrayDataReader(data)
readToEnd(extractor, reader, data.size.toLong())
assertEquals(1, output.seekMaps.size)
val seekMap = output.seekMaps.single()
assertTrue("tracks-after-clusters file with Cues must be seekable", seekMap.isSeekable)
val points = seekMap.getSeekPoints(500_000L)
// Cue-based resolution snaps to the fixture's only cue point (t=0); a byte-proportional
// estimate would return the requested time instead.
assertEquals(0L, points.first.timeUs)
val position = points.first.position
val clusterId = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
assertTrue(
"seek position $position must point at a Cluster element",
data.copyOfRange(position.toInt(), position.toInt() + 4).contentEquals(clusterId)
)
output.tracks.values.forEach { it.timesUs.clear() }
extractor.seek(position, points.first.timeUs)
reader.position = position
readToEnd(extractor, reader, data.size.toLong())
output.tracks.values.forEach { track ->
assertTrue("expected extracted samples after seek for track type ${track.type}", track.sampleCount > 0)
assertEquals(points.first.timeUs, track.timesUs.first())
}
}
}
@@ -67,8 +67,8 @@ class TrueHdMatPackerTest {
/** A burst is exactly 20ms of carrier, which is what makes the PCM-domain accounting downstream correct. */
@Test
fun aBurstIsTwentyMillisecondsOfCarrier() {
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME
val durationUs = framesPerBurst * 1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / IecCarrier.BYTES_PER_FRAME
val durationUs = framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE
assertEquals(20_000L, durationUs)
}
@@ -166,11 +166,11 @@ class TrueHdMatPackerTest {
val length = TrueHdMatPacker.accessUnitLength(units, 0, units.size)
packer.packAccessUnit(units, 0, length)
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedRateFamily)
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedStream)
packer.reset()
assertFalse("reset must clear the flag", packer.unsupportedRateFamily)
assertFalse("reset must clear the flag", packer.unsupportedStream)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
@@ -0,0 +1,80 @@
package com.edde746.plezy.exoplayer
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.DeflaterOutputStream
import java.util.zip.Inflater
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint,
* timecode, flags) must survive byte-identically, the payload must inflate, and
* every block the rewriter cannot prove safe must pass through unchanged (null).
*/
class ZlibTextBlockTest {
private val inflater = Inflater()
private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload
private fun deflate(bytes: ByteArray): ByteArray {
val target = ByteArrayOutputStream()
DeflaterOutputStream(target, Deflater()).use { it.write(bytes) }
return target.toByteArray()
}
// Track 3 (varint 0x83), relative timecode 0x0102, no lacing.
private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00)
@Test
fun inflatesUnlacedBlockPayloadAndPreservesHeader() {
val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray()
val data = block(unlacedHeader, deflate(dialogue))
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
assertArrayEquals(unlacedHeader + dialogue, rewritten)
}
@Test
fun twoByteTrackNumberVarintIsPreserved() {
val dialogue = "1,,Default,,0,0,0,,hello".toByteArray()
val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298
val data = block(header, deflate(dialogue))
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
assertArrayEquals(header + dialogue, rewritten)
}
@Test
fun lacedBlockPassesThrough() {
val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set
val data = block(laced, deflate("payload".toByteArray()))
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
}
@Test
fun corruptStreamPassesThrough() {
val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
}
@Test
fun truncatedStreamPassesThrough() {
val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray())
val truncated = compressed.copyOf(compressed.size - 4)
val data = block(unlacedHeader, truncated)
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
}
@Test
fun headerOnlyBlockPassesThrough() {
assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater))
}
}
@@ -0,0 +1,170 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.text.CueDecoder
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import com.edde746.plezy.libass.media.AssHandler
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with
* `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that
* broke #2023) and an identical uncompressed mux — and verifies the zlib block
* payloads are inflated *before* MatroskaExtractor's subtitle sample assembly.
*
* Regenerate with:
* mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass
* mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass
* where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events.
*
* Robolectric provides real android.util/android.os implementations —
* MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which
* are no-op stubs on plain JVM.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ZlibTextSubtitleExtractionTest {
private class CapturedSample(val timeUs: Long, val data: ByteArray)
private class FakeTrackOutput : TrackOutput {
val formats = mutableListOf<androidx.media3.common.Format>()
val samples = mutableListOf<CapturedSample>()
private var buf = ByteArray(64 * 1024)
private var bufLen = 0
override fun format(format: androidx.media3.common.Format) {
formats.add(format)
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
ensureCapacity(bufLen + length)
val read = input.read(buf, bufLen, length)
if (read > 0) bufLen += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
ensureCapacity(bufLen + length)
data.readBytes(buf, bufLen, length)
bufLen += length
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
val start = bufLen - offset - size
samples.add(CapturedSample(timeUs, buf.copyOfRange(start, start + size)))
if (offset == 0) bufLen = 0
}
private fun ensureCapacity(needed: Int) {
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
}
}
private class FakeExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, FakeTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
override fun endTracks() {}
override fun seekMap(seekMap: SeekMap) {}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= data.size) return C.RESULT_END_OF_INPUT
val toRead = minOf(length, data.size - position.toInt())
System.arraycopy(data, position.toInt(), buffer, offset, toRead)
position += toRead
return toRead
}
}
private fun extract(resource: String): FakeExtractorOutput {
val data = checkNotNull(javaClass.getResourceAsStream(resource)) {
"fixture $resource missing from test resources"
}.use { it.readBytes() }
// DefaultSubtitleParserFactory (not AssSubtitleParserFactory) so the SSA track
// transcodes to decodable media3 cues instead of loading native libass.
val extractor = ZlibMatroskaExtractor(DefaultSubtitleParserFactory(), AssHandler())
val output = FakeExtractorOutput()
extractor.init(output)
val reader = ByteArrayDataReader(data)
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
val seekPosition = PositionHolder()
while (true) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return output
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
}
else -> {}
}
}
}
private class DecodedCue(val startTimeUs: Long, val durationUs: Long, val text: String)
private fun decodeCues(output: FakeExtractorOutput): List<DecodedCue> {
val track = output.tracks.values.single()
val format = track.formats.last()
assertEquals(MimeTypes.APPLICATION_MEDIA3_CUES, format.sampleMimeType)
assertEquals(MimeTypes.TEXT_SSA, format.codecs)
val decoder = CueDecoder()
return track.samples.map { sample ->
val cues = decoder.decode(sample.timeUs, sample.data, 0, sample.data.size)
DecodedCue(
cues.startTimeUs,
cues.durationUs,
cues.cues.joinToString("\n") { it.text.toString() }
)
}
}
@Test
fun zlibCompressedAssDialogueIsInflatedToParseableCues() {
val cues = decodeCues(extract("/zlib_ssa.mkv"))
assertEquals(45, cues.size)
assertEquals("SUBTITLE LINE 1 (0:00:00.00)", cues.first().text)
assertEquals(0L, cues.first().startTimeUs)
assertEquals(2_000_000L, cues.first().durationUs)
assertEquals("SUBTITLE LINE 45 (0:01:28.00)", cues.last().text)
assertEquals(88_000_000L, cues.last().startTimeUs)
cues.forEachIndexed { index, cue ->
assertEquals(index * 2_000_000L, cue.startTimeUs)
assertTrue("cue $index text: ${cue.text}", cue.text.startsWith("SUBTITLE LINE ${index + 1} "))
}
}
@Test
fun zlibAndPlainMuxesProduceIdenticalCues() {
val zlib = decodeCues(extract("/zlib_ssa.mkv"))
val plain = decodeCues(extract("/plain_ssa.mkv"))
assertEquals(plain.size, zlib.size)
plain.zip(zlib).forEachIndexed { index, (expected, actual) ->
assertEquals("startTimeUs of cue $index", expected.startTimeUs, actual.startTimeUs)
assertEquals("durationUs of cue $index", expected.durationUs, actual.durationUs)
assertEquals("text of cue $index", expected.text, actual.text)
}
}
}
@@ -0,0 +1,38 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Boundaries are the contract; rationale on [DemuxerBudget]. */
class DemuxerBudgetTest {
private val mib = 1024L * 1024L
@Test
fun `unknown heap class keeps mpv defaults`() {
assertNull(DemuxerBudget.forHeapClassMB(0))
assertNull(DemuxerBudget.forHeapClassMB(-1))
}
@Test
fun `small heap class gets the tight tier`() {
val budget = DemuxerBudget.forHeapClassMB(256)!!
assertEquals(32 * mib, budget.aheadBytes)
assertEquals(16 * mib, budget.backBytes)
}
@Test
fun `mid heap class gets the middle tier`() {
assertEquals(64 * mib, DemuxerBudget.forHeapClassMB(257)!!.aheadBytes)
val budget = DemuxerBudget.forHeapClassMB(512)!!
assertEquals(64 * mib, budget.aheadBytes)
assertEquals(32 * mib, budget.backBytes)
}
@Test
fun `large heap class gets the full tier`() {
val budget = DemuxerBudget.forHeapClassMB(513)!!
assertEquals(100 * mib, budget.aheadBytes)
assertEquals(48 * mib, budget.backBytes)
}
}
@@ -0,0 +1,114 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* P5 has no compatible base layer: on a device without native DV it must
* leave the video plane for gpu-next software reshaping, and nothing else
* may be routed (wrong-colors class verified on a Pixel 7). The vo target
* matrix keeps gpu-next away from hardware sessions (#2010) while giving
* the reshaping path the only vo that composites RPU metadata (#1902).
*/
class GpuVoPolicyTest {
@Test
fun `P5 without native support is routed`() {
assertTrue(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = false))
}
@Test
fun `P5 with native support stays on the plane`() {
assertFalse(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = true))
}
@Test
fun `base-layer-compatible profiles are never routed`() {
// P7/P8 strip to an HDR10/HLG base layer; non-DV content has no profile.
assertFalse(GpuVoPolicy.needsDvReshaping(7L, "auto", canPlayP5Natively = false))
assertFalse(GpuVoPolicy.needsDvReshaping(8L, "auto", canPlayP5Natively = false))
assertFalse(GpuVoPolicy.needsDvReshaping(null, "auto", canPlayP5Natively = false))
}
@Test
fun `explicit conversion modes are user overrides and stay native`() {
for (mode in listOf("disabled", "native", "dv81", "hevc", "hevc_strip")) {
assertFalse(mode, GpuVoPolicy.needsDvReshaping(5L, mode, canPlayP5Natively = false))
}
}
@Test
fun `hdr tone-mapping is needed only for a PQ or HLG signal on a non-HDR display`() {
assertTrue(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = false))
assertTrue(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = false))
// An HDR display scans the signal out itself.
assertFalse(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = true))
assertFalse(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = true))
// SDR transfers need no mapping, and mpv reports none before the first
// frame of a file.
assertFalse(GpuVoPolicy.needsHdrToneMapping("bt.1886", displaySupportsHdr = false))
assertFalse(GpuVoPolicy.needsHdrToneMapping("srgb", displaySupportsHdr = false))
assertFalse(GpuVoPolicy.needsHdrToneMapping(null, displaySupportsHdr = false))
assertFalse(GpuVoPolicy.needsHdrToneMapping("", displaySupportsHdr = false))
}
@Test
fun `only direct mediacodec output can stay on the plane`() {
// -copy also reads frames back into system memory, so it leaves too.
assertTrue(GpuVoPolicy.needsSoftwareRender("no"))
assertTrue(GpuVoPolicy.needsSoftwareRender("mediacodec-copy"))
assertFalse(GpuVoPolicy.needsSoftwareRender("mediacodec"))
// Unreported until the decoder initializes: stay on the plane.
assertFalse(GpuVoPolicy.needsSoftwareRender(null))
assertFalse(GpuVoPolicy.needsSoftwareRender(""))
}
@Test
fun `a software-decoding session targets gpu, not gpu-next`() {
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SW_DECODE)))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
assertEquals(
"gpu",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_SW_DECODE))
)
}
@Test
fun `no reasons keeps the video plane`() {
assertNull(GpuVoPolicy.targetFor(emptySet()))
}
@Test
fun `dv reshaping targets gpu-next even alongside other reasons`() {
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
assertEquals(
"gpu-next",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_DV_RESHAPE))
)
}
@Test
fun `shaders and chain failure target the hardware-safe gpu vo`() {
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CHAIN_FAILURE)))
assertEquals(
"gpu",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_CHAIN_FAILURE))
)
}
@Test
fun `hdr tone-mapping targets gpu but yields to dv reshaping`() {
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR)))
assertEquals(
"gpu",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_SHADERS))
)
assertEquals(
"gpu-next",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_DV_RESHAPE))
)
}
}
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -48,6 +49,40 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
@Test
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
// with no context to inspect the audio route the only safe answer is "" (#1703, #1991).
val result = RecordingResult()
MpvPlayerPlugin().onMethodCall(MethodCall("getAudioSpdifCodecs", null), result)
assertEquals("", result.successValue)
assertNull(result.errorCode)
assertEquals(1, result.completionCount)
}
@Test
fun hardwareDecodeSessionsUseTheMediaCodecVoWithGpuFallback() {
// Rationale on MpvPlayerCore.initialVideoOutput.
assertEquals("mediacodec,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
assertEquals("gpu,gpu-next", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
}
@Test
fun hdrSurfaceIsWantedOnlyForPqAndHlgTransfers() {
// Rationale on MpvPlayerCore.wantsHdrSurface: both render into a PQ
// target; anything else stays on the sRGB surface, which renders every
// content correctly.
assertTrue(MpvPlayerCore.wantsHdrSurface("smpte2084"))
assertTrue(MpvPlayerCore.wantsHdrSurface("arib-std-b67"))
assertFalse(MpvPlayerCore.wantsHdrSurface("bt709"))
assertFalse(MpvPlayerCore.wantsHdrSurface("bt1886"))
assertFalse(MpvPlayerCore.wantsHdrSurface("unknown"))
assertFalse(MpvPlayerCore.wantsHdrSurface(""))
assertFalse(MpvPlayerCore.wantsHdrSurface(null))
}
@Test
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
@@ -598,6 +633,53 @@ class MpvPlayerPluginTest {
assertEquals(0, pending.size)
}
@Test
fun staleDisposeIsAcknowledgedWithoutTearingDownTheCore() {
// A dispose whose instanceId is not the core creator's lost the ownership
// race to a successor; tearing the core down anyway would kill that
// successor's session. It must be acknowledged as a no-op instead.
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 2L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 1)), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNotNull(getPluginField(plugin, "playerCore"))
assertEquals(2L, getPluginField(plugin, "coreInstanceId"))
}
@Test
fun matchingDisposeTearsDownTheCoreAndClearsTheToken() {
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 7L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 7)), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNull(getPluginField(plugin, "playerCore"))
assertNull(getPluginField(plugin, "coreInstanceId"))
}
@Test
fun tokenlessDisposeKeepsLegacySemanticsAndTearsDownTheCore() {
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 7L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", null), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNull(getPluginField(plugin, "playerCore"))
}
@Test
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -867,6 +949,97 @@ class MpvPlayerPluginTest {
getBoolean(core)
}
private fun invokeSetGpuVoRequirement(core: MpvPlayerCore, reason: String, active: Boolean) {
MpvPlayerCore::class.java
.getDeclaredMethod("setGpuVoRequirement", String::class.java, Boolean::class.javaPrimitiveType)
.apply {
isAccessible = true
invoke(core, reason, active)
}
}
@Test
fun voTargetFollowsTheActiveReasonSet() {
// Reason precedence through the real property-write path: DV reshaping
// outranks HDR-on-SDR, dropping the winner falls back to the reason still
// active, and dropping the last one returns the session to the plane.
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
writes.add(name to value)
})
setBoolean(core, "isInitialized", true)
fun lastVo(): String? = writes.toList().lastOrNull { it.first == "vo" }?.second
// HDR-on-SDR raises first, then the DV router wins the arbitration.
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, true)
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, true)
awaitCondition { lastVo() == "gpu-next" }
assertEquals("gpu-next", lastVo())
// Dropping the winning reason must fall back to the one still active,
// not to the plane.
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, false)
awaitCondition { lastVo() == "gpu" }
assertEquals("gpu", lastVo())
// Last reason dropping returns the session to the video plane.
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, false)
awaitCondition { lastVo() == "mediacodec" }
assertEquals("mediacodec", lastVo())
}
@Test
fun dvConversionModeMapsOntoForkDecoderOptions() {
// The app-level `dv-conversion-mode` property must translate to the fork
// FFmpeg hevc_mediacodec options, mirroring the ExoPlayer DoviBridge
// modes. Robolectric reports no Dolby Vision display, so `auto` takes the
// no-DV branch deterministically.
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
writes.add(name to value)
})
fun apply(mode: String): Result<Unit> {
writes.clear()
var outcome: Result<Unit>? = null
core.setProperty("dv-conversion-mode", mode) { outcome = it }
awaitCondition { outcome != null }
return outcome!!
}
assertTrue(apply("auto").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=0,dv_p7_mode=strip"), writes.toList())
assertTrue(apply("disabled").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=native"), writes.toList())
assertTrue(apply("dv81").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=convert"), writes.toList())
assertTrue(apply("hevc_strip").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=strip"), writes.toList())
val invalid = apply("bogus")
assertTrue(invalid.isFailure)
assertTrue(writes.isEmpty())
}
@Test
fun decoderOptionsMergeKeepsUserEntriesFirst() {
// The property interface cannot append to a list option, so the write
// replaces it wholesale: a user's own mpv.conf `vd-lavc-o` entries must
// survive, with the app's keys last (FFmpeg keeps the last duplicate).
assertEquals(
"threads=4,dolby_vision=1",
MpvPlayerCore.mergeDecoderOptions("threads=4", "dolby_vision=1")
)
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(null, "dolby_vision=1"))
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(" ", "dolby_vision=1"))
}
private fun awaitQueueEntry(
queue: ConcurrentLinkedQueue<Pair<String, String>>,
expected: Pair<String, String>
@@ -0,0 +1,72 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoRectPolicyTest {
@Test
fun `contain letterboxes wide content inside the container`() {
// 2.39:1 content on a 16:9 container: full width, bars top and bottom.
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
assertEquals(1920, size.width)
assertEquals(803, size.height)
assertTrue(size.height < 1080)
}
@Test
fun `contain pillarboxes tall content inside the container`() {
val size = VideoRectPolicy.sizeFor(1920, 1080, 1000, 2390)!!
assertEquals(1080, size.height)
assertTrue(size.width < 1920)
}
@Test
fun `panscan 1 fills the container so the image is cropped, not letterboxed`() {
// The regression this exists for: cover used to be a no-op on the plane.
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
assertTrue("cover must reach the container height", size.height >= 1080)
assertTrue("width must overflow and be clipped", size.width > 1920)
}
@Test
fun `panscan interpolates between contain and cover`() {
val contain = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
val half = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 0.5f)!!
val cover = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
assertTrue(half.height > contain.height)
assertTrue(half.height < cover.height)
}
@Test
fun `video-zoom is a log2 factor on top of the fit`() {
val base = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
val doubled = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 1f)!!
val halved = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = -1f)!!
assertEquals(base.width * 2, doubled.width)
assertEquals(base.width / 2, halved.width)
}
@Test
fun `cover fills a container whose aspect is wildly mismatched`() {
// Scope content in a portrait container: cover/fit exceeds 4, which a
// fit-relative clamp would truncate back into letterboxing.
val size = VideoRectPolicy.sizeFor(1080, 2400, 2390, 1000, panscan = 1f)!!
assertTrue("height must reach the container", size.height >= 2400)
}
@Test
fun `an absurd zoom is bounded rather than allocating an unbounded surface`() {
val fit = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
val absurd = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 12f)!!
assertEquals(fit.width * 4, absurd.width)
}
@Test
fun `unknown dimensions yield no size instead of a degenerate one`() {
assertNull(VideoRectPolicy.sizeFor(0, 1080, 1920, 1080))
assertNull(VideoRectPolicy.sizeFor(1920, 1080, 0, 0))
assertNull(VideoRectPolicy.sizeFor(1920, 0, 1920, 1080))
}
}
@@ -0,0 +1,149 @@
package com.edde746.plezy.shared
import com.edde746.plezy.shared.DisplayModeSelector.ModeInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class DisplayModeSelectorTest {
// A typical 4K TV: panel-native modes plus lower-resolution HDMI modes.
private val uhd60 = ModeInfo(1, 3840, 2160, 60f)
private val uhd50 = ModeInfo(2, 3840, 2160, 50f)
private val uhd24 = ModeInfo(3, 3840, 2160, 23.976f)
private val fhd60 = ModeInfo(4, 1920, 1080, 60f)
private val fhd50 = ModeInfo(5, 1920, 1080, 50f)
private val fhd24 = ModeInfo(6, 1920, 1080, 23.976f)
private val hd60 = ModeInfo(7, 1280, 720, 60f)
private val sd60 = ModeInfo(8, 720, 480, 60f)
private val allModes = listOf(uhd60, uhd50, uhd24, fhd60, fhd50, fhd24, hd60, sd60)
private fun select(
fps: Float,
current: ModeInfo = uhd60,
modes: List<ModeInfo> = allModes,
videoWidth: Int = 0,
videoHeight: Int = 0,
matchResolution: Boolean = false
) = DisplayModeSelector.findBestMode(fps, current, modes, videoWidth, videoHeight, matchResolution)
// --- Resolution matching ---
@Test
fun resolutionMatchingPicksNativeResolutionAndRate() {
val selection = select(23.976f, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
assertEquals(fhd24, selection?.mode)
}
@Test
fun resolutionOnlyRequestKeepsCurrentRefreshRate() {
val selection = select(0f, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
assertEquals(fhd60, selection?.mode)
}
@Test
fun resolutionMatchingNeverDownscalesTheVideo() {
// 1080p-class anamorphic content is wider than 720p even though shorter:
// the smallest containing mode is 1080p, not 720p.
val selection = select(0f, videoWidth = 1920, videoHeight = 800, matchResolution = true)
assertEquals(fhd60, selection?.mode)
}
@Test
fun resolutionWinsOverCadenceWhenNativeResolutionHasNoMatchingRate() {
// No 720p24 mode exists; the 720p video still lands on 720p (TV upscales)
// instead of widening back out to a 24 Hz mode at another resolution.
val selection = select(23.976f, videoWidth = 1280, videoHeight = 720, matchResolution = true)
assertEquals(hd60, selection?.mode)
}
@Test
fun panelNativeContentStaysAtPanelResolution() {
val selection = select(23.976f, videoWidth = 3840, videoHeight = 2160, matchResolution = true)
assertEquals(uhd24, selection?.mode)
}
@Test
fun panelNativeResolutionOnlyRequestNeedsNoSwitch() {
val selection = select(0f, videoWidth = 3840, videoHeight = 2160, matchResolution = true)
assertEquals(uhd60, selection?.mode) // caller sees modeId == current and skips
}
@Test
fun sourceLargerThanPanelFallsBackToCadencePolicy() {
val selection = select(23.976f, videoWidth = 7680, videoHeight = 4320, matchResolution = true)
assertEquals(uhd24, selection?.mode) // Tier-1 refresh-only switch
}
@Test
fun sourceLargerThanPanelWithoutFpsHasNoTarget() {
assertNull(select(0f, videoWidth = 7680, videoHeight = 4320, matchResolution = true))
}
@Test
fun resolutionMatchingWithoutDimensionsBehavesLikeCadenceOnly() {
val selection = select(23.976f, matchResolution = true)
assertEquals(uhd24, selection?.mode)
}
@Test
fun resolutionOnlyPrefersRateClosestToCurrent() {
// From a 50 Hz current mode, a resolution-only switch keeps 50 Hz.
val selection = select(0f, current = uhd50, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
assertEquals(fhd50, selection?.mode)
}
// --- Cadence-only policy (matchResolution off): pre-existing behaviour ---
@Test
fun cadenceMatchingStaysAtCurrentResolution() {
val selection = select(23.976f, videoWidth = 1920, videoHeight = 1080)
assertEquals(uhd24, selection?.mode)
}
@Test
fun cadenceTierTwoAllowsResolutionChangeButNeverBelowVideo() {
// Panel has no 4K@24; only 1080p@24 remains for 1080p content.
val modes = listOf(uhd60, uhd50, fhd60, fhd24, hd60)
val selection = select(23.976f, modes = modes, videoWidth = 1920, videoHeight = 1080)
assertEquals(fhd24, selection?.mode)
}
@Test
fun cadenceTierTwoRequiresKnownDimensions() {
val modes = listOf(uhd60, uhd50, fhd60, fhd24, hd60)
assertNull(select(23.976f, modes = modes))
}
@Test
fun invalidFpsWithoutResolutionRequestHasNoTarget() {
assertNull(select(0f, videoWidth = 1920, videoHeight = 1080))
}
@Test
fun multipleRateCountsAsCadenceMatch() {
// 30 fps on a 60 Hz mode is a clean 2x pulldown; current 60 Hz mode wins.
val selection = select(29.97f, current = uhd60, modes = listOf(uhd60, uhd50))
assertNotNull(selection)
assertEquals(uhd60, selection?.mode)
}
@Test
fun exactRateBeatsMultipleRate() {
val uhd30 = ModeInfo(9, 3840, 2160, 29.97f)
val selection = select(29.97f, modes = allModes + uhd30)
assertEquals(uhd30, selection?.mode)
}
// --- matchRefreshRate ---
@Test
fun refreshRateMatchClassifiesExactMultipleAndMiss() {
assertEquals(0, DisplayModeSelector.matchRefreshRate(23.976f, 23.976f)?.priority)
assertEquals(1, DisplayModeSelector.matchRefreshRate(59.94f, 29.97f)?.priority)
assertNull(DisplayModeSelector.matchRefreshRate(60f, 23.976f))
assertNull(DisplayModeSelector.matchRefreshRate(60f, 0f))
assertNull(DisplayModeSelector.matchRefreshRate(0f, 24f))
}
}
@@ -0,0 +1,95 @@
package com.edde746.plezy.shared
import android.app.Activity
import android.os.Handler
import android.os.Looper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import java.time.Duration
/**
* Teardown ordering for frame-rate matching (#2172): restoring the display
* mode while the display is still signaling HDR folds the HDR exit and the
* mode change into one slow HDMI renegotiation. An HDR session therefore
* defers the restore; an SDR session restores immediately.
*/
@RunWith(RobolectricTestRunner::class)
class FrameRateManagerRestoreTest {
private fun buildManager(activity: Activity): FrameRateManager =
FrameRateManager(activity, Handler(Looper.getMainLooper()))
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
private fun applyPreferredMode(activity: Activity, modeId: Int) {
val attrs = activity.window.attributes
attrs.preferredDisplayModeId = modeId
activity.window.attributes = attrs
}
@Test
fun sdrClearRestoresTheDefaultModeImmediately() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 4)
manager.clearVideoFrameRate(hdrActive = false)
assertEquals(0, preferredModeId(activity))
}
@Test
fun hdrClearDefersTheRestoreUntilTheHdrExitSettles() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 4)
manager.clearVideoFrameRate(hdrActive = true)
// Still at the content mode: the surface teardown must commit the HDR
// exit before the mode change renegotiates the link.
assertEquals(4, preferredModeId(activity))
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
assertEquals(0, preferredModeId(activity))
}
@Test
fun newSwitchRequestCancelsAPendingDeferredRestore() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 4)
manager.clearVideoFrameRate(hdrActive = true)
// A new session's request arrives before the deferred restore fires.
manager.setVideoFrameRate(fps = 23.976f, videoDurationMs = 0L, extraDelayMs = 0L) { }
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
// The stale restore must not clobber whatever the new request applied.
// (Robolectric's display has no matching mode, so the request itself
// leaves the window untouched; only the cancelled restore could zero it.)
assertNotEquals(0, preferredModeId(activity))
}
@Test
fun hdrClearWithNoAppliedModePostsNoRestore() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 7)
// Simulate "nothing applied by us": manager sees modeId 0.
applyPreferredMode(activity, 0)
manager.clearVideoFrameRate(hdrActive = true)
// A later, externally applied mode must not be clobbered by a stale
// deferred restore from a session that never switched.
applyPreferredMode(activity, 7)
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
assertEquals(7, preferredModeId(activity))
}
}
@@ -1,5 +1,6 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -60,4 +61,18 @@ class MediaCodecQueryTest {
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
}
@Test
fun mapsGatedCodecsToTheMimeTypesDecodersAdvertise() {
assertEquals(
mapOf("hevc" to true, "av1" to true),
MediaCodecQuery.hardwareVideoDecodeSupport(
setOf("video/avc", "video/hevc", "video/av01", "audio/mp4a-latm")
)
)
assertEquals(
mapOf("hevc" to true, "av1" to false),
MediaCodecQuery.hardwareVideoDecodeSupport(setOf("video/avc", "video/hevc"))
)
}
}
@@ -4,6 +4,7 @@ import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import org.junit.Assert.assertEquals
@@ -41,4 +42,27 @@ class PlayerSurfaceHostTest {
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
}
@Test
fun letterboxAreaIsCoveredByABufferlessPunchSurfaceBeneathTheVideo() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
}
val container = PlayerSurfaceHost.createContainer(activity)
val punch = container.getChildAt(0)
assertTrue(punch is SurfaceView)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.height)
// Cores append their surfaces after createContainer, so video (and the mpv
// OSD plane) always land above the punch layer in drawing order.
val video = PlayerSurfaceHost.createVideoSurface(activity, callback)
container.addView(video)
assertTrue(container.indexOfChild(video) > container.indexOfChild(punch))
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Regenerates the DTS-HD MA packer fixtures (#1988).
FFmpeg has no DTS-HD MA encoder, so the access-unit fixture is synthesized: real
DTS core frames from FFmpeg's `dca` encoder, each followed by a hand-built
extension substream (EXSS) whose header fields are coherent enough for FFmpeg's
DCA parser to glue core+EXSS into one access unit. The IEC 61937 wrapping never
inspects EXSS payload bytes, only the sync word and size fields, so this pins
the same wire format genuine Master Audio does.
One mid-stream access unit is oversized so `ffmpeg -f spdif` overflows the
burst and strips to core-only for `dtshd_fallback_time` (60s, i.e. the rest of
the fixture) — the golden therefore also pins the strip-and-hold behavior.
Usage (from this directory):
ffmpeg -y -f lavfi -i "aevalsrc=0.3*sin(2*PI*440*t)|0.3*sin(2*PI*554*t)|0.3*sin(2*PI*659*t)|0.2*sin(2*PI*220*t)|0.25*sin(2*PI*330*t)|0.25*sin(2*PI*392*t):s=48000:d=0.15" \
-c:a dca -strict experimental -f dts /tmp/dts_core.dts
python3 dtshd_fixture_generator.py /tmp/dts_core.dts
ffmpeg -y -f dts -i dtshd_ma_access_units.bin -c:a copy \
-f spdif -dtshd_rate 768000 dtshd_ma_iec61937_golden.bin
"""
import struct
import sys
CORE_SYNC = 0x7FFE8001
EXSS_SYNC = 0x64582025
# Payload bytes past which a 32768-byte burst (512 samples at 48kHz on the
# 768kHz HBR carrier) overflows: 32768 - 8 preamble - 12 start code/length.
BURST_CAPACITY = 32768 - 8 - 12
NORMAL_EXSS_SIZE = 2020
# Large enough that core (1884) + EXSS exceeds the burst capacity.
OVERSIZED_EXSS_SIZE = BURST_CAPACITY
OVERSIZED_AU_INDEX = 4
def build_exss(size: int) -> bytes:
"""A minimal EXSS frame: sync, then the narrow (bHeaderSizeType=0) header.
Bit layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
bHeaderSizeType(1), nuBits4Header(8) = header bytes - 1,
nuBits4ExSSFsize(16) = frame bytes - 1. Everything else is padding the
parser and packer never read.
"""
header_size = 16
bits = 0
bits = (bits << 8) | 0 # UserDefinedBits
bits = (bits << 2) | 0 # nExtSSIndex
bits = (bits << 1) | 0 # bHeaderSizeType
bits = (bits << 8) | (header_size - 1)
bits = (bits << 16) | (size - 1)
packed = bits.to_bytes(5, "big") # 35 bits, left-aligned below
body = bytearray(size)
struct.pack_into(">I", body, 0, EXSS_SYNC)
shifted = int.from_bytes(packed, "big") << (40 - 35)
body[4:9] = shifted.to_bytes(5, "big")
return bytes(body)
def main() -> None:
core = open(sys.argv[1], "rb").read()
out = bytearray()
offset = 0
index = 0
while offset + 9 <= len(core):
sync = struct.unpack_from(">I", core, offset)[0]
assert sync == CORE_SYNC, hex(sync)
b24 = (core[offset + 5] << 16) | (core[offset + 6] << 8) | core[offset + 7]
fsize = ((b24 >> 4) & 0x3FFF) + 1
out += core[offset : offset + fsize]
exss_size = OVERSIZED_EXSS_SIZE if index == OVERSIZED_AU_INDEX else NORMAL_EXSS_SIZE
out += build_exss(exss_size)
offset += fsize
index += 1
open("dtshd_ma_access_units.bin", "wb").write(out)
print(f"{index} access units, {len(out)} bytes")
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/universal_gamepad/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
+6 -2
View File
@@ -25,6 +25,7 @@
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
@@ -80,6 +81,7 @@
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecodeCapabilitiesPlugin.swift; sourceTree = "<group>"; };
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
@@ -197,6 +199,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -430,6 +433,7 @@
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -797,8 +801,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = exactVersion;
version = 1.0.21;
kind = revision;
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -32,8 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "1b8e5b2ba83f0b62c0eab6e90f36fa3dc5b4038f",
"version" : "1.0.21"
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
}
},
{
@@ -32,8 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "1b8e5b2ba83f0b62c0eab6e90f36fa3dc5b4038f",
"version" : "1.0.21"
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
}
},
{
+4
View File
@@ -38,6 +38,10 @@ import MediaPlayer
MpvAudioPlayerPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "VideoDecodeCapabilitiesPlugin") {
VideoDecodeCapabilitiesPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeviceAdjustmentChannel") {
registerDeviceAdjustmentChannel(messenger: registrar.messenger())
}
@@ -0,0 +1,32 @@
import CoreMedia
import Flutter
import VideoToolbox
/// Answers `com.plezy/device`'s `getVideoDecodeCapabilities` on iOS and tvOS.
///
/// Compiled into the iOS and tvOS Runners only — macOS keeps its own sources
/// and is deliberately left unprobed, because desktop CPUs software-decode
/// both codecs in real time and narrowing the profile there would force
/// transcodes for nothing.
class VideoDecodeCapabilitiesPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.plezy/device",
binaryMessenger: registrar.messenger()
)
registrar.addMethodCallDelegate(VideoDecodeCapabilitiesPlugin(), channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getVideoDecodeCapabilities":
result([
"hevc": VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC),
"av1": VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1),
])
default:
// The rest of `com.plezy/device` is Android-only.
result(FlutterMethodNotImplemented)
}
}
}
+6 -67
View File
@@ -5,50 +5,6 @@ import '../services/plex_auth_service.dart';
import '../utils/json_utils.dart';
import '../utils/url_utils.dart';
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
/// [MediaBackend] for places that only care about persistence/auth shape
/// (e.g. database column values).
enum ConnectionKind {
plex,
jellyfin,
emby;
String get id => switch (this) {
ConnectionKind.plex => 'plex',
ConnectionKind.jellyfin => 'jellyfin',
ConnectionKind.emby => 'emby',
};
static ConnectionKind fromId(String id) => switch (id) {
'plex' => ConnectionKind.plex,
'jellyfin' => ConnectionKind.jellyfin,
'emby' => ConnectionKind.emby,
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
};
MediaBackend get backend => switch (this) {
ConnectionKind.plex => MediaBackend.plex,
ConnectionKind.jellyfin => MediaBackend.jellyfin,
ConnectionKind.emby => MediaBackend.emby,
};
/// The MediaBrowser dialect this kind speaks, or `null` for Plex.
MediaBrowserDialect? get dialect => switch (this) {
ConnectionKind.plex => null,
ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin,
ConnectionKind.emby => MediaBrowserDialect.emby,
};
static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) {
MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin,
MediaBrowserDialect.emby => ConnectionKind.emby,
};
}
/// Health snapshot for a connection. Updated by the orchestrator each time a
/// session is established or refreshed.
enum ConnectionStatus { unknown, online, offline, authError, disabled }
/// A media server connection — a unit of authentication the user added.
///
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
@@ -56,15 +12,14 @@ enum ConnectionStatus { unknown, online, offline, authError, disabled }
/// user. Most users only ever add one connection.
sealed class Connection {
String get id;
ConnectionKind get kind;
MediaBackend get kind;
String get displayName;
ConnectionStatus get status;
DateTime get createdAt;
DateTime? get lastAuthenticatedAt;
/// Backend kind as a [MediaBackend] — for UI that branches on backend
/// (badges, etc.). Just a passthrough to [kind.backend].
MediaBackend get backend => kind.backend;
/// Alias for [kind] kept for UI call sites that read "backend"
/// (badges, etc.).
MediaBackend get backend => kind;
/// Primary label shown in connection-list UIs. Plex shows the active
/// profile/account name; Jellyfin shows the server name.
@@ -90,9 +45,6 @@ class PlexAccountConnection extends Connection {
@override
final String id;
@override
final ConnectionStatus status;
@override
final DateTime createdAt;
@@ -123,13 +75,12 @@ class PlexAccountConnection extends Connection {
required this.accountLabel,
this.activeProfile,
this.servers = const [],
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
});
@override
ConnectionKind get kind => ConnectionKind.plex;
MediaBackend get kind => MediaBackend.plex;
@override
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
@@ -150,7 +101,6 @@ class PlexAccountConnection extends Connection {
PlexHomeUser? activeProfile,
bool clearActiveProfile = false,
List<PlexServer>? servers,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
@@ -161,7 +111,6 @@ class PlexAccountConnection extends Connection {
accountLabel: accountLabel ?? this.accountLabel,
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
servers: servers ?? this.servers,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
);
@@ -181,7 +130,6 @@ class PlexAccountConnection extends Connection {
factory PlexAccountConnection.fromConfigJson({
required String id,
required Map<String, Object?> json,
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
}) {
@@ -198,7 +146,6 @@ class PlexAccountConnection extends Connection {
accountLabel: json['accountLabel'] as String? ?? 'Plex',
activeProfile: activeProfile,
servers: servers,
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
@@ -212,9 +159,6 @@ class JellyfinConnection extends Connection {
@override
final String id;
@override
final ConnectionStatus status;
@override
final DateTime createdAt;
@@ -276,14 +220,13 @@ class JellyfinConnection extends Connection {
this.dialect = MediaBrowserDialect.jellyfin,
this.isAdministrator = false,
this.primaryImageTag,
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
}) : baseUrl = canonicalizeBaseUrl(baseUrl),
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
@override
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
MediaBackend get kind => dialect.backend;
@override
String get displayName => '$userName · $serverName';
@@ -338,7 +281,6 @@ class JellyfinConnection extends Connection {
/// a refresh must be able to null the cached value — a bare
/// `primaryImageTag: null` is indistinguishable from "unchanged".
bool clearPrimaryImageTag = false,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
@@ -356,7 +298,6 @@ class JellyfinConnection extends Connection {
dialect: dialect ?? this.dialect,
isAdministrator: isAdministrator ?? this.isAdministrator,
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
);
@@ -384,7 +325,6 @@ class JellyfinConnection extends Connection {
factory JellyfinConnection.fromConfigJson({
required String id,
required Map<String, Object?> json,
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
@@ -408,7 +348,6 @@ class JellyfinConnection extends Connection {
deviceId: json['deviceId'] as String? ?? '',
isAdministrator: json['isAdministrator'] as bool? ?? false,
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
+17 -43
View File
@@ -11,6 +11,7 @@ import '../services/storage_service.dart';
import '../utils/app_logger.dart';
import 'connection.dart';
import 'connection_registry.dart';
import 'plex_account_setup.dart';
/// One-shot helpers that bridge between the legacy single-Plex-account
/// SharedPreferences state (`StorageService.plexToken` +
@@ -97,25 +98,12 @@ class ConnectionBootstrap {
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
try {
final auth = await PlexAuthService.create();
try {
final info = await auth.getUserInfo(devToken);
final servers = await auth.fetchServers(devToken);
final clientId = await storage.getOrCreateClientIdentifier();
final conn = PlexAccountConnection(
id: 'plex.$clientId',
accountToken: devToken,
clientIdentifier: clientId,
accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex',
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
);
await connectionRegistry.upsert(conn);
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}');
} finally {
auth.dispose();
}
// The dev seed always keys its row by the device client id and aborts
// on an identity-lookup failure — a bad PLEX_TOKEN must not seed a
// mislabelled row.
final build = await buildPlexAccountConnection(devToken, keyByAccountUuid: false, tolerateUserInfoFailure: false);
await connectionRegistry.upsert(build.connection);
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${build.connection.id}');
} catch (e, st) {
appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st);
}
@@ -138,31 +126,17 @@ class ConnectionBootstrap {
}
try {
final clientId = await storage.getOrCreateClientIdentifier();
final servers = await serverRegistry.getServers();
String accountLabel = 'Plex';
String accountUuid = '';
try {
final info = await _plexUserInfoFetcher(token);
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
} catch (e) {
appLogger.d('Plex migration: account label lookup failed (using fallback): $e');
}
final conn = PlexAccountConnection(
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
accountToken: token,
clientIdentifier: clientId,
accountLabel: accountLabel,
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
final build = await buildPlexAccountConnection(
token,
clientIdentifier: await storage.getOrCreateClientIdentifier(),
fetchUserInfo: _plexUserInfoFetcher,
// The migration wraps the cached legacy server list; a network server
// fetch could fail exactly when the migration must succeed offline.
fetchServers: (_) => serverRegistry.getServers(),
);
await connectionRegistry.upsert(conn);
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}');
return conn;
await connectionRegistry.upsert(build.connection);
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${build.connection.id}');
return build.connection;
} catch (e, st) {
appLogger.w('Plex account migration failed', error: e, stackTrace: st);
return null;
+28 -62
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import '../database/app_database.dart';
import '../media/media_backend.dart';
import '../services/credential_vault.dart';
import '../utils/app_logger.dart';
import 'connection.dart';
@@ -11,13 +13,23 @@ import 'connection.dart';
/// CRUD over the persisted [Connections] table. The registry is the source
/// of truth for which connections the user has added; the runtime
/// `MultiServerManager` populates per-server clients from these records.
///
/// Single-connection users get a default automatically — power users with
/// multiple connections can override it via [setDefault].
class ConnectionRegistry {
ConnectionRegistry(this._db);
final AppDatabase _db;
static const DeepCollectionEquality _configEquality = DeepCollectionEquality();
final Expando<Map<String, Object?>> _decryptedConfigs = Expando<Map<String, Object?>>(
'ConnectionRegistry.decryptedConfigs',
);
/// Compares the decrypted persisted config projections retained while rows
/// are decoded. The fallback covers models supplied outside this registry.
bool hasSameConfig(Connection a, Connection b) {
final aConfig = _decryptedConfigs[a] ??= a.toConfigJson();
final bConfig = _decryptedConfigs[b] ??= b.toConfigJson();
return _configEquality.equals(aConfig, bConfig);
}
/// Emits the current set of connections after every mutation. Drift's
/// `watch()` provides this for free.
@@ -40,33 +52,19 @@ class ConnectionRegistry {
return _rowToConnection(row);
}
/// Insert or replace [connection]. If this is the first stored connection
/// it is automatically marked default; re-upserting an existing row keeps
/// the row's current `isDefault` and `createdAt` (so token/metadata
/// refreshes don't clear the default flag or restamp creation order).
/// Insert or replace [connection]. Re-upserting an existing row keeps the
/// row's current `createdAt` (so token/metadata refreshes don't restamp
/// creation order).
///
/// Creation order is behaviour, not bookkeeping: it decides which
/// connection lends a profile its picture, and `remove` promotes the oldest
/// remaining row to default. Re-authenticating rebuilds the model with
/// `DateTime.now()` and reuses the same stable id, so without this the
/// originally-first connection would jump to last on every re-sign-in.
/// connection lends a profile its picture. Re-authenticating rebuilds the
/// model with `DateTime.now()` and reuses the same stable id, so without
/// this the originally-first connection would jump to last on every
/// re-sign-in.
Future<void> upsert(Connection connection) async {
await _db.runIdentityMutation(() async {
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
final bool isDefault;
final int createdAt;
if (existing != null) {
isDefault = existing.isDefault;
createdAt = existing.createdAt;
} else {
final any =
await (_db.selectOnly(_db.connections)
..addColumns([_db.connections.id])
..limit(1))
.getSingleOrNull();
isDefault = any == null;
createdAt = connection.createdAt.millisecondsSinceEpoch;
}
final createdAt = existing?.createdAt ?? connection.createdAt.millisecondsSinceEpoch;
final protectedConfig = await CredentialVault.protectConnectionConfig(
connection.kind.id,
connection.toConfigJson(),
@@ -76,7 +74,6 @@ class ConnectionRegistry {
kind: Value(connection.kind.id),
displayName: Value(connection.displayName),
configJson: Value(jsonEncode(protectedConfig)),
isDefault: Value(isDefault),
createdAt: Value(createdAt),
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
);
@@ -85,44 +82,14 @@ class ConnectionRegistry {
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
}
/// Remove a stored connection. If the removed row was the default, the
/// oldest remaining connection (if any) becomes default.
/// Remove a stored connection.
Future<void> remove(String id) async {
await _db.runIdentityMutation(() async {
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
}
});
appLogger.d('ConnectionRegistry: removed $id');
}
/// Set [id] as the default connection. Clears the flag on all others.
Future<void> setDefault(String id) async {
await _db.runIdentityMutation(() async {
await _db.transaction(() async {
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
});
});
}
/// Update only the auth-related metadata on an existing row (token,
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
/// silent refresh without touching the rest of the config.
Future<void> recordAuthSuccess(String id, DateTime at) async {
await _db.runIdentityMutation(() async {
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
);
});
}
Future<void> clear() async {
await _db.runIdentityMutation(() async {
await _db.delete(_db.connections).go();
@@ -147,29 +114,28 @@ class ConnectionRegistry {
Future<Connection?> _rowToConnection(ConnectionRow row) async {
try {
final json = jsonDecode(row.configJson) as Map<String, dynamic>;
final kind = ConnectionKind.fromId(row.kind);
final kind = MediaBackend.fromId(row.kind);
final revealed = await CredentialVault.revealConnectionConfig(kind.id, json);
final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt);
final lastAuth = row.lastAuthenticatedAt == null
? null
: DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!);
final connection = switch (kind) {
ConnectionKind.plex => PlexAccountConnection.fromConfigJson(
MediaBackend.plex => PlexAccountConnection.fromConfigJson(
id: row.id,
json: revealed.config,
status: ConnectionStatus.unknown,
createdAt: createdAt,
lastAuthenticatedAt: lastAuth,
),
ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson(
MediaBackend.jellyfin || MediaBackend.emby => JellyfinConnection.fromConfigJson(
id: row.id,
json: revealed.config,
status: ConnectionStatus.unknown,
createdAt: createdAt,
lastAuthenticatedAt: lastAuth,
dialect: kind.dialect!,
),
};
_decryptedConfigs[connection] = revealed.config;
if (revealed.migrated) {
await upsert(connection);
}
+98 -57
View File
@@ -19,6 +19,77 @@ typedef PlexAccountRegistration = ({
String email,
});
/// Result of [buildPlexAccountConnection]: the assembled connection plus the
/// raw identity fields callers fold into their own records and dedup scans.
typedef PlexAccountBuild = ({PlexAccountConnection connection, String username, String email, String accountUuid});
/// Shared token → [PlexAccountConnection] pipeline: resolve the plex.tv
/// account identity, list the account's servers, and assemble a connection
/// row stamped "now". [registerPlexAccountFromToken] and both
/// [ConnectionBootstrap] entry points (the PLEX_TOKEN dev seed and the
/// legacy-token migration) funnel through here so the id/label policy can't
/// drift. Each caller keeps its delta via the knobs:
///
/// - [clientIdentifier] is persisted on the row and keys it when the account
/// uuid is unavailable. `null` uses the short-lived [PlexAuthService]'s own
/// per-device id (the same [StorageService]-backed value).
/// - [fetchUserInfo]/[fetchServers] override the identity/server sources —
/// the legacy migration injects a test seam and the cached legacy server
/// list. When both are given (and [clientIdentifier] is set) no
/// [PlexAuthService] is created at all.
/// - [keyByAccountUuid]: the dev seed keys its row by client id even when
/// the account uuid is known.
/// - [tolerateUserInfoFailure]: sign-in and migration fall back to a
/// client-id-keyed row when the identity lookup fails; the dev seed aborts
/// instead of seeding a mislabelled row.
Future<PlexAccountBuild> buildPlexAccountConnection(
String token, {
String? clientIdentifier,
Future<Map<String, dynamic>> Function(String token)? fetchUserInfo,
Future<List<PlexServer>> Function(String token)? fetchServers,
bool keyByAccountUuid = true,
bool tolerateUserInfoFailure = true,
}) async {
PlexAuthService? auth;
try {
if (clientIdentifier == null || fetchUserInfo == null || fetchServers == null) {
auth = await PlexAuthService.create();
}
// Account identity from plex.tv — the uuid is what makes multi-account
// work (the clientIdentifier is per-device and identical for every Plex
// account on this install). Falls back to the client identifier only
// when the user-info call fails outright; a later successful sign-in
// migrates that legacy row via [_migrateLegacyClientIdRow].
String username = '';
String email = '';
String accountUuid = '';
try {
final info = await (fetchUserInfo ?? auth!.getUserInfo)(token);
username = (info['username'] as String?) ?? '';
email = (info['email'] as String?) ?? '';
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
} catch (e) {
if (!tolerateUserInfoFailure) rethrow;
appLogger.d('Plex user-info lookup failed (using fallback identity): $e');
}
final servers = await (fetchServers ?? auth!.fetchServers)(token);
final clientId = clientIdentifier ?? auth!.clientIdentifier;
final connection = PlexAccountConnection(
id: 'plex.${keyByAccountUuid && accountUuid.isNotEmpty ? accountUuid : clientId}',
accountToken: token,
clientIdentifier: clientId,
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
);
return (connection: connection, username: username, email: email, accountUuid: accountUuid);
} finally {
auth?.dispose();
}
}
/// Shared post-auth pipeline for a fresh plex.tv [token]: resolve the
/// account identity, persist the [PlexAccountConnection] (folding in a
/// legacy client-id-keyed row from an earlier failed identity lookup), and
@@ -31,66 +102,36 @@ Future<PlexAccountRegistration> registerPlexAccountFromToken({
required StorageService storage,
required PlexHomeService plexHome,
}) async {
final auth = await PlexAuthService.create();
try {
// Account identity from plex.tv — the uuid is what makes multi-account
// work (the clientIdentifier is per-device and identical for every Plex
// account on this install). Falls back to the client identifier only
// when the user-info call fails outright; a later successful sign-in
// migrates that legacy row below.
String username = '';
String email = '';
String accountUuid = '';
try {
final info = await auth.getUserInfo(token);
username = (info['username'] as String?) ?? '';
email = (info['email'] as String?) ?? '';
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
} catch (e) {
appLogger.d('getUserInfo after Plex sign-in failed (using fallback identity): $e');
}
final build = await buildPlexAccountConnection(token);
final connection = build.connection;
final legacyId = 'plex.${connection.clientIdentifier}';
final existedBefore =
await connections.get(connection.id) != null ||
(build.accountUuid.isNotEmpty &&
legacyId != connection.id &&
await connections.get(legacyId) is PlexAccountConnection);
await connections.upsert(connection);
final servers = await auth.fetchServers(token);
final connection = PlexAccountConnection(
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}',
accountToken: token,
clientIdentifier: auth.clientIdentifier,
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
if (build.accountUuid.isNotEmpty) {
await _migrateLegacyClientIdRow(
replacement: connection,
clientIdentifier: connection.clientIdentifier,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
final legacyId = 'plex.${auth.clientIdentifier}';
final existedBefore =
await connections.get(connection.id) != null ||
(accountUuid.isNotEmpty &&
legacyId != connection.id &&
await connections.get(legacyId) is PlexAccountConnection);
await connections.upsert(connection);
if (accountUuid.isNotEmpty) {
await _migrateLegacyClientIdRow(
replacement: connection,
clientIdentifier: auth.clientIdentifier,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
}
// Fetch home users now so pickers surface the account's virtual
// profiles immediately.
final homeUsersFetched = await plexHome.refresh(connection);
return (
connection: connection,
homeUsersFetched: homeUsersFetched,
existedBefore: existedBefore,
username: username,
email: email,
);
} finally {
auth.dispose();
}
// Fetch home users now so pickers surface the account's virtual
// profiles immediately.
final homeUsersFetched = await plexHome.refresh(connection);
return (
connection: connection,
homeUsersFetched: homeUsersFetched,
existedBefore: existedBefore,
username: build.username,
email: build.email,
);
}
/// A row keyed by the per-device client identifier (created when an earlier
+1 -1
View File
@@ -1,7 +1,7 @@
/// Dense BMP ranks per DUCET (Unicode 13.0) + pinned CLDR CJK radical-stroke.
/// Each code unit stores rank + 1; zero means absent.
/// Katakana sorts before hiragana (CLDR root tailoring).
/// Generated by scripts/generate_ducet_ranks.dart — do not edit.
/// Generated by scripts/codegen/generate_ducet_ranks.dart — do not edit.
library;
const String _ducetRanks =
+1 -1
View File
@@ -1,4 +1,4 @@
// Generated by dart run scripts/generate_hid_key_labels.dart from scripts/data/hid_key_labels.json; do not edit by hand.
// Generated by dart run scripts/codegen/generate_hid_key_labels.dart from scripts/codegen/data/hid_key_labels.json; do not edit by hand.
/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.
const hidKeyLabels = <int, String>{
+1 -1
View File
@@ -1,4 +1,4 @@
// Generated by dart run scripts/generate_iso_639_data.dart from scripts/data/iso_639_codes.json; do not edit by hand.
// Generated by dart run scripts/codegen/generate_iso_639_data.dart from scripts/codegen/data/iso_639_codes.json; do not edit by hand.
class LanguageEntry {
final String code1;
+75 -50
View File
@@ -37,15 +37,6 @@ enum OfflineActionType {
OfflineActionType.watched => 'watched',
OfflineActionType.unwatched => 'unwatched',
};
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
/// silently fall back to the wrong action.
static OfflineActionType fromId(String id) => switch (id) {
'progress' => OfflineActionType.progress,
'watched' => OfflineActionType.watched,
'unwatched' => OfflineActionType.unwatched,
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
};
}
final class AppDatabaseBootstrap {
@@ -67,6 +58,7 @@ final class AppDatabaseBootstrap {
Connections,
Profiles,
ProfileConnections,
MusicSessions,
],
)
class AppDatabase extends _$AppDatabase {
@@ -81,7 +73,6 @@ class AppDatabase extends _$AppDatabase {
AppDatabase._withRecovery(super.e, this._recoveryStore);
final TvosDatabaseRecoveryStore? _recoveryStore;
final SerialFutureQueue _durabilityQueue = SerialFutureQueue();
static final Object _durabilityZoneKey = Object();
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
@@ -156,12 +147,10 @@ class AppDatabase extends _$AppDatabase {
final store = _recoveryStore;
if (store == null || !store.isTvos) return Future<void>.value();
return _durabilityQueue.run(
() => _tvosRecoveryQueue.run(
() => store.acknowledgeRecoveryRequired(
readIdentity: _readProtectedIdentityRecoveryRows,
readPending: _readPendingRecoveryRows,
),
return _tvosRecoveryQueue.run(
() => store.acknowledgeRecoveryRequired(
readIdentity: _readProtectedIdentityRecoveryRows,
readPending: _readPendingRecoveryRows,
),
);
}
@@ -175,17 +164,15 @@ class AppDatabase extends _$AppDatabase {
if (store == null || !store.isTvos) return mutation();
if (Zone.current[_durabilityZoneKey] == this) return mutation();
return _durabilityQueue.run(
() => _tvosRecoveryQueue.run(
() => runZoned(
() => store.runDurableMutation(
group: group,
mutation: mutation,
readIdentity: _readProtectedIdentityRecoveryRows,
readPending: _readPendingRecoveryRows,
),
zoneValues: {_durabilityZoneKey: this},
return _tvosRecoveryQueue.run(
() => runZoned(
() => store.runDurableMutation(
group: group,
mutation: mutation,
readIdentity: _readProtectedIdentityRecoveryRows,
readPending: _readPendingRecoveryRows,
),
zoneValues: {_durabilityZoneKey: this},
),
);
}
@@ -324,6 +311,14 @@ class AppDatabase extends _$AppDatabase {
});
}
/// Columns that once existed in a snapshotted table and may still appear in
/// committed recovery images written by older builds. They are stripped
/// before the strict round-trip check in [_decodeRecoveryRow] so retiring a
/// column does not brick restore on devices holding a pre-retirement image.
static const Map<String, Set<String>> _retiredRecoveryColumns = {
'connections': {'isDefault'},
};
static List<T> _decodeRecoveryRows<T extends DataClass>(
Map<String, Object?> group,
String key,
@@ -331,9 +326,21 @@ class AppDatabase extends _$AppDatabase {
) {
final value = group[key];
if (value is! List) throw _invalidRecoveryImage;
final retired = _retiredRecoveryColumns[key];
return [
for (final row in value)
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
if (row is Map<String, dynamic>)
_decodeRecoveryRow(
retired == null
? row
: {
for (final entry in row.entries)
if (!retired.contains(entry.key)) entry.key: entry.value,
},
fromJson,
)
else
throw _invalidRecoveryImage,
];
}
@@ -359,7 +366,7 @@ class AppDatabase extends _$AppDatabase {
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
@override
int get schemaVersion => 20;
int get schemaVersion => 22;
@override
MigrationStrategy get migration {
@@ -725,6 +732,14 @@ class AppDatabase extends _$AppDatabase {
() => m.create(idxSyncRuleDownloadsProfileKey),
);
}
if (from < 21) {
appLogger.i('Dropping unused Connections.isDefault column (v21 migration)');
await m.alterTable(TableMigration(connections));
}
if (from < 22) {
appLogger.i('Adding MusicSessions table (v22 migration)');
await _ignoreAlreadyExists('MusicSessions table', () => m.createTable(musicSessions));
}
},
);
}
@@ -1057,25 +1072,6 @@ class AppDatabase extends _$AppDatabase {
});
}
/// Delete a specific watch action outside a snapshotted replay.
Future<void> deleteWatchAction(int id) {
return _runPendingMutation(() async {
await (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go();
});
}
/// Update retry state outside a snapshotted replay.
Future<void> updateSyncAttempt(int id, String? errorMessage) {
return _runPendingMutation(() async {
final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull();
if (existing == null) return;
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
);
});
}
/// Get count of pending sync items
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
@@ -1103,6 +1099,38 @@ class AppDatabase extends _$AppDatabase {
});
}
// ===========================================================================
// Music session persistence (#2148)
// ===========================================================================
/// Full snapshot write: replaces the profile's persisted music session.
Future<void> upsertMusicSession(MusicSessionRow row) {
return into(musicSessions).insertOnConflictUpdate(row);
}
/// Cheap write-through for playhead/cursor changes — leaves the (possibly
/// large) queue JSON untouched. No-op when no snapshot row exists.
Future<void> updateMusicSessionProgress({
required String profileId,
required int cursor,
required int positionMs,
required int updatedAt,
}) async {
await (update(musicSessions)..where((t) => t.profileId.equals(profileId))).write(
MusicSessionsCompanion(cursor: Value(cursor), positionMs: Value(positionMs), updatedAt: Value(updatedAt)),
);
}
Future<MusicSessionRow?> getMusicSession(String profileId) {
return (select(musicSessions)..where((t) => t.profileId.equals(profileId))).getSingleOrNull();
}
/// Drop a profile's persisted music session (user session end or profile
/// teardown).
Future<void> deleteMusicSessionForProfile(String profileId) async {
await (delete(musicSessions)..where((t) => t.profileId.equals(profileId))).go();
}
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
final query = select(syncRules);
if (profileId != null) {
@@ -1275,9 +1303,6 @@ class AppDatabase extends _$AppDatabase {
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
Future<void> completeSyncRuleExecution(String globalKey) {
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
SyncRulesCompanion(
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -145,10 +145,6 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
Future<int> getDownloadOwnerCount(String globalKey) async {
return (await _validDownloadOwnerRows(globalKey)).length;
}
@visibleForTesting
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
@@ -190,8 +186,8 @@ extension DownloadDatabaseOperations on AppDatabase {
/// inherit them.
///
/// Runs on every profile switch — validity context is computed once and
/// applied in memory instead of the per-download full-table rescan
/// `getDownloadOwnerCount` would do.
/// applied in memory instead of a per-download full-table rescan through
/// `_validDownloadOwnerRows`.
Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
if (profileId.isEmpty) return;
if (isStillActive != null && !isStillActive()) return;
@@ -437,14 +433,20 @@ extension DownloadDatabaseOperations on AppDatabase {
}
/// Get next item from queue (highest priority, oldest first)
/// Only returns items that are not paused
Future<DownloadQueueItem?> getNextQueueItem() async {
/// Only returns items that are not paused.
///
/// [excludedGlobalKeys] filters out heads the caller already tried and could
/// not resolve, so one stale row cannot starve the rest of the queue.
Future<DownloadQueueItem?> getNextQueueItem({Set<String> excludedGlobalKeys = const {}}) async {
final query = select(
downloadQueue,
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
query.where(downloadedMedia.status.equals(DownloadStatus.queued.index));
if (excludedGlobalKeys.isNotEmpty) {
query.where(downloadQueue.mediaGlobalKey.isNotIn(excludedGlobalKeys.toList(growable: false)));
}
query
..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
..orderBy([
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
OrderingTerm(expression: downloadQueue.addedAt),
+47 -7
View File
@@ -9,12 +9,14 @@ class ApiCache extends Table {
TextColumn get data => text()();
/// When the row was last written ([ApiCacheSingleton.put] stamps it on
/// every store). Read by the fresh-cache-first playback metadata gate,
/// [ApiCacheSingleton.getIfFresh].
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
/// Whether this item is pinned for offline access
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
/// Timestamp for cache invalidation (optional future use)
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {cacheKey};
}
@@ -150,10 +152,6 @@ class Connections extends Table {
/// Backend-specific config payload (token, baseUrl, profile id, …).
TextColumn get configJson => text()();
/// Whether this is the default connection used at app launch when only
/// one connection is present.
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
/// Timestamp this connection was added (milliseconds since epoch).
IntColumn get createdAt => integer()();
@@ -284,3 +282,45 @@ class OfflineWatchProgress extends Table {
/// Last sync error message
TextColumn get lastError => text().nullable()();
}
/// Last music session per profile, restored paused on the next launch (#2148).
///
/// One row per profile: the serialized queue plus enough arrangement state to
/// rebuild it faithfully (canonical order, shuffle permutation, cursor, modes)
/// and the playhead. Written through during playback (throttled position
/// updates, full rewrites on queue-shape changes) because mobile gives no
/// termination hook; cleared when the user visibly ends the session.
@DataClassName('MusicSessionRow')
class MusicSessions extends Table {
/// Active Plezy profile that owns this session snapshot.
TextColumn get profileId => text()();
/// Canonical queue tracks (insertion order) as a JSON array of MediaItem
/// JSON — self-contained so restore never depends on volatile cache rows.
TextColumn get queueJson => text()();
/// Playback-order permutation into [queueJson] as a JSON int array
/// (identity while unshuffled).
TextColumn get orderJson => text()();
/// Position of the current track within the playback order.
IntColumn get cursor => integer()();
BoolColumn get shuffled => boolean().withDefault(const Constant(false))();
/// Stable repeat-mode id ('off' | 'all' | 'one') — not the enum `.name`.
TextColumn get repeatMode => text().withDefault(const Constant('off'))();
/// Play-context provenance ("Playing from …"); kind is a stable id.
TextColumn get contextTitle => text().nullable()();
TextColumn get contextKind => text().nullable()();
/// Playhead within the current track in milliseconds.
IntColumn get positionMs => integer().withDefault(const Constant(0))();
/// Timestamp of the last write (milliseconds since epoch).
IntColumn get updatedAt => integer()();
@override
Set<Column> get primaryKey => {profileId};
}
+14 -3
View File
@@ -38,7 +38,7 @@ class MediaServerAuthException extends MediaServerException {
/// Auth polling reached a terminal server-side expiry/rejection state before
/// the user completed the external sign-in flow.
class MediaServerPinExpiredException extends MediaServerAuthException {
const MediaServerPinExpiredException({String? display}) : super('PIN expired before sign-in', display: display);
const MediaServerPinExpiredException({super.display}) : super('PIN expired before sign-in');
}
/// HTTP transport / non-2xx errors. Carries the status code (when known),
@@ -57,11 +57,11 @@ class MediaServerHttpException extends MediaServerException {
MediaServerHttpException({
required this.type,
String? message,
String? display,
super.display,
this.statusCode,
this.responseData,
this.requestUri,
}) : super(message ?? '', display: display);
}) : super(message ?? '');
/// Map a caught exception to a [MediaServerHttpException].
factory MediaServerHttpException.from(Object error, {Uri? uri}) {
@@ -119,6 +119,17 @@ class MediaServerHttpException extends MediaServerException {
}
}
/// The backend already has a recording scheduled for the requested airing.
///
/// Plex signals duplicates with a bare 409, which UI maps by status code.
/// Jellyfin/Emby answer `POST /LiveTv/Timers` duplicates with a 400 that is
/// indistinguishable from a malformed request by status alone — only the DVR
/// adapter knows that call site's semantics, so it rethrows this type and UI
/// maps it to the "already scheduled" outcome without a backend check.
class RecordingConflictException extends MediaServerException {
const RecordingConflictException(super.message, {super.display});
}
/// The server explicitly terminated the client's playback session (admin
/// "stop stream", paused-too-long auto-termination, concurrent-stream limit).
///
+61 -13
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
@@ -70,29 +71,73 @@ extension DpadKeyExtension on LogicalKeyboardKey {
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
/// Base class for suppressing key-up events after a key category triggers an
/// action (e.g. opening a sheet). While suppressed, all events for the matched
/// key category are consumed; suppression auto-clears on [KeyUpEvent].
/// Base class for suppressing key events after a key category triggers an
/// action (e.g. opening a sheet). While suppressed, every event of the matched
/// key category is consumed — including a [KeyDownEvent] when
/// [consumeKeyDowns] is set, because an armer running in the
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
/// very KeyDown that is then re-dispatched through the focus tree.
///
/// Suppression ends with the physical press: a global [HardwareKeyboard]
/// observer watches for the matching [KeyUpEvent] and clears the armed state
/// in a microtask — after that KeyUp's own synchronous focus dispatch, so
/// focus-phase consumers still swallow it — which guarantees a stale armed
/// state (a KeyUp delivered to a focus chain that never consulted us) can
/// never swallow the next press.
///
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
/// matching [KeyDownEvent] as proof the suppressed press already ended without
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
/// state is cleared and the fresh press passes through unconsumed.
class _KeyUpSuppressor {
final bool Function(LogicalKeyboardKey) _keyMatcher;
_KeyUpSuppressor(this._keyMatcher);
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
/// documentation for why armers targeting an in-flight KeyUp must unset it.
final bool consumeKeyDowns;
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
bool _suppressed = false;
void suppress() => _suppressed = true;
void suppress() {
// Re-registered on every arm because flutter_test's HardwareKeyboard
// clearState() drops handlers between tests; remove-then-add keeps exactly
// one live registration. The observer only reads state and returns false,
// so it can never consume an event, and it is intentionally never removed
// outside re-registration.
HardwareKeyboard.instance
..removeHandler(_observeKeyUp)
..addHandler(_observeKeyUp);
_suppressed = true;
}
bool _observeKeyUp(KeyEvent event) {
if (_suppressed && event is KeyUpEvent && _keyMatcher(event.logicalKey)) {
// The hardware phase runs before the same event's focus dispatch;
// clearing in a microtask keeps the armed state visible to the KeyUp's
// focus-phase consumers while ending it before any later event.
scheduleMicrotask(clearSuppression);
}
return false;
}
void clearSuppression() => _suppressed = false;
/// Returns `true` (consumed) when the event belongs to the matched key
/// category and suppression is active. Clears suppression on [KeyUpEvent].
/// category and suppression is active. Clears suppression on [KeyUpEvent],
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
bool consumeIfSuppressed(KeyEvent event) {
if (!_suppressed) return false;
if (_keyMatcher(event.logicalKey)) {
if (event is KeyUpEvent) _suppressed = false;
return true;
if (!_keyMatcher(event.logicalKey)) return false;
if (event is KeyUpEvent) _suppressed = false;
if (event is KeyDownEvent && !consumeKeyDowns) {
// The in-flight KeyUp never arrived; this is a fresh press, not the
// suppressed one. Clear and let it through.
_suppressed = false;
return false;
}
return false;
return true;
}
}
@@ -108,10 +153,13 @@ class SelectKeyUpSuppressor {
/// Global helper to suppress the next BACK key-up event.
///
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — so the
/// in-flight key-up doesn't propagate to the underlying screen's back handler.
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
/// down-only back handler moves focus before the matching KeyUp is dispatched.
/// A matching KeyDown while armed clears the arming instead of consuming: it
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
/// fresh press must act normally.
class BackKeyUpSuppressor {
static final _instance = _KeyUpSuppressor((k) => k.isBackKey);
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
static void suppressBackUntilKeyUp() => _instance.suppress();
+5 -2
View File
@@ -24,6 +24,7 @@ Widget buildFocusChrome(
bool useBackgroundFocus = false,
bool useFocusGlow = false,
bool delegateFocusBorder = false,
bool? showGlow,
Size? glowSize,
required Widget child,
}) {
@@ -44,10 +45,12 @@ Widget buildFocusChrome(
}
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
// symmetric; the in-card decoration only carries the border.
// symmetric; the in-card decoration only carries the border. [showGlow] lets
// callers hold back just the glow (e.g. while a viewport scroll animates)
// without touching the border or scale chrome.
if (useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
isFocused: showGlow ?? showFocus,
borderRadius: borderRadius,
color: focusColor ?? FocusTheme.getFocusBorderColor(context),
glowSize: glowSize,
+30 -6
View File
@@ -57,6 +57,12 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
/// fades out before the portal is hidden in [_handleFadeEnd].
bool _visible = false;
/// Whether the previous [build] mounted the [OverlayPortal] — i.e. whether
/// [_controller] is attached. An attached controller's show()/hide() assert
/// when called during build, so [didUpdateWidget] must defer; a detached
/// controller's show() merely records pending state and is build-safe.
bool _portalInTree = false;
/// Glow is skipped on the reduced effects tier (blurred shadows + fade
/// saveLayer are too expensive on weak GPUs) and when the user turned the
/// Focus Glow setting off (#1278). The crisp focus border remains.
@@ -77,12 +83,25 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
if (_disabled) return;
if (widget.isFocused == oldWidget.isFocused) return;
if (widget.isFocused) {
_controller.show();
// Start hidden, then fade in next frame.
_visible = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isFocused) setState(() => _visible = true);
});
if (_portalInTree) {
// The portal from a previous glow session is still attached (mid
// fade-out, or the rail held the glow back during a vertical scroll):
// show() would assert during build, so defer it out of the frame and
// fade back in from wherever the fade-out got to.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.isFocused) return;
if (!_controller.isShowing) _controller.show();
setState(() => _visible = true);
});
} else {
// Detached: a build-time show() only records pending state, applied
// when this build mounts the portal. Start hidden, fade in next frame.
_controller.show();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isFocused) setState(() => _visible = true);
});
}
} else {
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
setState(() => _visible = false);
@@ -98,13 +117,18 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
@override
Widget build(BuildContext context) {
if (_disabled) return widget.child;
if (_disabled) {
_portalInTree = false;
return widget.child;
}
// Gate the LeaderLayer to the focused card only: when not focused and not
// mid-fade, return the bare child (no OverlayPortal, no leader).
if (!widget.isFocused && !_controller.isShowing) {
_portalInTree = false;
return widget.child;
}
_portalInTree = true;
final duration = FocusTheme.getAnimationDuration(context);
+20
View File
@@ -33,6 +33,26 @@ class DirectionalShortcutFocusNode extends FocusNode {
node is DirectionalShortcutFocusNode && node.consumesDirectionalKeys(key);
}
/// A [FocusNode] for a locked-focus row: the node spans the whole row while
/// its owner steps an internal selection index between the row's items.
///
/// Swipe-step pricing follows the focused *item's* geometry (see
/// `AppleTvRemoteTouchService`), and for a locked-focus row the node's own
/// rect is the row — screen-wide — which would price a swipe step at the
/// travel cap and make the row feel dead. The owner instead vends the
/// selected item's global rect here; a null return (item not built yet,
/// unknown geometry) falls back to the fixed step distance.
///
/// Like [DirectionalShortcutFocusNode], the fact rides on the node because it
/// depends on what has focus, not on where a widget sits.
class LockedFocusRowNode extends FocusNode {
LockedFocusRowNode({required this.focusedItemRect, super.debugLabel, super.skipTraversal});
/// Global rect of the row's currently selected item, evaluated per swipe
/// frame so selection moves need no syncing.
final Rect? Function() focusedItemRect;
}
/// Whether [event] is evidence that the viewer wants to navigate by focus.
///
/// This is the single answer to two questions that must never disagree:
+127 -44
View File
@@ -11,19 +11,10 @@ import 'owned_focus_node_binding.dart';
typedef FocusableActionBuilder = Widget Function(BuildContext context, FocusableActionBuildState state);
class FocusableActionBuildState {
final FocusNode focusNode;
final bool isFocused;
final bool showFocus;
final bool isKeyboardMode;
final Duration animationDuration;
const FocusableActionBuildState({
required this.focusNode,
required this.isFocused,
required this.showFocus,
required this.isKeyboardMode,
required this.animationDuration,
});
const FocusableActionBuildState({required this.showFocus, required this.animationDuration});
}
class FocusableAction {
@@ -41,6 +32,12 @@ class FocusableAction {
final Widget? child;
final FocusableActionBuilder? builder;
/// Row gap between this action and the one before it, overriding the bar's
/// uniform [FocusableActionBar.spacing]. Lets visually joined pairs (the
/// detail screen's split Play button) sit tighter than the rest of the row.
/// Ignored on the first action.
final double? spacingBefore;
const FocusableAction({
this.icon = Symbols.circle_rounded,
this.iconColor,
@@ -53,6 +50,7 @@ class FocusableAction {
this.onPressed,
this.child,
this.builder,
this.spacingBefore,
});
}
@@ -78,7 +76,6 @@ class FocusableActionBar extends StatefulWidget {
final ValueChanged<bool>? onFocusChange;
final double spacing;
final MainAxisSize mainAxisSize;
const FocusableActionBar({
super.key,
@@ -90,7 +87,6 @@ class FocusableActionBar extends StatefulWidget {
this.onBack,
this.onFocusChange,
this.spacing = 0,
this.mainAxisSize = MainAxisSize.min,
});
@override
@@ -134,8 +130,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
void didUpdateWidget(FocusableActionBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (_shouldRebuildFocusNodes(oldWidget)) {
_disposeNodes();
_initNodes();
_rebindNodes(oldWidget.actions);
}
}
@@ -148,31 +143,120 @@ class FocusableActionBarState extends State<FocusableActionBar> {
return false;
}
void _initNodes() {
_focusBindings = [];
_focusNodes = [];
_focusStates = List<bool>.filled(widget.actions.length, false);
for (var i = 0; i < widget.actions.length; i++) {
final index = i;
final binding = OwnedFocusNodeBinding();
binding.bind(
externalNode: widget.actions[i].focusNode,
debugLabel: widget.actions[i].debugLabel ?? 'ActionBar[$i]',
listener: () {
final hasFocus = _focusNodes[index].hasFocus;
if (_focusStates[index] != hasFocus) {
setState(() => _focusStates[index] = hasFocus);
}
_notifyRowFocusIfChanged();
},
);
_focusBindings.add(binding);
_focusNodes.add(binding.node);
_focusStates[i] = binding.node.hasFocus;
/// Stable identity for matching an action across list changes: the supplied
/// [FocusableAction.focusNode] first, else [FocusableAction.debugLabel].
/// Unlabeled actions have no identity and only ever match positionally.
static Object? _actionIdentity(FocusableAction action) => action.focusNode ?? action.debugLabel;
/// Whether the action-list shape is unchanged: same length and every
/// labeled identity present in both lists sits at the index it had. Only
/// then does an unlabeled action's position provably still refer to the
/// same conceptual action.
bool _unlabeledPositionsStable(List<FocusableAction> oldActions) {
if (oldActions.length != widget.actions.length) return false;
for (var i = 0; i < oldActions.length; i++) {
final identity = _actionIdentity(oldActions[i]);
if (identity == null) continue;
for (var j = 0; j < widget.actions.length; j++) {
if (j != i && _actionIdentity(widget.actions[j]) == identity) return false;
}
}
return true;
}
OwnedFocusNodeBinding _createBinding(FocusableAction action, int index) {
final binding = OwnedFocusNodeBinding();
binding.bind(
externalNode: action.focusNode,
debugLabel: action.debugLabel ?? 'ActionBar[$index]',
listener: () => _handleBindingFocusChange(binding),
);
return binding;
}
/// Index resolved at call time: a binding survives action-list changes, so
/// a listener must not capture the slot it was created for.
void _handleBindingFocusChange(OwnedFocusNodeBinding binding) {
final index = _focusBindings.indexOf(binding);
if (index != -1) {
final hasFocus = binding.node.hasFocus;
if (_focusStates[index] != hasFocus) {
setState(() => _focusStates[index] = hasFocus);
}
}
_notifyRowFocusIfChanged();
}
void _initNodes() {
_focusBindings = [for (var i = 0; i < widget.actions.length; i++) _createBinding(widget.actions[i], i)];
_focusNodes = [for (final binding in _focusBindings) binding.node];
_focusStates = [for (final node in _focusNodes) node.hasFocus];
_hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
}
/// Rebind on an action-list change, reusing the binding of every action
/// whose identity survives so its focus node is not disposed out from under
/// the user (media detail's watchlist action arrives asynchronously and
/// used to drop D-pad focus with the wholesale rebuild). A removed focused
/// action is disposed with its listener already detached, so the genuine
/// row-focus loss is reported through [_notifyRowFocusIfChanged] here.
void _rebindNodes(List<FocusableAction> oldActions) {
final oldBindings = _focusBindings;
final focusedOldIndex = _focusNodes.indexWhere((node) => node.hasFocus);
final claimed = List<bool>.filled(oldBindings.length, false);
final unlabeledPositionsStable = _unlabeledPositionsStable(oldActions);
int? matchOldIndex(int newIndex) {
final identity = _actionIdentity(widget.actions[newIndex]);
if (identity != null) {
for (var i = 0; i < oldActions.length; i++) {
if (!claimed[i] && _actionIdentity(oldActions[i]) == identity) return i;
}
return null;
}
// Positional reuse is the only option for an unlabeled action, but it
// is only provably correct while the list shape is unchanged; across an
// insertion/removal its successor is unknowable, so fall through to a
// fresh binding — genuinely dropping focus beats silently retargeting
// the focused binding (and its next Select) onto a different action.
if (unlabeledPositionsStable && !claimed[newIndex] && _actionIdentity(oldActions[newIndex]) == null) {
return newIndex;
}
return null;
}
var focusedNewIndex = -1;
final newBindings = <OwnedFocusNodeBinding>[];
for (var i = 0; i < widget.actions.length; i++) {
final oldIndex = matchOldIndex(i);
if (oldIndex == null) {
newBindings.add(_createBinding(widget.actions[i], i));
continue;
}
claimed[oldIndex] = true;
if (oldIndex == focusedOldIndex) focusedNewIndex = i;
newBindings.add(oldBindings[oldIndex]);
}
_focusBindings = newBindings;
_focusNodes = [for (final binding in newBindings) binding.node];
_focusStates = [for (final node in _focusNodes) node.hasFocus];
for (var i = 0; i < oldBindings.length; i++) {
if (!claimed[i]) oldBindings[i].dispose();
}
// The focused action survived: keep focus on it. The keyed row keeps its
// Focus element attached across reorders, so this only re-requests focus
// if the framework unfocused the node during widget churn.
if (focusedNewIndex != -1 &&
widget.actions[focusedNewIndex].onPressed != null &&
!_focusNodes[focusedNewIndex].hasFocus) {
_focusNodes[focusedNewIndex].requestFocus();
}
_notifyRowFocusIfChanged();
}
void _notifyRowFocusIfChanged() {
final hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
if (_hasAnyFocus == hasAnyFocus) return;
@@ -198,10 +282,10 @@ class FocusableActionBarState extends State<FocusableActionBar> {
final duration = FocusTheme.getAnimationDuration(context);
final row = Row(
mainAxisSize: widget.mainAxisSize,
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < widget.actions.length; i++) ...[
if (i > 0 && widget.spacing > 0) SizedBox(width: widget.spacing),
if (i > 0 && _gapBefore(i) > 0) SizedBox(width: _gapBefore(i)),
_buildButton(i, isKeyboard, duration),
],
],
@@ -215,22 +299,21 @@ class FocusableActionBarState extends State<FocusableActionBar> {
return AnimatedOpacity(opacity: isKeyboard && !_hasAnyFocus ? 0.6 : 1.0, duration: duration, child: row);
}
double _gapBefore(int index) => widget.actions[index].spacingBefore ?? widget.spacing;
Widget _buildButton(int index, bool isKeyboard, Duration duration) {
final action = widget.actions[index];
final enabled = action.onPressed != null;
final isFocused = _focusStates[index];
final showFocus = isFocused && isKeyboard;
final opacity = isKeyboard && _hasAnyFocus && !isFocused ? 0.6 : 1.0;
final buildState = FocusableActionBuildState(
focusNode: _focusNodes[index],
isFocused: isFocused,
showFocus: showFocus,
isKeyboardMode: isKeyboard,
animationDuration: duration,
);
final buildState = FocusableActionBuildState(showFocus: showFocus, animationDuration: duration);
final customChild = action.builder?.call(context, buildState);
return Focus(
// Keyed on the binding so an insertion moves the element with its node
// instead of detaching (and thereby unfocusing) it slot by slot.
key: ObjectKey(_focusBindings[index]),
focusNode: _focusNodes[index],
canRequestFocus: enabled,
autofocus: action.autofocus && enabled,
+7 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import '../utils/scroll_utils.dart';
import 'owned_focus_node_binding.dart';
import 'input_mode_tracker.dart';
import 'dpad_navigator.dart';
import 'dpad_select_long_press_controller.dart';
import 'key_event_utils.dart';
@@ -92,8 +93,12 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
}
// Same convention as FocusableTileStateMixin: a chip inside a
// scrollable strip (TabChipStrip, filter bars) reveals itself on
// focus; a no-op when no ancestor scrollable exists.
if (focusNode.hasFocus) scrollContextToCenter(context);
// focus; a no-op when no ancestor scrollable exists. Keyboard/D-pad
// sessions only — pointer-mode focus is programmatic and invisible,
// so revealing it would yank the strip (issue #2031).
if (focusNode.hasFocus && InputModeTracker.currentMode == InputMode.keyboard) {
scrollContextToCenter(context);
}
}
}
+34 -35
View File
@@ -38,25 +38,14 @@ bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputTy
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
enum TvTextInputAutoOpenBehavior {
/// Resolve per presentation: [onFirstFocus] for the native tvOS keyboard —
/// arriving at a field opens it once, but returning to it during D-pad
/// traversal does not, since it is a modal full-screen surface and
/// re-raising it on every pass makes a form untraversable. [onFocus] for the
/// in-app Flutter overlay, which is cheap, non-modal, and involves no UIKit
/// first responder.
/// Resolve per presentation: open-on-first-focus for the native tvOS
/// keyboard — arriving at a field opens it once, but returning to it during
/// D-pad traversal does not, since it is a modal full-screen surface and
/// re-raising it on every pass makes a form untraversable. Open-on-focus for
/// the in-app Flutter overlay, which is cheap, non-modal, and involves no
/// UIKit first responder.
automatic,
/// Open the selected TV text input presentation whenever the field receives
/// focus. On Apple TV this raises the system keyboard on every focus entry,
/// including plain D-pad traversal — prefer [automatic] unless the field is
/// the sole purpose of its screen.
onFocus,
/// Open on the field's first focus, then stay closed on later focus entries.
/// Explicit tap/select still opens it, as does the first focus after a
/// focus-node or presentation change.
onFirstFocus,
/// Keep initial focus on the field without opening text input, then open it
/// automatically on later focus entries. Explicit tap/select still opens it.
afterFirstFocus,
@@ -238,9 +227,32 @@ KeyEventResult _handleInputKey({
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
}
if (onBack != null && key.isBackKey) {
if (event is KeyDownEvent) onBack();
return finish(KeyEventResult.handled, 'onBack');
if (onBack != null && event.logicalKey.isBackKey) {
// On TV the native text-input path can swallow the matching KeyUp (the
// closing IME session eats it), so back fires on KeyDown — the same
// down-only shape as [handleBackKeyAction]'s Apple TV branch, coordinator
// mark included so a parallel back dispatch still dedupes. Elsewhere the
// shared handler's KeyUp semantics apply.
if (PlatformDetector.isTV()) {
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return finish(KeyEventResult.handled, 'onBack');
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
// onBack may move focus (empty search field -> sidebar); the matching
// KeyUp is then delivered to the NEW focus chain, whose shared
// handlers act on KeyUp — a second back action. Arm the suppressor
// (after onBack, so a modal opened by it cannot clear the arming) so
// whichever chain receives the KeyUp swallows it. This cannot pin:
// the suppressor's hardware observer clears the armed state once the
// physical press ends, and if the IME swallows that KeyUp entirely,
// the next back KeyDown is treated as stale arming and passes
// through — see _KeyUpSuppressor.
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
return finish(KeyEventResult.handled, 'onBack');
}
final backResult = handleBackKeyAction(event, onBack);
if (backResult != KeyEventResult.ignored) return finish(backResult, 'onBack');
}
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
@@ -906,21 +918,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
// field should still open it — otherwise typing always costs two
// presses — but re-raising it every time D-pad traversal passes back
// over the field makes a multi-field form unusable, so `automatic`
// resolves to `onFirstFocus` there. Android TV's native IME is a docked
// opens only on first focus there. Android TV's native IME is a docked
// soft keyboard that does not take over the screen, so it keeps the
// historical auto-open. Explicit modes stay literal on both: a caller
// that asks for onFocus gets onFocus.
// historical auto-open.
case TvTextInputAutoOpenBehavior.automatic:
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
_hasSeenNativeTextInputFocus = true;
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.onFirstFocus:
if (_hasSeenNativeTextInputFocus) return;
_hasSeenNativeTextInputFocus = true;
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.onFocus:
_hasSeenNativeTextInputFocus = true;
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenNativeTextInputFocus) {
_hasSeenNativeTextInputFocus = true;
@@ -1052,11 +1056,6 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
// The Flutter overlay is an in-app, non-modal widget with no UIKit first
// responder behind it, so opening it on focus costs nothing.
case TvTextInputAutoOpenBehavior.automatic:
case TvTextInputAutoOpenBehavior.onFocus:
return true;
case TvTextInputAutoOpenBehavior.onFirstFocus:
if (_hasSeenTvKeyboardFocus) return false;
_hasSeenTvKeyboardFocus = true;
return true;
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenTvKeyboardFocus) {
+5 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../utils/scroll_utils.dart';
import 'input_mode_tracker.dart';
import 'owned_focus_node_binding.dart';
/// Manages the internal/external FocusNode lifecycle for list-tile widgets and
@@ -39,7 +40,10 @@ mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
}
void _onFocusChange() {
if (effectiveFocusNode.hasFocus) {
// Reveal on focus is keyboard/D-pad-only: pointer-mode focus is
// programmatic and invisible, so revealing it would yank the scroll view
// (issue #2031).
if (effectiveFocusNode.hasFocus && InputModeTracker.currentMode == InputMode.keyboard) {
scrollContextToCenter(context);
}
}
+6 -7
View File
@@ -161,9 +161,6 @@ class FocusableWrapper extends StatefulWidget {
/// Short press triggers [onSelect].
final bool enableLongPress;
/// Duration for long-press detection.
final Duration longPressDuration;
/// Whether to use background color instead of border for focus indicator.
/// Useful for video controls where outline doesn't look good.
final bool useBackgroundFocus;
@@ -223,7 +220,6 @@ class FocusableWrapper extends StatefulWidget {
this.canRequestFocus = true,
this.onKeyEvent,
this.enableLongPress = false,
this.longPressDuration = const Duration(milliseconds: 500),
this.useBackgroundFocus = false,
this.focusColor,
this.disableScale = false,
@@ -323,8 +319,12 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_animationController?.reverse();
}
// Auto-scroll into view
if (hasFocus && widget.autoScroll) {
// Auto-scroll into view. Keyboard/D-pad sessions only: pointer-mode
// focus is invisible and only ever parked or restored programmatically
// (entry focus targets, a context menu re-focusing its trigger on
// close), so revealing it would yank the viewport away from wherever
// the user scrolled (issue #2031).
if (hasFocus && widget.autoScroll && InputModeTracker.currentMode == InputMode.keyboard) {
_scrollIntoView();
}
@@ -458,7 +458,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
if (widget.enableLongPress) {
final result = _selectLongPress.handleKeyEvent(
event,
duration: widget.longPressDuration,
isOwnerActive: () => mounted,
onShortPress: () => widget.onSelect?.call(),
onLongPress: () => widget.onLongPress?.call(),
+15 -3
View File
@@ -47,6 +47,19 @@ class InputModeTracker extends StatefulWidget {
return of(context, listen: listen) == InputMode.keyboard;
}
/// Live input mode, readable without a [BuildContext].
///
/// Focus-change listeners fire during key dispatch, before the inherited
/// provider has rebuilt for a mode flip, so a context read through [of]
/// is one frame stale on the first navigation key of a keyboard session.
/// The tracker's own state is updated synchronously by its
/// [HardwareKeyboard] handler, which runs before focus traversal moves
/// focus. Falls back to the platform default when no tracker is mounted.
static InputMode get currentMode => _instance?._mode ?? _defaultMode;
/// Keyboard/D-pad on TV, pointer everywhere else.
static InputMode get _defaultMode => PlatformDetector.isTV() ? InputMode.keyboard : InputMode.pointer;
/// Whether system back must be blocked because the dpad key handler owns
/// back navigation: on Android in keyboard mode (TV/gamepad), letting the
/// system back through as well double-pops the route.
@@ -74,8 +87,7 @@ class InputModeTracker extends StatefulWidget {
}
class _InputModeTrackerState extends State<InputModeTracker> {
// Default to keyboard mode on Android TV, pointer mode elsewhere
InputMode _mode = TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer;
InputMode _mode = InputModeTracker._defaultMode;
@override
void initState() {
@@ -134,7 +146,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
Widget build(BuildContext context) {
// Non-desktop TVs keep keyboard mode across synthetic pointer events, but
// their controls remain pointer-reachable for engine-generated taps.
if (TvDetectionService.isTVSync() && !PlatformDetector.isDesktopOS()) {
if (PlatformDetector.isTV() && !PlatformDetector.isDesktopOS()) {
return _InputModeProvider(mode: _mode, child: widget.child);
}
+8 -9
View File
@@ -76,11 +76,10 @@ KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
// a non-keyboard device, but the same down-only handling is still required.
// The suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here
// because onBack typically calls Navigator.pop, swapping the focus tree
// before the matching KeyUp is dispatched — the orphaned KeyUp then never
// reaches a consumeIfSuppressed call, pinning the suppressor armed and
// silently swallowing the next press's KeyDown.
// No suppressor arming is needed here: onBack typically calls
// Navigator.pop, swapping the focus tree before the matching KeyUp is
// dispatched — but the orphaned KeyUp lands on the new chain, where this
// same branch consumes it silently.
if (PlatformDetector.isAppleTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
@@ -246,10 +245,10 @@ class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed and arming it would pin state across the
// pop's focus-tree swap. (The atomic engine fix delivers KeyDown+KeyUp in
// a single recognizer Began callback, so didPop fires squarely inside the
// window where BackKeyPressTracker.isBackKeyDown is true.)
// so the suppressor isn't needed. (The atomic engine fix delivers
// KeyDown+KeyUp in a single recognizer Began callback, so didPop fires
// squarely inside the window where BackKeyPressTracker.isBackKeyDown is
// true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
+8
View File
@@ -32,4 +32,12 @@ class HubFocusMemory {
final remembered = _perHubMemory[hubKey];
return (remembered ?? fallback).clamp(0, itemCount - 1);
}
/// Rewrite a hub's remembered index in place after its content reordered
/// underneath it (a Continue Watching refresh, not user navigation). Unlike
/// [setForHub] this never moves the cross-hub column hint, and it is a no-op
/// for hubs without memory.
void remapForHub(String hubKey, int index) {
if (_perHubMemory.containsKey(hubKey)) _perHubMemory[hubKey] = index;
}
}
+191 -26
View File
@@ -12,6 +12,7 @@
"useBrowser": "Səyahətçini istifadə et",
"or": "və ya",
"connectToMediaBrowser": "${product}-a qoşul",
"quickConnect": "Sürətli Qoşulma",
"useQuickConnect": "Sürətli Qoşulmanı istifadə et",
"quickConnectInstructions": "Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.",
"quickConnectWaiting": "Təsdiq gözlənilir…",
@@ -53,6 +54,7 @@
"mute": "Səsi söndür",
"ok": "Oldu",
"off": "Söndürülüb",
"options": "Seçimlər",
"seasonNumber": "Mövsüm ${number}",
"episodeNumberTitle": "Seriya ${number} - ${title}",
"chapterNumber": "Hissə ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Film",
"show": "Serial",
"season": "Sezon",
"episode": "Epizod",
"artist": "İfaçı",
"album": "Albom",
"track": "Mahnı",
"collection": "Kolleksiya",
"playlist": "Pleylist",
"clip": "Klip",
"photo": "Şəkil",
"folder": "Qovluq"
}
},
"screens": {
"licenses": "Lisenziyalar",
@@ -127,6 +143,10 @@
"displayScale": "Ekran miqyası",
"compact": "Sıx",
"comfortable": "Rəhat",
"gridSpacing": "Tor aralığı",
"gridSpacingTight": "Sıx",
"gridSpacingNormal": "Normal",
"gridSpacingSpacious": "Geniş",
"tvCornerSpotlightBackdrop": "Künc işıqlandırma fonu",
"tvCornerSpotlightBackdropDescription": "Arxa fonu ekranı örtmək əvəzinə sağ üst küncdə göstər",
"viewMode": "Baxış rejimi",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Avadanlıq kod açılması",
"hardwareDecodingDescription": "Mümkün olduqda avadanlıq sürətləndirməsini istifadə et",
"bufferSize": "Bufer həcmi",
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Avtomatik (Tövsiyə olunan)",
"bufferSizeWarning": "${heap}MB yaddaş əlçatandır. ${size}MB bufer oynatmaya təsir edə bilər.",
"playbackBuffer": "Oxutma buferi",
"playbackBufferAuto": "Avtomatik (tövsiyə olunur)",
"playbackBufferLarge": "Böyük",
"playbackBufferExtraLarge": "Çox böyük",
"playbackBufferDescription": "Qeyri-sabit əlaqələrə qarşı daha çox buferləyir. Bufer ölçüsü ilə də məhdudlaşır.",
"defaultQualityTitle": "Defolt keyfiyyət",
"cellularQualityTitle": "Mobil şəbəkədə defolt keyfiyyət",
"cellularQualitySameAsDefault": "Defolt keyfiyyətlə eyni",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Musiqi keyfiyyəti",
"subtitleStyling": "Altyazı tənzimləmələri",
"subtitleStylingDescription": "Altyazı görünüşünü özünüləşdirin",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Hər məzmun üçün səs və altyazı seçimlərini yadda saxla",
"followServerTrackSelections": "Hər epizod üçün serverin trek seçimlərini istifadə et",
"followServerTrackSelectionsDescription": "Epizod dəyişəndə cari seçimi köçürmək əvəzinə serverdə seçilmiş səs və altyazını tətbiq et",
"resumeMusicOnLaunch": "Musiqi sessiyasını yadda saxla",
"resumeMusicOnLaunchDescription": "Tətbiq açılanda son mahnını dayandırıldığı yerdə fasilədə aç",
"showChapterMarkersOnTimeline": "Zaman çubuğunda hissə işarələrini göstər",
"showChapterMarkersOnTimelineDescription": "Zaman çubuğunu hissə sərhədlərinə böl",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Xüsusi bölmələr epizod sırasına görə",
"specialsOrderingDescription": "Xüsusi bölmələrin serialın izləmə sırasındakı yeri",
"specialsOrderingServer": "Server sırasını izlə",
"specialsOrderingAirDate": "Yayım tarixinə görə qarışdır",
"specialsOrderingLast": "Adi mövsümlərdən sonra",
"clickVideoTogglesPlayback": "Oynat/fasilə üçün videoya toxun",
"clickVideoTogglesPlaybackDescription": "İdarəetməni göstərmək əvəzinə oynatmaq/fasilə etmək üçün videoya toxun.",
"videoPlayerControls": "Video oynadıcı idarəetmələri",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Qısayol artıq ${action} üçün təyin edilib",
"shortcutUpdated": "${action} üçün qısayol yeniləndi",
"saveFailed": "Dəyişikliklər yadda saxlanıla bilmədi. Təzədən cəhd edin.",
"autoSkip": "Avtomatik ötür",
"autoPlayAndSkip": "Avtomatik oynat və ötür",
"autoPlayNextEpisode": "Növbəti seriyanı avtomatik oynat",
"autoPlayNextEpisodeDescription": "Bir seriya bitdikdə növbətisini avtomatik başlat",
"playNextCountdown": "Növbəti seriya geri sayımı",
"playNextCountdownImmediate": "Dərhal oynat",
"autoSkipIntro": "Girişi avtomatik ötür",
"autoSkipIntroDescription": "Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür",
"autoSkipCredits": "Titrləri avtomatik ötür",
@@ -291,6 +317,8 @@
"autoPipDescription": "Oynatma zamanı tətbiqdən çıxdıqda avtomatik PiP rejiminə keç",
"matchContentFrameRate": "Kadr tezliyini uyğunlaşdır",
"matchContentFrameRateDescription": "Ekran yenilənmə tezliyini video məzmununa uyğunlaşdır",
"matchContentResolution": "Məzmunun görüntü keyfiyyətinə uyğunlaş",
"matchContentResolutionDescription": "Ekranı videonun öz görüntü keyfiyyətinə keçirir ki, miqyaslandırmanı televizorunuz etsin. Oxutma zamanı menyular və altyazılar da miqyaslandırılır",
"matchRefreshRate": "Yenilənmə tezliyini uyğunlaşdır",
"matchRefreshRateDescription": "Tam ekranda ekran yenilənmə tezliyini uyğunlaşdır",
"matchDynamicRange": "Dinamik diapazonu uyğunlaşdır",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Daxili DV7-ni məcburi et",
"dvConversionDv81Description": "Dolby Vision profile 8.1-ə çevrilməni məcburi et",
"dvConversionHevcStripDescription": "Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et",
"deinterlace": "Deinterleysinq",
"deinterlaceDescription": "Sətirlərarası videodakı darama artefaktlarını aradan qaldır (yalnız mpv oynadıcısı)",
"requireProfileSelectionOnOpen": "Açılışda profil soruş",
"requireProfileSelectionOnOpenDescription": "Tətbiq hər dəfə açıldıqda profil seçimini göstər",
"forceTvMode": "TV rejimini məcburi et",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Plex Discover və qoşulmuş izləmə xidmətlərindəki məzmunla Kəşf et nişanını göstər",
"liveTvDefaultFavorites": "Canlı TV-də sevimli kanalları defolt et",
"liveTvDefaultFavoritesDescription": "Canlı TV açıldıqda yalnız sevimli kanalları göstər",
"general": "Ümumi",
"generalDescription": "Dil, başlanğıc və pəncərə davranışı",
"languageAndRegion": "Dil və Region",
"startup": "Başlanğıc",
"display": "Ekran",
"libraryAndCards": "Kitabxana və kartlar",
"homeScreen": "Ana ekran",
"navigation": "Naviqasiya",
"window": "Pəncərə",
"content": "Məzmun",
"liveTv": "Canlı TV",
"player": "Oynadıcı",
"subtitlesAndConfig": "Altyazılar və konfiqurasiya",
"videoAndDisplay": "Video və Ekran",
"audio": "Səs",
"quality": "Keyfiyyət",
"subtitles": "Altyazılar",
"seekAndTiming": "Sarğı və vaxt tənzimləməsi",
"behavior": "Davranış",
"gestures": "Jestlər",
"gestureBrightnessSwipe": "Parlaqlıq sürüşdürməsi",
"gestureBrightnessSwipeDescription": "Parlaqlığı tənzimləmək üçün sol kənarda yuxarı və ya aşağı sürüşdürün",
"gestureVolumeSwipe": "Səs sürüşdürməsi",
"gestureVolumeSwipeDescription": "Səsi tənzimləmək üçün sağ kənarda yuxarı və ya aşağı sürüşdürün",
"gesturePinchToZoom": "Çimdiklə yaxınlaşdır",
"gesturePinchToZoomDescription": "Yaxınlaşdırmaq və ya uzaqlaşdırmaq üçün videoda çimdik hərəkəti edin",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "İdarəetmələr",
"rememberPlayerChanges": "Pleyer dəyişikliklərini yadda saxla",
"rememberPlayerChangesDescription": "Oxutma zamanı edilən dəyişikliklərin harada saxlanacağı və yenidən tətbiq ediləcəyi",
"scopePlaybackSpeed": "Oxutma sürəti",
"scopeShaderPreset": "Şader ön ayarı",
"scopeAspectRatio": "Tərəf nisbəti",
"scopeSyncOffsets": "Audio və subtitr sinxronizasiyası",
"playerScopeOff": "Saxlama",
"playerScopeGlobal": "Hər yerdə",
"playerScopeLibrary": "Kitabxana üzrə",
"playerScopeTitle": "Serial və ya film üzrə",
"exportDialogTitle": "Plezy tənzimləmələrini ixrac et"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Səs",
"subtitlesLabel": "Altyazı",
"resetToZero": "0ms-yə sıfırla",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} sonra oynadılır",
"playsEarlier": "${label} əvvəl oynadılır",
"noOffset": "Ofset yoxdur",
"letterbox": "Geniş ekran (Letterbox)",
"fillScreen": "Ekrana doldur",
"stretch": "Gərmək",
@@ -670,6 +723,7 @@
"notSupported": "Cihaz PiP rejimini dəstəkləmir",
"voSwitchFailed": "PiP üçün video çıxışı dəyişdirilə bilmədi",
"failed": "PiP rejimi başladılarkən xəta",
"prepareFailed": "PiP rejimi hazırlana bilmədi",
"unknown": "Xəta baş verdi: ${error}"
},
"chapters": "Hissələr",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "${product} qoşulmasını düzəliş et",
"editMediaBrowserIntro": "${serverName} üçün URL-lər əlavə edin və ya silin. Plezy əlçatan olan ən aşağı gecikməli URL-i istifadə edəcək."
},
"accountPreferences": {
"sectionTitle": "Hesab tərcihləri",
"hubSubtitleSingle": "${account} hesabında saxlanılan səs, altyazı və kitabxana seçimləri",
"hubSubtitleMultiple": "${count} hesabda saxlanılan səs, altyazı və kitabxana seçimləri",
"pickAccount": "Hər hesab öz tərcihlərini saxlayır. Düzəliş etmək üçün birini seçin.",
"storedOnAccount": "Bu seçimlər hesabın özündə saxlanılır, ona görə də ona daxil olan hər tətbiq onlardan istifadə edir — digər cihazlarınızdakı Plezy də daxil olmaqla.",
"noAccounts": "Konfiqurasiya ediləcək hesab yoxdur",
"noAccountsHint": "Plex-ə daxil olun və ya Jellyfin və ya Emby serverinə qoşulun; o hesabda saxlanılan tərcihlər burada görünəcək.",
"unavailable": "Bu hesaba çatmaq olmur",
"loadFailed": "Bu tərcihlər yüklənə bilmədi",
"noPreference": "Tərcih yoxdur",
"notSet": "Təyin edilməyib",
"groups": {
"audioAndSubtitles": "Səs və altyazılar",
"libraryDisplay": "Kitabxana",
"personalMedia": "Şəxsi media"
},
"preferredAudioLanguage": "Üstünlük verilən səs dili",
"autoSelectAudio": "Səsi dilə görə seç",
"autoSelectAudioDescription": "Söndürüldükdə faylın defolt olaraq işarələdiyi səs treki istifadə olunur.",
"preferredSubtitleLanguage": "Üstünlük verilən altyazı dili",
"subtitleMode": "Altyazıları aç",
"subtitleModes": {
"none": "Əl ilə seçilmiş",
"noneDescription": "Altyazıları heç vaxt özbaşına açma.",
"defaultMode": "Trek bayraqlarına əməl et",
"defaultModeDescription": "Hər altyazı trekində saxlanılan defolt və məcburi bayraqları istifadə et.",
"always": "Həmişə aktivdir",
"alwaysDescription": "Üstünlük verilən dildə altyazı treki mövcuddursa, onu aç.",
"onlyForced": "Yalnız məcburi altyazılar",
"onlyForcedDescription": "Yalnız məcburi işarələnmiş trekləri yüklə.",
"smart": "Xarici səs olduqda göstərilən",
"smartDescription": "Altyazıları yalnız səs başqa dildə olduqda aç."
},
"subtitleAccessibility": "SDH altyazıları",
"subtitleAccessibilityOptions": {
"preferNonSdh": "SDH olmayan altyazılara üstünlük ver",
"preferSdh": "SDH altyazılarına üstünlük ver",
"onlySdh": "Yalnız SDH altyazıları",
"onlyNonSdh": "Yalnız SDH olmayan altyazılar"
},
"forcedSubtitles": "Məcburi altyazılar",
"forcedSubtitleOptions": {
"preferNonForced": "Məcburi olmayan altyazılara üstünlük ver",
"preferForced": "Məcburi altyazılara üstünlük ver",
"onlyForced": "Yalnız məcburi altyazılar",
"onlyNonForced": "Yalnız məcburi olmayan altyazılar"
},
"displayMissingEpisodes": "Çatışmayan seriyaları göstər",
"displayMissingEpisodesDescription": "Serverin bildiyi, lakin faylı olmayan seriyaları siyahıya al.",
"hidePlayedInLatest": "Baxılmış elementləri Son əlavə olunanlarda gizlət",
"hidePlayedInLatestDescription": "Artıq baxdığınız elementləri serverin Son əlavə olunanlar sətirlərində göstərmə.",
"displayCollectionsView": "Kolleksiyalar görünüşünü göstər",
"displayCollectionsViewDescription": "Kitabxanalarınızla yanaşı serverin Kolleksiyalar görünüşünü də təklif et.",
"rewatchingInNextUp": "Təkrar baxılan serialları Sırada saxla",
"rewatchingInNextUpDescription": "Bir serialı bitirdikdən sonra yenidən başlasanız, Sırada serialı atmaq əvəzinə təkrar baxışı izləyir.",
"watchedIndicator": "Baxıldı göstəriciləri",
"watchedIndicatorOptions": {
"none": "Heç vaxt",
"moviesAndShows": "Kinolar və TV şoular",
"movies": "Yalnız kinolar",
"shows": "Yalnız TV şoular"
},
"mediaReviewsVisibility": "Reytinq və rəylər",
"mediaReviewsOptions": {
"usersAndCritics": "İstifadəçilər və tənqidçilər",
"usersOnly": "Yalnız istifadəçilər",
"criticsOnly": "Yalnız tənqidçilər",
"nobody": "Gizlədilib"
}
},
"discover": {
"title": "Kəşf et",
"noContentAvailable": "Məzmun əlçatan deyil",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "${username} (${email}) üçün server tapılmadı",
"failedToLoadServers": "Serverlər yüklənə bilmədi: ${error}"
"failedToLoadServers": "Serverlər yüklənə bilmədi: ${error}",
"noValidServers": "Bu hesabda istifadəyə yararlı server tapılmadı"
},
"hubDetail": {
"title": "Başlıq",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} populyar",
"rankAiring": "#${n} yayımlanır",
"rankRated": "#${n} qiymətləndirilib",
"rankFavorited": "#${n} favorit",
"rankTrending": "#${n} trenddə",
"rankSeasonal": "${season} üçün #${n}",
"watchingNow": "${n} izləyir",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Bilinməyən kanal",
"live": "CANLI",
"reloadGuide": "Bələdçini yenilə",
"searchGuide": "Bələdçidə axtar",
"searchHint": "Kanal və proqram axtar",
"searchNoResults": "\"${query}\" üçün uyğunluq tapılmadı",
"channelsSection": "Kanallar",
"programsSection": "Proqramlar",
"now": "İndi",
"today": "Bu gün",
"tomorrow": "Sabah",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Bələdçi yenilənməsi tələb olundu",
"rulesProcessRequested": "Qaydaların yenidən qiymətləndirilməsi tələb olundu",
"recordShow": "Şounu yaz",
"recordSettings": {
"startEarly": "Erkən başla (saniyə)",
"endLate": "Gec bitir (saniyə)",
"newOnly": "Yalnız yeni epizodlar",
"anyChannel": "İstənilən kanalda yaz",
"anyTime": "İstənilən vaxt yaz",
"skipInLibrary": "Kitabxanada artıq olan epizodları ötür",
"keepUpTo": "Saxlanılacaq epizodlar",
"keepUpToHint": "0 bütün epizodları saxlayır"
},
"startingInMinutes": "${minutes} dəq sonra başlayır",
"dayAtTime": "${day}, saat ${time}",
"invalidPlaybackData": "${product} etibarsız Canlı TV oynatma məlumatı qaytardı",
@@ -1416,7 +1556,14 @@
"repeatAll": "Hamısını təkrarla",
"repeatOne": "Birini təkrarla",
"instantMixNoServer": "Ani miks üçün heç bir server mövcud deyil",
"noAudioUrl": "${track} üçün səs URL-i mövcud deyil"
"instantMixFailed": "Anında qarışıq yüklənə bilmədi",
"instantMixEmpty": "Anında qarışıq heç bir mahnı qaytarmadı",
"noAudioUrl": "${track} üçün səs URL-i mövcud deyil",
"discography": {
"singlesAndEps": "Single-lar və EP-lər",
"live": "Canlı",
"compilations": "Kompilyasiyalar"
}
},
"watchTogether": {
"title": "Birlikdə İzlə",
@@ -1442,6 +1589,13 @@
"host": "Təşkilatçı",
"hostBadge": "TƏŞKİLATÇI",
"youAreHost": "Təşkilatçı sizsiniz",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Başqaları ilə izlənilir",
"endSession": "Seansı bitir",
"leaveSession": "Seansdan çıx",
@@ -1484,7 +1638,13 @@
"removeRoom": "Sil",
"guestSwitchUnavailable": "Keçid etmək olmadı — eyniləşdirmə üçün server əlçatan deyil",
"guestSwitchFailed": "Keçid etmək olmadı — məzmun tapılmadı",
"defaultDisplayName": "İstifadəçi"
"defaultDisplayName": "İstifadəçi",
"errors": {
"timedOut": "Rele serveri vaxtında cavab vermədi",
"connectionLost": "Bağlantı seans hazır olmamış kəsildi",
"invalidRelayResponse": "Rele serveri gözlənilməz cavab göndərdi",
"sessionEnded": "Təşkilatçı seansı bitirdi"
}
},
"downloads": {
"title": "Yükləmələr",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Bu tətbiqi idarə etmək üçün mobil cihazınızı istifadə edin",
"startServer": "Serveri başlat",
"stopServer": "Serveri dayandır",
"minimize": "Yığ"
"minimize": "Yığ",
"manualAddressHint": "Əl ilə bağlantı ünvanı:"
},
"pairing": {
"discoveryDescription": "Eyni Plex hesabına sahib Plezy cihazları burada görünür",
@@ -1726,6 +1887,7 @@
"decoder": "Çözücü",
"rawDecoder": "Xam çözücü",
"tunneling": "Tünelləmə",
"passthrough": "Birbaşa ötürmə",
"aspect": "Nisbət",
"rotation": "Dönmə",
"dvSource": "DV mənbəyi",
@@ -1857,9 +2019,7 @@
"producer": "Prodüser",
"country": "Ölkə",
"collection": "Kolleksiya",
"label": "Etiket",
"style": "Stil",
"mood": "Əhval-ruhiyyə"
"label": "Etiket"
},
"matchScreen": {
"match": "Uyğunlaşdır...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Hissəvi əlçatandır",
"statusRequested": "Sorğu göndərildi",
"statusProcessing": "Emal edilir",
"statusBlocklisted": "Bloklanmış",
"couldNotReach": "${url} ünvanına çatmaq olmadı: ${error}",
"noInstanceAtUrl": "${url} ünvanında Seerr instansiyası yoxdur (HTTP ${status})",
"invalidUrl": "https://seerr.example.com kimi server ünvanı daxil edin",
"quickConnectUnsupported": "Bu Seerr nüsxəsi Sürətli Qoşulmanı dəstəkləmir. Seerr 3.4 və ya daha yeni versiya tələb olunur.",
"notInitialized": "Bu Seerr instansiyasının ilkin quraşdırılması tamamlanmayıb",
"noPlexTokenForReauth": "Yenidən daxil olmaq üçün Plex tokeni mövcud deyil",
"noStoredCredentials": "Yenidən daxil olmaq üçün yadda saxlanmış giriş məlumatları mövcud deyil",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Xidmətlər",
"hubSubtitle": "İzləmə tərəqqisini eyniləşdirin və yeni başlıqlar sorğulayın.",
"integrations": "İnteqrasiyalar",
"notConnected": "Qoşulmayıb",
"connectedAs": "@${username} olaraq qoşuldu",
"scrobble": "Tərəqqini avtomatik izlə",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Başqa profilin qoşulmasını yenidən istifadə edin.",
"invalidCredentials": "İstifadəçi adı və ya şifrə yanlışdır",
"authResponseNotJson": "Autentifikasiya cavabı etibarlı JSON deyildi",
"authResponseIncomplete": "Serverin giriş cavabı natamam idi",
"quickConnectRejected": "Quick Connect server tərəfindən rədd edildi",
"quickConnectNotJson": "Quick Connect cavabı etibarlı JSON deyildi",
"quickConnectMissingFields": "Quick Connect cavabında kod və ya məxfi açar yoxdur",
+191 -26
View File
@@ -12,6 +12,7 @@
"useBrowser": "Използвай браузър",
"or": "или",
"connectToMediaBrowser": "Свържи се с ${product}",
"quickConnect": "Quick Connect",
"useQuickConnect": "Използвай Quick Connect",
"quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.",
"quickConnectWaiting": "Изчакване на одобрение…",
@@ -53,6 +54,7 @@
"mute": "Заглуши",
"ok": "OK",
"off": "Изкл.",
"options": "Опции",
"seasonNumber": "Сезон ${number}",
"episodeNumberTitle": "Епизод ${number} - ${title}",
"chapterNumber": "Глава ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "Н/Д",
"url": "URL",
"letterKeys": "АБВ"
"letterKeys": "АБВ",
"mediaKind": {
"movie": "Филм",
"show": "Сериал",
"season": "Сезон",
"episode": "Епизод",
"artist": "Изпълнител",
"album": "Албум",
"track": "Песен",
"collection": "Колекция",
"playlist": "Плейлист",
"clip": "Клип",
"photo": "Снимка",
"folder": "Папка"
}
},
"screens": {
"licenses": "Лицензи",
@@ -127,6 +143,10 @@
"displayScale": "Мащаб на дисплея",
"compact": "Компактна",
"comfortable": "Удобна",
"gridSpacing": "Разстояние на мрежата",
"gridSpacingTight": "Плътно",
"gridSpacingNormal": "Нормално",
"gridSpacingSpacious": "Просторно",
"tvCornerSpotlightBackdrop": "Фон с акцент в ъгъла",
"tvCornerSpotlightBackdropDescription": "Показвай акцентното изображение в горния десен ъгъл, вместо на целия екран",
"viewMode": "Режим на изглед",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Хардуерно декодиране",
"hardwareDecodingDescription": "Използвай хардуерно ускорение, когато е налично",
"bufferSize": "Размер на буфера",
"bufferSizeMB": "${size} MB",
"bufferSizeAuto": "Автоматично (препоръчително)",
"bufferSizeWarning": "Налична памет: ${heap} MB. Буфер от ${size} MB може да повлияе на възпроизвеждането.",
"playbackBuffer": "Буфер за възпроизвеждане",
"playbackBufferAuto": "Автоматично (препоръчително)",
"playbackBufferLarge": "Голям",
"playbackBufferExtraLarge": "Изключително голям",
"playbackBufferDescription": "Буферира повече при нестабилни връзки. Също ограничен от размера на буфера.",
"defaultQualityTitle": "Качество по подразбиране",
"cellularQualityTitle": "Качество по подразбиране при мобилни данни",
"cellularQualitySameAsDefault": "Същото като качеството по подразбиране",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Качество на музиката",
"subtitleStyling": "Стил на субтитрите",
"subtitleStylingDescription": "Настройване на вида на субтитрите",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Запомняй избора на аудиопътечка и субтитри за всяко заглавие",
"followServerTrackSelections": "Използвай избора на пътечки от сървъра за всеки епизод",
"followServerTrackSelectionsDescription": "При смяна на епизода прилагай избраните на сървъра аудио и субтитри, вместо да се пренася текущият избор",
"resumeMusicOnLaunch": "Запомняне на музикалната сесия",
"resumeMusicOnLaunchDescription": "При стартиране на приложението отваряй последната песен на пауза от мястото, докъдето е стигнала",
"showChapterMarkersOnTimeline": "Показвай маркери на глави върху времевата линия",
"showChapterMarkersOnTimelineDescription": "Разделяй времевата линия на сегменти по границите на главите",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Специални епизоди в реда на епизодите",
"specialsOrderingDescription": "Къде се възпроизвеждат специалните епизоди в реда за гледане на сериала",
"specialsOrderingServer": "Следвай реда на сървъра",
"specialsOrderingAirDate": "Подреждай по дата на излъчване",
"specialsOrderingLast": "След редовните сезони",
"clickVideoTogglesPlayback": "Клик върху видеото превключва възпроизвеждане/пауза",
"clickVideoTogglesPlaybackDescription": "Клик върху видеото пуска/паузира вместо да показва контролите.",
"videoPlayerControls": "Контроли на видео плейъра",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Клавишната комбинация вече е назначена за ${action}",
"shortcutUpdated": "Клавишната комбинация е обновена за ${action}",
"saveFailed": "Промените не можаха да бъдат запазени. Опитайте отново.",
"autoSkip": "Автоматично прескачане",
"autoPlayAndSkip": "Автоматично пускане и прескачане",
"autoPlayNextEpisode": "Автоматично пускане на следващия епизод",
"autoPlayNextEpisodeDescription": "Пускай следващия епизод автоматично, когато текущият свърши",
"playNextCountdown": "Отброяване до следващия епизод",
"playNextCountdownImmediate": "Пусни веднага",
"autoSkipIntro": "Автоматично прескачане на интро",
"autoSkipIntroDescription": "Автоматично прескачай интро маркери след няколко секунди",
"autoSkipCredits": "Автоматично прескачане на финални надписи",
@@ -291,6 +317,8 @@
"autoPipDescription": "Автоматично включвай режима картина в картината при излизане от приложението по време на възпроизвеждане",
"matchContentFrameRate": "Напасване към кадровата честота на съдържанието",
"matchContentFrameRateDescription": "Напасни честотата на опресняване на дисплея към видео съдържанието",
"matchContentResolution": "Съобразяване с разделителната способност на съдържанието",
"matchContentResolutionDescription": "Превключва дисплея към собствената разделителна способност на видеото, за да се погрижи телевизорът за мащабирането. По време на възпроизвеждане менютата и субтитрите също се мащабират",
"matchRefreshRate": "Напасване на честотата на опресняване",
"matchRefreshRateDescription": "Напасни честотата на опресняване на дисплея при цял екран",
"matchDynamicRange": "Напасване на динамичния диапазон",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване",
"dvConversionDv81Description": "Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1",
"dvConversionHevcStripDescription": "Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток",
"deinterlace": "Деинтерлейсинг",
"deinterlaceDescription": "Премахва гребеновидните артефакти от интерлейсирано видео (само за mpv плейъра)",
"requireProfileSelectionOnOpen": "Питай за профил при отваряне на приложението",
"requireProfileSelectionOnOpenDescription": "Показвай избор на профил всеки път при отваряне на приложението",
"forceTvMode": "Принуди TV режим",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Показва раздела „Открий“ със съдържание от Plex Discover и свързаните тракери",
"liveTvDefaultFavorites": "По подразбиране към любими канали",
"liveTvDefaultFavoritesDescription": "Показвай само любими канали при отваряне на телевизия на живо",
"general": "Общи",
"generalDescription": "Език, стартиране и поведение на прозореца",
"languageAndRegion": "Език и регион",
"startup": "Стартиране",
"display": "Дисплей",
"libraryAndCards": "Библиотека и карти",
"homeScreen": "Начален екран",
"navigation": "Навигация",
"window": "Прозорец",
"content": "Съдържание",
"liveTv": "Телевизия на живо",
"player": "Плейър",
"subtitlesAndConfig": "Субтитри и конфигурация",
"videoAndDisplay": "Видео и дисплей",
"audio": "Аудио",
"quality": "Качество",
"subtitles": "Субтитри",
"seekAndTiming": "Търсене и време",
"behavior": "Поведение",
"gestures": "Жестове",
"gestureBrightnessSwipe": "Плъзгане за яркост",
"gestureBrightnessSwipeDescription": "Плъзни нагоре или надолу по левия ръб, за да регулираш яркостта",
"gestureVolumeSwipe": "Плъзгане за сила на звука",
"gestureVolumeSwipeDescription": "Плъзни нагоре или надолу по десния ръб, за да регулираш силата на звука",
"gesturePinchToZoom": "Стискане за мащабиране",
"gesturePinchToZoomDescription": "Стисни видеото, за да увеличиш или намалиш мащаба",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Контроли",
"rememberPlayerChanges": "Запомняне на промените в плейъра",
"rememberPlayerChangesDescription": "Къде се записва и откъде се прилага отново промяна, направена по време на възпроизвеждане",
"scopePlaybackSpeed": "Скорост на възпроизвеждане",
"scopeShaderPreset": "Предварителна настройка на шейдъра",
"scopeAspectRatio": "Съотношение на страните",
"scopeSyncOffsets": "Синхронизация на аудио и субтитри",
"playerScopeOff": "Не запазвай",
"playerScopeGlobal": "Навсякъде",
"playerScopeLibrary": "По библиотека",
"playerScopeTitle": "По сериал или филм",
"exportDialogTitle": "Експортиране на настройките на Plezy"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Аудио",
"subtitlesLabel": "Субтитри",
"resetToZero": "Нулирай до 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} се възпроизвежда по-късно",
"playsEarlier": "${label} се възпроизвежда по-рано",
"noOffset": "Без отместване",
"letterbox": "Черни ленти",
"fillScreen": "Запълни екрана",
"stretch": "Разтегни",
@@ -670,6 +723,7 @@
"notSupported": "Устройството не поддържа режим картина в картината",
"voSwitchFailed": "Неуспешна смяна на видео изхода за режим картина в картината",
"failed": "Режимът картина в картината не успя да стартира",
"prepareFailed": "Режимът картина в картината не можа да бъде подготвен",
"unknown": "Възникна грешка: ${error}"
},
"chapters": "Глави",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Редактирай връзката с ${product}",
"editMediaBrowserIntro": "Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност."
},
"accountPreferences": {
"sectionTitle": "Предпочитания на акаунта",
"hubSubtitleSingle": "Опции за аудио, субтитри и библиотека, запазени на ${account}",
"hubSubtitleMultiple": "Опции за аудио, субтитри и библиотека, запазени на ${count} акаунта",
"pickAccount": "Всеки акаунт съхранява собствени предпочитания. Изберете кой да редактирате.",
"storedOnAccount": "Тези опции се запазват на самия акаунт, така че всяко приложение, вписано в него, ги използва — включително Plezy на другите ви устройства.",
"noAccounts": "Няма акаунти за настройка",
"noAccountsHint": "Влезте в Plex или свържете Jellyfin или Emby сървър и предпочитанията, запазени на този акаунт, ще се появят тук.",
"unavailable": "Акаунтът не може да бъде достигнат",
"loadFailed": "Предпочитанията не можаха да бъдат заредени",
"noPreference": "Без предпочитание",
"notSet": "Не е зададено",
"groups": {
"audioAndSubtitles": "Аудио и субтитри",
"libraryDisplay": "Библиотека",
"personalMedia": "Лична медия"
},
"preferredAudioLanguage": "Предпочитан аудио език",
"autoSelectAudio": "Избирай аудио по език",
"autoSelectAudioDescription": "При изключено се запазва аудиопътечката, която файлът маркира като подразбираща се.",
"preferredSubtitleLanguage": "Предпочитан език за субтитри",
"subtitleMode": "Включване на субтитри",
"subtitleModes": {
"none": "Ръчно избрани",
"noneDescription": "Никога не включвай субтитри автоматично.",
"defaultMode": "Следвай флаговете на пътечката",
"defaultModeDescription": "Използвай флаговете по подразбиране и принудителните, съхранени на всяка пътечка със субтитри.",
"always": "Винаги включени",
"alwaysDescription": "Включвай пътечка със субтитри на предпочитания език, когато има такава.",
"onlyForced": "Само принудителни субтитри",
"onlyForcedDescription": "Зареждай само пътечките, маркирани като принудителни.",
"smart": "Показване при чуждоезично аудио",
"smartDescription": "Включвай субтитри само когато аудиото е на друг език."
},
"subtitleAccessibility": "SDH субтитри",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Предпочитай субтитри без SDH",
"preferSdh": "Предпочитай SDH субтитри",
"onlySdh": "Само SDH субтитри",
"onlyNonSdh": "Само субтитри без SDH"
},
"forcedSubtitles": "Принудителни субтитри",
"forcedSubtitleOptions": {
"preferNonForced": "Предпочитай субтитри без принудителни",
"preferForced": "Предпочитай принудителни субтитри",
"onlyForced": "Само принудителни субтитри",
"onlyNonForced": "Само субтитри без принудителни"
},
"displayMissingEpisodes": "Показвай липсващи епизоди",
"displayMissingEpisodesDescription": "Изброявай епизоди, за които сървърът знае, но няма файл.",
"hidePlayedInLatest": "Скривай изгледаните елементи в „Последни“",
"hidePlayedInLatestDescription": "Не включвай вече изгледаните елементи в редовете „Последни“ на сървъра.",
"displayCollectionsView": "Показвай изгледа „Колекции“",
"displayCollectionsViewDescription": "Предлагай изгледа „Колекции“ на сървъра редом с библиотеките ви.",
"rewatchingInNextUp": "Запазвай повторно гледаните сериали в „Следва“",
"rewatchingInNextUpDescription": "Когато завършите сериал и го пуснете отново, „Следва“ проследява повторното гледане, вместо да премахва сериала.",
"watchedIndicator": "Индикатори за изгледано",
"watchedIndicatorOptions": {
"none": "Никога",
"moviesAndShows": "Филми и ТВ сериали",
"movies": "Само филми",
"shows": "Само ТВ сериали"
},
"mediaReviewsVisibility": "Оценки и ревюта",
"mediaReviewsOptions": {
"usersAndCritics": "Потребители и критици",
"usersOnly": "Само потребители",
"criticsOnly": "Само критици",
"nobody": "Скрити"
}
},
"discover": {
"title": "Открий",
"noContentAvailable": "Няма налично съдържание",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "Не са намерени сървъри за ${username} (${email})",
"failedToLoadServers": "Неуспешно зареждане на сървъри: ${error}"
"failedToLoadServers": "Неуспешно зареждане на сървъри: ${error}",
"noValidServers": "Не бяха намерени използваеми сървъри в този акаунт"
},
"hubDetail": {
"title": "Заглавие",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} популярен",
"rankAiring": "#${n} в ефир",
"rankRated": "#${n} оценен",
"rankFavorited": "#${n} най-харесван",
"rankTrending": "#${n} в тенденция",
"rankSeasonal": "#${n} през ${season}",
"watchingNow": "${n} гледат",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Неизвестен канал",
"live": "НА ЖИВО",
"reloadGuide": "Презареди ТВ програмата",
"searchGuide": "Търсене в програмата",
"searchHint": "Търсене на канали и предавания",
"searchNoResults": "Няма съвпадения за \"${query}\"",
"channelsSection": "Канали",
"programsSection": "Предавания",
"now": "Сега",
"today": "Днес",
"tomorrow": "Утре",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Заявено е опресняване на ТВ програмата",
"rulesProcessRequested": "Заявена е преоценка на правилата",
"recordShow": "Запиши предаването",
"recordSettings": {
"startEarly": "Започване по-рано (секунди)",
"endLate": "Приключване по-късно (секунди)",
"newOnly": "Само нови епизоди",
"anyChannel": "Записване от всеки канал",
"anyTime": "Записване по всяко време",
"skipInLibrary": "Пропускане на епизоди, които вече са в библиотеката",
"keepUpTo": "Епизоди за запазване",
"keepUpToHint": "0 запазва всички епизоди"
},
"startingInMinutes": "Започва след ${minutes} мин",
"dayAtTime": "${day} в ${time}",
"invalidPlaybackData": "${product} върна невалидни данни за възпроизвеждане на телевизия на живо",
@@ -1416,7 +1556,14 @@
"repeatAll": "Повтаряне на всички",
"repeatOne": "Повтаряне на една",
"instantMixNoServer": "Няма наличен сървър за незабавен микс",
"noAudioUrl": "Няма наличен URL за аудиото на ${track}"
"instantMixFailed": "Мигновеният микс не можа да бъде зареден",
"instantMixEmpty": "Мигновеният микс не върна песни",
"noAudioUrl": "Няма наличен URL за аудиото на ${track}",
"discography": {
"singlesAndEps": "Сингли и EP",
"live": "Концертни",
"compilations": "Компилации"
}
},
"watchTogether": {
"title": "Гледане заедно",
@@ -1442,6 +1589,13 @@
"host": "Организатор",
"hostBadge": "ОРГАНИЗАТОР",
"youAreHost": "Вие сте организаторът",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Гледате с други",
"endSession": "Край на сесията",
"leaveSession": "Напусни сесията",
@@ -1484,7 +1638,13 @@
"removeRoom": "Премахни",
"guestSwitchUnavailable": "Превключването не е възможно — сървърът е недостъпен за синхронизация",
"guestSwitchFailed": "Превключването не е възможно — съдържанието не е намерено на този сървър",
"defaultDisplayName": "Потребител"
"defaultDisplayName": "Потребител",
"errors": {
"timedOut": "Релейният сървър не отговори навреме",
"connectionLost": "Връзката се затвори, преди сесията да е готова",
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
"sessionEnded": "Организаторът прекрати сесията"
}
},
"downloads": {
"title": "Изтегляния",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Използвайте мобилното си устройство, за да управлявате това приложение",
"startServer": "Стартирай сървър",
"stopServer": "Спри сървър",
"minimize": "Минимизирай"
"minimize": "Минимизирай",
"manualAddressHint": "Ръчен адрес за връзка:"
},
"pairing": {
"discoveryDescription": "Plezy устройства със същия Plex акаунт се показват тук",
@@ -1726,6 +1887,7 @@
"decoder": "Декодер",
"rawDecoder": "Суров декодер",
"tunneling": "Тунелиране",
"passthrough": "Директно предаване",
"aspect": "Съотношение",
"rotation": "Завъртане",
"dvSource": "DV източник",
@@ -1857,9 +2019,7 @@
"producer": "Продуцент",
"country": "Държава",
"collection": "Колекция",
"label": "Етикет",
"style": "Стил",
"mood": "Настроение"
"label": "Етикет"
},
"matchScreen": {
"match": "Съпостави...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Частично налично",
"statusRequested": "Заявено",
"statusProcessing": "Обработва се",
"statusBlocklisted": "В списъка с блокирани",
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
"noPlexTokenForReauth": "Няма наличен Plex токен за повторен вход",
"noStoredCredentials": "Няма запазени данни за повторен вход",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Услуги",
"hubSubtitle": "Синхронизирай прогреса на гледане и заявявай нови заглавия.",
"integrations": "Интеграции",
"notConnected": "Няма връзка",
"connectedAs": "Свързан като @${username}",
"scrobble": "Проследявай прогреса автоматично",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Използвай връзка от друг профил. PIN-защитените профили изискват PIN.",
"invalidCredentials": "Невалидно потребителско име или парола",
"authResponseNotJson": "Отговорът при удостоверяване не беше валиден JSON",
"authResponseIncomplete": "Отговорът за вход от сървъра беше непълен",
"quickConnectRejected": "Quick Connect беше отхвърлен от сървъра",
"quickConnectNotJson": "Отговорът на Quick Connect не беше валиден JSON",
"quickConnectMissingFields": "В отговора на Quick Connect липсва код или таен ключ",
+191 -26
View File
@@ -12,6 +12,7 @@
"useBrowser": "Brug browseren",
"or": "eller",
"connectToMediaBrowser": "Opret forbindelse til ${product}",
"quickConnect": "Quick Connect",
"useQuickConnect": "Brug Quick Connect",
"quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.",
"quickConnectWaiting": "Venter på godkendelse…",
@@ -53,6 +54,7 @@
"mute": "Lydløs",
"ok": "OK",
"off": "Fra",
"options": "Valgmuligheder",
"seasonNumber": "Sæson ${number}",
"episodeNumberTitle": "Episode ${number} – ${title}",
"chapterNumber": "Kapitel ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Film",
"show": "Serie",
"season": "Sæson",
"episode": "Afsnit",
"artist": "Kunstner",
"album": "Album",
"track": "Nummer",
"collection": "Samling",
"playlist": "Afspilningsliste",
"clip": "Klip",
"photo": "Foto",
"folder": "Mappe"
}
},
"screens": {
"licenses": "Licenser",
@@ -127,6 +143,10 @@
"displayScale": "Skalering",
"compact": "Kompakt",
"comfortable": "Komfortabel",
"gridSpacing": "Gitterafstand",
"gridSpacingTight": "Tæt",
"gridSpacingNormal": "Normal",
"gridSpacingSpacious": "Rummelig",
"tvCornerSpotlightBackdrop": "Fremhævet baggrundsbillede i hjørnet",
"tvCornerSpotlightBackdropDescription": "Vis fremhævet grafik i øverste højre hjørne i stedet for at fylde skærmen",
"viewMode": "Visningstilstand",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Hardwaredekodning",
"hardwareDecodingDescription": "Brug hardwareacceleration, når den er tilgængelig",
"bufferSize": "Bufferstørrelse",
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automatisk (anbefalet)",
"bufferSizeWarning": "${heap} MB hukommelse tilgængelig. En buffer på ${size} MB kan påvirke afspilningen.",
"playbackBuffer": "Afspilningsbuffer",
"playbackBufferAuto": "Auto (anbefalet)",
"playbackBufferLarge": "Stor",
"playbackBufferExtraLarge": "Ekstra stor",
"playbackBufferDescription": "Bufrer mere mod ustabile forbindelser. Begrænses også af bufferstørrelsen.",
"defaultQualityTitle": "Standardkvalitet",
"cellularQualityTitle": "Standardkvalitet på mobildata",
"cellularQualitySameAsDefault": "Samme som standardkvalitet",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Musikkvalitet",
"subtitleStyling": "Undertekststil",
"subtitleStylingDescription": "Tilpas underteksters udseende",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Husk valget af lyd og undertekster for hver titel",
"followServerTrackSelections": "Brug serverens sporvalg for hvert afsnit",
"followServerTrackSelectionsDescription": "Ved afsnitsskift anvendes lyden og underteksterne valgt på serveren i stedet for at videreføre det aktuelle valg",
"resumeMusicOnLaunch": "Husk musiksession",
"resumeMusicOnLaunchDescription": "Åbn den seneste sang på pause der, hvor den slap, når appen starter",
"showChapterMarkersOnTimeline": "Vis kapitelmarkører på tidslinjen",
"showChapterMarkersOnTimelineDescription": "Opdel tidslinjen ved kapitelgrænser",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Specialafsnit i episoderekkefølge",
"specialsOrderingDescription": "Hvor specialafsnit afspilles i seriens visningsrækkefølge",
"specialsOrderingServer": "Følg serverrækkefølgen",
"specialsOrderingAirDate": "Indflet efter udsendelsesdato",
"specialsOrderingLast": "Efter almindelige sæsoner",
"clickVideoTogglesPlayback": "Klik på videoen for at skifte mellem afspilning og pause",
"clickVideoTogglesPlaybackDescription": "Klik på videoen for at afspille eller sætte på pause i stedet for at vise betjeningsknapperne.",
"videoPlayerControls": "Videoafspillerkontroller",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Genvejen er allerede tildelt ${action}",
"shortcutUpdated": "Genvejen for ${action} er opdateret",
"saveFailed": "Ændringerne kunne ikke gemmes. Prøv igen.",
"autoSkip": "Automatisk spring",
"autoPlayAndSkip": "Autoafspilning og spring",
"autoPlayNextEpisode": "Autoafspil næste afsnit",
"autoPlayNextEpisodeDescription": "Start automatisk næste afsnit, når et afsnit slutter",
"playNextCountdown": "Nedtælling til næste afsnit",
"playNextCountdownImmediate": "Afspil med det samme",
"autoSkipIntro": "Spring intro over automatisk",
"autoSkipIntroDescription": "Spring automatisk intromarkører over efter få sekunder",
"autoSkipCredits": "Spring rulletekster over automatisk",
@@ -291,6 +317,8 @@
"autoPipDescription": "Skift automatisk til billede-i-billede, når du forlader appen under afspilning",
"matchContentFrameRate": "Tilpas billedhastigheden til indholdet",
"matchContentFrameRateDescription": "Tilpas skærmens opdateringsfrekvens til videoindhold",
"matchContentResolution": "Tilpas til indholdets opløsning",
"matchContentResolutionDescription": "Skifter skærmen til videoens oprindelige opløsning, så dit tv står for opskaleringen. Menuer og undertekster opskaleres også under afspilning",
"matchRefreshRate": "Tilpas opdateringsfrekvensen",
"matchRefreshRateDescription": "Tilpas skærmens opdateringsfrekvens i fuld skærm",
"matchDynamicRange": "Tilpas dynamikområdet",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen",
"dvConversionDv81Description": "Tving inline RPU-konvertering til Dolby Vision profil 8.1",
"dvConversionHevcStripDescription": "Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC",
"deinterlace": "Deinterlacing",
"deinterlaceDescription": "Fjern kamartefakter fra interlaced video (kun mpv-afspiller)",
"requireProfileSelectionOnOpen": "Spørg om profil ved åbning",
"requireProfileSelectionOnOpenDescription": "Vis profilvalg hver gang appen åbnes",
"forceTvMode": "Gennemtving TV-tilstand",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Vis fanen Opdag med indhold fra Plex Discover og tilknyttede trackere",
"liveTvDefaultFavorites": "Vis favoritkanaler som standard",
"liveTvDefaultFavoritesDescription": "Vis kun favoritkanaler ved åbning af Live TV",
"general": "Generelt",
"generalDescription": "Sprog, opstart og vinduesadfærd",
"languageAndRegion": "Sprog og region",
"startup": "Opstart",
"display": "Skærm",
"libraryAndCards": "Bibliotek og kort",
"homeScreen": "Startskærm",
"navigation": "Navigation",
"window": "Vindue",
"content": "Indhold",
"liveTv": "Live TV",
"player": "Afspiller",
"subtitlesAndConfig": "Undertekster og konfiguration",
"videoAndDisplay": "Video og skærm",
"audio": "Lyd",
"quality": "Kvalitet",
"subtitles": "Undertekster",
"seekAndTiming": "Søgning og timing",
"behavior": "Adfærd",
"gestures": "Bevægelser",
"gestureBrightnessSwipe": "Lysstyrke-strygning",
"gestureBrightnessSwipeDescription": "Stryg op eller ned i venstre kant for at justere lysstyrken",
"gestureVolumeSwipe": "Lydstyrke-strygning",
"gestureVolumeSwipeDescription": "Stryg op eller ned i højre kant for at justere lydstyrken",
"gesturePinchToZoom": "Klem for at zoome",
"gesturePinchToZoomDescription": "Klem på videoen for at zoome ind eller ud",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Kontroller",
"rememberPlayerChanges": "Husk afspillerændringer",
"rememberPlayerChangesDescription": "Hvor en ændring under afspilning gemmes og anvendes igen",
"scopePlaybackSpeed": "Afspilningshastighed",
"scopeShaderPreset": "Shader-forudindstilling",
"scopeAspectRatio": "Billedformat",
"scopeSyncOffsets": "Synkronisering af lyd og undertekster",
"playerScopeOff": "Gem ikke",
"playerScopeGlobal": "Overalt",
"playerScopeLibrary": "Pr. bibliotek",
"playerScopeTitle": "Pr. serie eller film",
"exportDialogTitle": "Eksportér Plezy-indstillinger"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Lyd",
"subtitlesLabel": "Undertekster",
"resetToZero": "Nulstil til 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} afspilles senere",
"playsEarlier": "${label} afspilles tidligere",
"noOffset": "Ingen forskydning",
"letterbox": "Letterbox",
"fillScreen": "Fyld skærm",
"stretch": "Stræk",
@@ -670,6 +723,7 @@
"notSupported": "Enheden understøtter ikke billede-i-billede",
"voSwitchFailed": "Kunne ikke skifte videooutput til billede-i-billede",
"failed": "Billede-i-billede kunne ikke starte",
"prepareFailed": "Billede-i-billede kunne ikke forberedes",
"unknown": "Der opstod en fejl: ${error}"
},
"chapters": "Kapitler",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Rediger ${product}-forbindelse",
"editMediaBrowserIntro": "Tilføj eller fjern URL'er for ${serverName}. Plezy bruger den tilgængelige URL med laveste latenstid."
},
"accountPreferences": {
"sectionTitle": "Kontopræferencer",
"hubSubtitleSingle": "Lyd-, undertekst- og biblioteksindstillinger gemt på ${account}",
"hubSubtitleMultiple": "Lyd-, undertekst- og biblioteksindstillinger gemt på ${count} konti",
"pickAccount": "Hver konto gemmer sine egne præferencer. Vælg den, du vil redigere.",
"storedOnAccount": "Disse indstillinger gemmes på selve kontoen, så alle apps, der er logget ind på den, bruger dem — inklusive Plezy på dine andre enheder.",
"noAccounts": "Ingen konti at konfigurere",
"noAccountsHint": "Log ind på Plex, eller forbind en Jellyfin- eller Emby-server, så vises de præferencer, der er gemt på kontoen, her.",
"unavailable": "Kan ikke nå denne konto",
"loadFailed": "Kunne ikke indlæse disse præferencer",
"noPreference": "Ingen præference",
"notSet": "Ikke indstillet",
"groups": {
"audioAndSubtitles": "Lyd og undertekster",
"libraryDisplay": "Bibliotek",
"personalMedia": "Personlige medier"
},
"preferredAudioLanguage": "Foretrukket lydsprog",
"autoSelectAudio": "Vælg lyd ud fra sprog",
"autoSelectAudioDescription": "Fra beholder det lydspor, som filen markerer som standard.",
"preferredSubtitleLanguage": "Foretrukket undertekstsprog",
"subtitleMode": "Slå undertekster til",
"subtitleModes": {
"none": "Manuelt valgt",
"noneDescription": "Slår aldrig undertekster til af sig selv.",
"defaultMode": "Følg sporets flag",
"defaultModeDescription": "Brug standard- og tvungne flag, der er gemt på hvert undertekstspor.",
"always": "Altid aktiveret",
"alwaysDescription": "Slå et undertekstspor til på det foretrukne sprog, når der findes ét.",
"onlyForced": "Kun tvungne undertekster",
"onlyForcedDescription": "Indlæs kun spor, der er markeret som tvungne.",
"smart": "Vis ved fremmedsproget lyd",
"smartDescription": "Slå kun undertekster til, når lyden er på et andet sprog."
},
"subtitleAccessibility": "SDH-undertekster",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Foretræk ikke-SDH-undertekster",
"preferSdh": "Foretræk SDH-undertekster",
"onlySdh": "Kun SDH-undertekster",
"onlyNonSdh": "Kun ikke-SDH-undertekster"
},
"forcedSubtitles": "Tvungne undertekster",
"forcedSubtitleOptions": {
"preferNonForced": "Foretræk ikke-tvungne undertekster",
"preferForced": "Foretræk tvungne undertekster",
"onlyForced": "Kun tvungne undertekster",
"onlyNonForced": "Kun ikke-tvungne undertekster"
},
"displayMissingEpisodes": "Vis manglende afsnit",
"displayMissingEpisodesDescription": "Vis afsnit, som serveren kender til, men som ikke har nogen fil.",
"hidePlayedInLatest": "Skjul sete elementer i Seneste",
"hidePlayedInLatestDescription": "Hold elementer, du allerede har set, ude af serverens Seneste-rækker.",
"displayCollectionsView": "Vis samlingsvisningen",
"displayCollectionsViewDescription": "Vis serverens samlingsvisning sammen med dine biblioteker.",
"rewatchingInNextUp": "Behold gensete serier i Næste afsnit",
"rewatchingInNextUpDescription": "Når du er færdig med en serie og ser den igen, følger Næste afsnit med i gensynet i stedet for at fjerne serien.",
"watchedIndicator": "Set-indikatorer",
"watchedIndicatorOptions": {
"none": "Aldrig",
"moviesAndShows": "Film og TV-serier",
"movies": "Kun film",
"shows": "Kun TV-serier"
},
"mediaReviewsVisibility": "Bedømmelser og anmeldelser",
"mediaReviewsOptions": {
"usersAndCritics": "Brugere og anmeldere",
"usersOnly": "Kun brugere",
"criticsOnly": "Kun anmeldere",
"nobody": "Skjult"
}
},
"discover": {
"title": "Opdag",
"noContentAvailable": "Intet indhold tilgængeligt",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "Ingen servere fundet for ${username} (${email})",
"failedToLoadServers": "Kunne ikke indlæse servere: ${error}"
"failedToLoadServers": "Kunne ikke indlæse servere: ${error}",
"noValidServers": "Der blev ikke fundet nogen brugbare servere på denne konto"
},
"hubDetail": {
"title": "Titel",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} populær",
"rankAiring": "#${n} på luften",
"rankRated": "#${n} bedømt",
"rankFavorited": "#${n} favorit",
"rankTrending": "#${n} populær lige nu",
"rankSeasonal": "#${n} i ${season}",
"watchingNow": "${n} ser med",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Ukendt kanal",
"live": "LIVE",
"reloadGuide": "Genindlæs guide",
"searchGuide": "Søg i guiden",
"searchHint": "Søg efter kanaler og programmer",
"searchNoResults": "Ingen match for \"${query}\"",
"channelsSection": "Kanaler",
"programsSection": "Programmer",
"now": "Nu",
"today": "I dag",
"tomorrow": "I morgen",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Der er anmodet om en opdatering af guiden",
"rulesProcessRequested": "Der er anmodet om en ny evaluering af reglerne",
"recordShow": "Optag program",
"recordSettings": {
"startEarly": "Start tidligere (sekunder)",
"endLate": "Slut senere (sekunder)",
"newOnly": "Kun nye afsnit",
"anyChannel": "Optag på alle kanaler",
"anyTime": "Optag på alle tidspunkter",
"skipInLibrary": "Spring afsnit over, der allerede er i biblioteket",
"keepUpTo": "Afsnit der skal beholdes",
"keepUpToHint": "0 beholder alle afsnit"
},
"startingInMinutes": "Starter om ${minutes} min",
"dayAtTime": "${day} kl. ${time}",
"invalidPlaybackData": "${product} returnerede ugyldige afspilningsdata for Live TV",
@@ -1416,7 +1556,14 @@
"repeatAll": "Gentag alle",
"repeatOne": "Gentag ét nummer",
"instantMixNoServer": "Ingen server er tilgængelig til et øjeblikkeligt mix",
"noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}"
"instantMixFailed": "Kunne ikke indlæse det direkte miks",
"instantMixEmpty": "Det direkte miks indeholdt ingen numre",
"noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}",
"discography": {
"singlesAndEps": "Singler og EP'er",
"live": "Live",
"compilations": "Opsamlinger"
}
},
"watchTogether": {
"title": "Se sammen",
@@ -1442,6 +1589,13 @@
"host": "Vært",
"hostBadge": "VÆRT",
"youAreHost": "Du er vært",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Ser med andre",
"endSession": "Afslut session",
"leaveSession": "Forlad session",
@@ -1484,7 +1638,13 @@
"removeRoom": "Fjern",
"guestSwitchUnavailable": "Kunne ikke skifte — server ikke tilgængelig for synkronisering",
"guestSwitchFailed": "Kunne ikke skifte — indhold blev ikke fundet på denne server",
"defaultDisplayName": "Bruger"
"defaultDisplayName": "Bruger",
"errors": {
"timedOut": "Relayserveren svarede ikke i tide",
"connectionLost": "Forbindelsen blev lukket, før sessionen var klar",
"invalidRelayResponse": "Relayserveren sendte et uventet svar",
"sessionEnded": "Værten afsluttede sessionen"
}
},
"downloads": {
"title": "Downloads",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Brug din mobilenhed til at styre denne app",
"startServer": "Start serveren",
"stopServer": "Stop serveren",
"minimize": "Minimér"
"minimize": "Minimér",
"manualAddressHint": "Manuel forbindelsesadresse:"
},
"pairing": {
"discoveryDescription": "Plezy-enheder med samme Plex-konto vises her",
@@ -1726,6 +1887,7 @@
"decoder": "Dekoder",
"rawDecoder": "Rå dekoder",
"tunneling": "Tunneling",
"passthrough": "Passthrough",
"aspect": "Billedformat",
"rotation": "Rotation",
"dvSource": "DV-kilde",
@@ -1857,9 +2019,7 @@
"producer": "Producer",
"country": "Land",
"collection": "Samling",
"label": "Etiket",
"style": "Stil",
"mood": "Stemning"
"label": "Etiket"
},
"matchScreen": {
"match": "Match...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Delvist tilgængelig",
"statusRequested": "Anmodet",
"statusProcessing": "Behandler",
"statusBlocklisted": "På blokeringslisten",
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
"invalidUrl": "Indtast en serveradresse som https://seerr.example.com",
"quickConnectUnsupported": "Denne Seerr-instans understøtter ikke Quick Connect. Den kræver Seerr 3.4 eller nyere.",
"notInitialized": "Denne Seerr-instans har ikke fuldført førstegangsopsætningen",
"noPlexTokenForReauth": "Intet Plex-token er tilgængeligt til at logge ind igen",
"noStoredCredentials": "Ingen gemte loginoplysninger er tilgængelige til at logge ind igen",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Tjenester",
"hubSubtitle": "Synkroniser dit visningsfremskridt, og anmod om nye titler.",
"integrations": "Integrationer",
"notConnected": "Ikke forbundet",
"connectedAs": "Forbundet som @${username}",
"scrobble": "Registrer fremgang automatisk",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN.",
"invalidCredentials": "Ugyldigt brugernavn eller ugyldig adgangskode",
"authResponseNotJson": "Godkendelsessvaret var ikke gyldig JSON",
"authResponseIncomplete": "Loginsvaret fra serveren var ufuldstændigt",
"quickConnectRejected": "Quick Connect blev afvist af serveren",
"quickConnectNotJson": "Quick Connect-svaret var ikke gyldig JSON",
"quickConnectMissingFields": "Quick Connect-svaret mangler en kode eller hemmelighed",
+192 -27
View File
@@ -12,6 +12,7 @@
"useBrowser": "Browser verwenden",
"or": "oder",
"connectToMediaBrowser": "Mit ${product} verbinden",
"quickConnect": "Quick Connect",
"useQuickConnect": "Quick Connect verwenden",
"quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.",
"quickConnectWaiting": "Warte auf Bestätigung…",
@@ -53,6 +54,7 @@
"mute": "Stumm",
"ok": "OK",
"off": "Aus",
"options": "Optionen",
"seasonNumber": "Staffel ${number}",
"episodeNumberTitle": "Folge ${number} – ${title}",
"chapterNumber": "Kapitel ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N. v.",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Film",
"show": "Serie",
"season": "Staffel",
"episode": "Episode",
"artist": "Interpret",
"album": "Album",
"track": "Titel",
"collection": "Sammlung",
"playlist": "Wiedergabeliste",
"clip": "Clip",
"photo": "Foto",
"folder": "Ordner"
}
},
"screens": {
"licenses": "Lizenzen",
@@ -127,6 +143,10 @@
"displayScale": "Skalierung",
"compact": "Kompakt",
"comfortable": "Großzügig",
"gridSpacing": "Rasterabstand",
"gridSpacingTight": "Eng",
"gridSpacingNormal": "Normal",
"gridSpacingSpacious": "Großzügig",
"tvCornerSpotlightBackdrop": "Backdrop in der Ecke",
"tvCornerSpotlightBackdropDescription": "Zeigt das Spotlight-Artwork oben rechts statt bildschirmfüllend",
"viewMode": "Ansichtsmodus",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Hardwaredekodierung",
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
"bufferSize": "Puffergröße",
"bufferSizeMB": "${size} MB",
"bufferSizeAuto": "Automatisch (empfohlen)",
"bufferSizeWarning": "${heap} MB Speicher verfügbar. Ein Puffer von ${size} MB kann die Wiedergabe beeinträchtigen.",
"playbackBuffer": "Wiedergabepuffer",
"playbackBufferAuto": "Automatisch (empfohlen)",
"playbackBufferLarge": "Groß",
"playbackBufferExtraLarge": "Extra groß",
"playbackBufferDescription": "Puffert mehr gegen instabile Verbindungen. Zusätzlich durch die Puffergröße begrenzt.",
"defaultQualityTitle": "Standardqualität",
"cellularQualityTitle": "Standardqualität im Mobilfunknetz",
"cellularQualitySameAsDefault": "Wie Standardqualität",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Musikqualität",
"subtitleStyling": "Untertitel-Stil",
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Audio- und Untertitelauswahl je Titel merken",
"followServerTrackSelections": "Serverseitige Spurauswahl pro Episode verwenden",
"followServerTrackSelectionsDescription": "Beim Episodenwechsel die auf dem Server ausgewählten Audio- und Untertitelspuren übernehmen, statt die aktuelle Auswahl zu übertragen",
"resumeMusicOnLaunch": "Musiksitzung merken",
"resumeMusicOnLaunchDescription": "Beim App-Start den letzten Titel pausiert an der letzten Stelle wieder öffnen",
"showChapterMarkersOnTimeline": "Kapitelmarkierungen auf der Suchleiste anzeigen",
"showChapterMarkersOnTimelineDescription": "Die Suchleiste an Kapitelgrenzen unterteilen",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Specials in Episodenreihenfolge",
"specialsOrderingDescription": "Wo Specials in der Wiedergabereihenfolge einer Serie abgespielt werden",
"specialsOrderingServer": "Serverreihenfolge folgen",
"specialsOrderingAirDate": "Nach Ausstrahlungsdatum einfügen",
"specialsOrderingLast": "Nach den regulären Staffeln",
"clickVideoTogglesPlayback": "Video zum Abspielen oder Pausieren anklicken",
"clickVideoTogglesPlaybackDescription": "Video zum Abspielen oder Pausieren anklicken, statt die Steuerung anzuzeigen.",
"videoPlayerControls": "Videoplayer-Steuerung",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}",
"shortcutUpdated": "Tastenkürzel aktualisiert für ${action}",
"saveFailed": "Änderungen konnten nicht gespeichert werden. Versuche es erneut.",
"autoSkip": "Automatisches Überspringen",
"autoPlayAndSkip": "Autoplay & Überspringen",
"autoPlayNextEpisode": "Nächste Episode automatisch abspielen",
"autoPlayNextEpisodeDescription": "Die nächste Episode automatisch starten, wenn die aktuelle endet",
"playNextCountdown": "Countdown bis zur nächsten Episode",
"playNextCountdownImmediate": "Sofort abspielen",
"autoSkipIntro": "Intro automatisch überspringen",
"autoSkipIntroDescription": "Intro-Marker nach wenigen Sekunden automatisch überspringen",
"autoSkipCredits": "Abspann automatisch überspringen",
@@ -291,6 +317,8 @@
"autoPipDescription": "Beim Verlassen der App während der Wiedergabe automatisch Bild-in-Bild starten",
"matchContentFrameRate": "Inhalts-Bildrate anpassen",
"matchContentFrameRateDescription": "Bildwiederholrate des Displays an Videoinhalt anpassen",
"matchContentResolution": "An Auflösung des Inhalts anpassen",
"matchContentResolutionDescription": "Wechselt die Anzeige auf die native Auflösung des Videos, damit dein Fernseher das Hochskalieren übernimmt. Menüs und Untertitel werden während der Wiedergabe ebenfalls hochskaliert",
"matchRefreshRate": "Bildwiederholrate anpassen",
"matchRefreshRateDescription": "Bildwiederholrate im Vollbild anpassen",
"matchDynamicRange": "Dynamikumfang anpassen",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken",
"dvConversionDv81Description": "Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen",
"dvConversionHevcStripDescription": "Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben",
"deinterlace": "Deinterlacing",
"deinterlaceDescription": "Kammartefakte aus interlaced Videos entfernen (nur mpv-Player)",
"requireProfileSelectionOnOpen": "Profil beim Öffnen abfragen",
"requireProfileSelectionOnOpenDescription": "Profilauswahl bei jedem Öffnen der App anzeigen",
"forceTvMode": "TV-Modus erzwingen",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Zeigt den Tab „Entdecken“ mit Inhalten aus Plex Discover und verbundenen Trackern an",
"liveTvDefaultFavorites": "Standardmäßig Favoritenkanäle",
"liveTvDefaultFavoritesDescription": "Beim Öffnen von Live TV nur Favoritenkanäle anzeigen",
"general": "Allgemein",
"generalDescription": "Sprache, Start und Fensterverhalten",
"languageAndRegion": "Sprache & Region",
"startup": "Start",
"display": "Anzeige",
"libraryAndCards": "Mediathek & Karten",
"homeScreen": "Startseite",
"navigation": "Navigation",
"window": "Fenster",
"content": "Inhalt",
"liveTv": "Live-TV",
"player": "Wiedergabe",
"subtitlesAndConfig": "Untertitel und Konfiguration",
"videoAndDisplay": "Video & Anzeige",
"audio": "Audio",
"quality": "Qualität",
"subtitles": "Untertitel",
"seekAndTiming": "Spulen & Timing",
"behavior": "Verhalten",
"gestures": "Gesten",
"gestureBrightnessSwipe": "Wischen für Helligkeit",
"gestureBrightnessSwipeDescription": "Wische am linken Rand nach oben oder unten, um die Helligkeit anzupassen",
"gestureVolumeSwipe": "Wischen für Lautstärke",
"gestureVolumeSwipeDescription": "Wische am rechten Rand nach oben oder unten, um die Lautstärke anzupassen",
"gesturePinchToZoom": "Zum Zoomen kneifen",
"gesturePinchToZoomDescription": "Kneife auf dem Video, um hinein- oder herauszuzoomen",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Steuerung",
"rememberPlayerChanges": "Playeränderungen merken",
"rememberPlayerChangesDescription": "Wo eine während der Wiedergabe vorgenommene Änderung gespeichert und erneut angewendet wird",
"scopePlaybackSpeed": "Wiedergabegeschwindigkeit",
"scopeShaderPreset": "Shader-Voreinstellung",
"scopeAspectRatio": "Seitenverhältnis",
"scopeSyncOffsets": "Audio- und Untertitelsynchronisierung",
"playerScopeOff": "Nicht speichern",
"playerScopeGlobal": "Überall",
"playerScopeLibrary": "Pro Bibliothek",
"playerScopeTitle": "Pro Serie oder Film",
"exportDialogTitle": "Plezy-Einstellungen exportieren"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Untertitel",
"resetToZero": "Auf 0 ms zurücksetzen",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} spielt später",
"playsEarlier": "${label} spielt früher",
"noOffset": "Kein Offset",
"letterbox": "Letterbox",
"fillScreen": "Bild füllen",
"stretch": "Strecken",
@@ -670,6 +723,7 @@
"notSupported": "Dieses Gerät unterstützt den Bild-in-Bild-Modus nicht",
"voSwitchFailed": "Videoausgabe für Bild-in-Bild konnte nicht umgeschaltet werden",
"failed": "Bild-in-Bild konnte nicht gestartet werden",
"prepareFailed": "Bild-in-Bild konnte nicht vorbereitet werden",
"unknown": "Ein Fehler ist aufgetreten: ${error}"
},
"chapters": "Kapitel",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Verbindung zu ${product} bearbeiten",
"editMediaBrowserIntro": "URLs für ${serverName} hinzufügen oder entfernen. Plezy verwendet die erreichbare URL mit der geringsten Latenz."
},
"accountPreferences": {
"sectionTitle": "Kontoeinstellungen",
"hubSubtitleSingle": "Audio-, Untertitel- und Mediathek-Optionen werden auf ${account} gespeichert",
"hubSubtitleMultiple": "Audio-, Untertitel- und Mediathek-Optionen werden auf ${count} Konten gespeichert",
"pickAccount": "Jedes Konto speichert seine eigenen Einstellungen. Wähle das Konto, das du bearbeiten möchtest.",
"storedOnAccount": "Diese Optionen werden auf dem Konto selbst gespeichert, sodass jede damit angemeldete App sie verwendet — auch Plezy auf deinen anderen Geräten.",
"noAccounts": "Keine Konten zum Konfigurieren",
"noAccountsHint": "Melde dich bei Plex an oder verbinde einen Jellyfin- oder Emby-Server — die auf diesem Konto gespeicherten Einstellungen erscheinen dann hier.",
"unavailable": "Dieses Konto ist nicht erreichbar",
"loadFailed": "Diese Einstellungen konnten nicht geladen werden",
"noPreference": "Keine Einstellung",
"notSet": "Nicht festgelegt",
"groups": {
"audioAndSubtitles": "Audio & Untertitel",
"libraryDisplay": "Mediathek",
"personalMedia": "Persönliche Medien"
},
"preferredAudioLanguage": "Bevorzugte Audiosprache",
"autoSelectAudio": "Audio nach Sprache auswählen",
"autoSelectAudioDescription": "Bei „Aus“ wird die Audiospur verwendet, die die Datei als Standard markiert.",
"preferredSubtitleLanguage": "Bevorzugte Untertitelsprache",
"subtitleMode": "Untertitel einschalten",
"subtitleModes": {
"none": "Manuell ausgewählt",
"noneDescription": "Untertitel niemals von selbst einschalten.",
"defaultMode": "Spurmarkierungen folgen",
"defaultModeDescription": "Die Standard- und Erzwungen-Markierungen jeder Untertitelspur verwenden.",
"always": "Immer aktiviert",
"alwaysDescription": "Eine Untertitelspur in der bevorzugten Sprache einschalten, sofern eine vorhanden ist.",
"onlyForced": "Nur erzwungene Untertitel",
"onlyForcedDescription": "Nur die als erzwungen markierten Spuren laden.",
"smart": "Bei fremdsprachigem Audio anzeigen",
"smartDescription": "Untertitel nur einschalten, wenn das Audio in einer anderen Sprache ist."
},
"subtitleAccessibility": "SDH-Untertitel",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Nicht-SDH-Untertitel bevorzugen",
"preferSdh": "SDH-Untertitel bevorzugen",
"onlySdh": "Nur SDH-Untertitel",
"onlyNonSdh": "Nur Nicht-SDH-Untertitel"
},
"forcedSubtitles": "Erzwungene Untertitel",
"forcedSubtitleOptions": {
"preferNonForced": "Nicht erzwungene Untertitel bevorzugen",
"preferForced": "Erzwungene Untertitel bevorzugen",
"onlyForced": "Nur erzwungene Untertitel",
"onlyNonForced": "Nur nicht erzwungene Untertitel"
},
"displayMissingEpisodes": "Fehlende Episoden anzeigen",
"displayMissingEpisodesDescription": "Episoden auflisten, die dem Server bekannt sind, für die aber keine Datei vorliegt.",
"hidePlayedInLatest": "Gesehene Elemente in „Neueste“ ausblenden",
"hidePlayedInLatestDescription": "Elemente, die du bereits gesehen hast, erscheinen nicht mehr in den „Neueste“-Zeilen des Servers.",
"displayCollectionsView": "Sammlungsansicht anzeigen",
"displayCollectionsViewDescription": "Die Sammlungsansicht des Servers zusätzlich zu deinen Mediatheken anzeigen.",
"rewatchingInNextUp": "Erneut geschaute Serien in „Als Nächstes“ behalten",
"rewatchingInNextUpDescription": "Beendest du eine Serie und startest sie erneut, folgt „Als Nächstes“ dem erneuten Anschauen, statt die Serie zu verwerfen.",
"watchedIndicator": "Gesehen-Markierung",
"watchedIndicatorOptions": {
"none": "Nie",
"moviesAndShows": "Filme und Serien",
"movies": "Nur Filme",
"shows": "Nur Serien"
},
"mediaReviewsVisibility": "Bewertungen & Rezensionen",
"mediaReviewsOptions": {
"usersAndCritics": "Benutzer und Kritiker",
"usersOnly": "Nur Benutzer",
"criticsOnly": "Nur Kritiker",
"nobody": "Ausgeblendet"
}
},
"discover": {
"title": "Entdecken",
"noContentAvailable": "Kein Inhalt verfügbar",
@@ -1008,7 +1133,7 @@
"productionYear": "Produktionsjahr",
"runtime": "Laufzeit",
"officialRating": "Offizielle Bewertung",
"premiereDate": "Premierendatum",
"premiereDate": "Veröffentlichungsdatum",
"startDate": "Startdatum",
"airTime": "Sendezeit",
"studio": "Studio",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "Keine Server gefunden für ${username} (${email})",
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}"
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}",
"noValidServers": "Für dieses Konto wurden keine nutzbaren Server gefunden"
},
"hubDetail": {
"title": "Titel",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} beliebt",
"rankAiring": "#${n} derzeit ausgestrahlt",
"rankRated": "#${n} bewertet",
"rankFavorited": "#${n} favorisiert",
"rankTrending": "#${n} angesagt",
"rankSeasonal": "#${n} in ${season}",
"watchingNow": "${n} schauen gerade",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Unbekannter Kanal",
"live": "LIVE",
"reloadGuide": "TV-Programm neu laden",
"searchGuide": "TV-Programm durchsuchen",
"searchHint": "Kanäle & Sendungen suchen",
"searchNoResults": "Keine Treffer für „${query}“",
"channelsSection": "Kanäle",
"programsSection": "Sendungen",
"now": "Jetzt",
"today": "Heute",
"tomorrow": "Morgen",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Aktualisierung des TV-Programms angefordert",
"rulesProcessRequested": "Regelauswertung angefordert",
"recordShow": "Sendung aufnehmen",
"recordSettings": {
"startEarly": "Früher starten (Sekunden)",
"endLate": "Später beenden (Sekunden)",
"newOnly": "Nur neue Episoden",
"anyChannel": "Auf jedem Sender aufnehmen",
"anyTime": "Zu jeder Zeit aufnehmen",
"skipInLibrary": "Episoden überspringen, die schon in der Bibliothek sind",
"keepUpTo": "Zu behaltende Episoden",
"keepUpToHint": "0 behält alle Episoden"
},
"startingInMinutes": "Beginnt in ${minutes} Min.",
"dayAtTime": "${day} um ${time}",
"invalidPlaybackData": "${product} hat ungültige Live-TV-Wiedergabedaten zurückgegeben",
@@ -1416,7 +1556,14 @@
"repeatAll": "Alle wiederholen",
"repeatOne": "Titel wiederholen",
"instantMixNoServer": "Für einen Instant Mix ist kein Server verfügbar",
"noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar"
"instantMixFailed": "Instant-Mix konnte nicht geladen werden",
"instantMixEmpty": "Der Instant-Mix enthält keine Titel",
"noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar",
"discography": {
"singlesAndEps": "Singles & EPs",
"live": "Live",
"compilations": "Kompilationen"
}
},
"watchTogether": {
"title": "Gemeinsam Schauen",
@@ -1442,6 +1589,13 @@
"host": "Host",
"hostBadge": "HOST",
"youAreHost": "Du bist der Host",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Mit anderen schauen",
"endSession": "Sitzung beenden",
"leaveSession": "Sitzung verlassen",
@@ -1484,7 +1638,13 @@
"removeRoom": "Entfernen",
"guestSwitchUnavailable": "Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar",
"guestSwitchFailed": "Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden",
"defaultDisplayName": "Benutzer"
"defaultDisplayName": "Benutzer",
"errors": {
"timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet",
"connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war",
"invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet",
"sessionEnded": "Der Host hat die Sitzung beendet"
}
},
"downloads": {
"title": "Downloads",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Verwende dein Mobilgerät, um diese App zu steuern",
"startServer": "Server starten",
"stopServer": "Server stoppen",
"minimize": "Minimieren"
"minimize": "Minimieren",
"manualAddressHint": "Manuelle Verbindungsadresse:"
},
"pairing": {
"discoveryDescription": "Plezy-Geräte mit demselben Plex-Konto erscheinen hier",
@@ -1726,6 +1887,7 @@
"decoder": "Decoder",
"rawDecoder": "Raw-Decoder",
"tunneling": "Tunneling",
"passthrough": "Durchleitung",
"aspect": "Seitenverhältnis",
"rotation": "Drehung",
"dvSource": "DV-Quelle",
@@ -1857,9 +2019,7 @@
"producer": "Produzent",
"country": "Land",
"collection": "Sammlung",
"label": "Label",
"style": "Stil",
"mood": "Stimmung"
"label": "Label"
},
"matchScreen": {
"match": "Zuordnen...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Teilweise verfügbar",
"statusRequested": "Angefragt",
"statusProcessing": "Wird verarbeitet",
"statusBlocklisted": "Auf der Sperrliste",
"couldNotReach": "${url} nicht erreichbar: ${error}",
"noInstanceAtUrl": "Keine Seerr-Instanz unter ${url} (HTTP ${status})",
"invalidUrl": "Gib eine Serveradresse ein, z. B. https://seerr.example.com",
"quickConnectUnsupported": "Diese Seerr-Instanz unterstützt Quick Connect nicht. Dafür ist Seerr 3.4 oder neuer erforderlich.",
"notInitialized": "Die Ersteinrichtung dieser Seerr-Instanz wurde noch nicht abgeschlossen",
"noPlexTokenForReauth": "Für die erneute Anmeldung ist kein Plex-Token verfügbar",
"noStoredCredentials": "Für die erneute Anmeldung sind keine gespeicherten Anmeldedaten verfügbar",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Dienste",
"hubSubtitle": "Wiedergabefortschritt synchronisieren und neue Titel anfragen.",
"integrations": "Integrationen",
"notConnected": "Nicht verbunden",
"connectedAs": "Verbunden als @${username}",
"scrobble": "Fortschritt automatisch verfolgen",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN.",
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
"authResponseNotJson": "Die Authentifizierungsantwort war kein gültiges JSON",
"authResponseIncomplete": "Die Anmeldeantwort des Servers war unvollständig",
"quickConnectRejected": "Quick Connect wurde vom Server abgelehnt",
"quickConnectNotJson": "Die Quick Connect-Antwort war kein gültiges JSON",
"quickConnectMissingFields": "In der Quick Connect-Antwort fehlt ein Code oder Geheimnis",
+187 -22
View File
@@ -12,6 +12,7 @@
"useBrowser": "Use browser",
"or": "or",
"connectToMediaBrowser": "Connect to ${product}",
"quickConnect": "Quick Connect",
"useQuickConnect": "Use Quick Connect",
"quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.",
"quickConnectWaiting": "Waiting for approval…",
@@ -53,6 +54,7 @@
"mute": "Mute",
"ok": "OK",
"off": "Off",
"options": "Options",
"seasonNumber": "Season ${number}",
"episodeNumberTitle": "Episode ${number} - ${title}",
"chapterNumber": "Chapter ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Movie",
"show": "Show",
"season": "Season",
"episode": "Episode",
"artist": "Artist",
"album": "Album",
"track": "Track",
"collection": "Collection",
"playlist": "Playlist",
"clip": "Clip",
"photo": "Photo",
"folder": "Folder"
}
},
"screens": {
"licenses": "Licenses",
@@ -127,6 +143,10 @@
"displayScale": "Display Scale",
"compact": "Compact",
"comfortable": "Comfortable",
"gridSpacing": "Grid Spacing",
"gridSpacingTight": "Tight",
"gridSpacingNormal": "Normal",
"gridSpacingSpacious": "Spacious",
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
"tvCornerSpotlightBackdropDescription": "Show spotlight artwork in the top-right corner instead of filling the screen",
"viewMode": "View Mode",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Hardware Decoding",
"hardwareDecodingDescription": "Use hardware acceleration when available",
"bufferSize": "Buffer Size",
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Recommended)",
"bufferSizeWarning": "${heap}MB memory available. A ${size}MB buffer may affect playback.",
"playbackBuffer": "Playback Buffer",
"playbackBufferAuto": "Auto (Recommended)",
"playbackBufferLarge": "Large",
"playbackBufferExtraLarge": "Extra Large",
"playbackBufferDescription": "Buffer more against unstable connections. Also limited by Buffer Size.",
"defaultQualityTitle": "Default Quality",
"cellularQualityTitle": "Default Quality on Cellular",
"cellularQualitySameAsDefault": "Same as Default Quality",
"directPlayCoveredQuality": "Play Smaller Videos at Original Quality",
"directPlayCoveredQualityDescription": "Direct play videos already within the quality limit instead of transcoding them",
"musicQualityTitle": "Music Quality",
"subtitleStyling": "Subtitle Styling",
"subtitleStylingDescription": "Customize subtitle appearance",
@@ -193,6 +213,8 @@
"rememberTrackSelectionsDescription": "Remember audio and subtitle choices per title",
"followServerTrackSelections": "Use server's per-episode track selections",
"followServerTrackSelectionsDescription": "On episode change, apply the audio and subtitles selected on the server instead of carrying over the current choice",
"resumeMusicOnLaunch": "Remember music session",
"resumeMusicOnLaunchDescription": "Reopen the last song paused where it left off when the app starts",
"showChapterMarkersOnTimeline": "Show chapter markers on seek bar",
"showChapterMarkersOnTimelineDescription": "Segment the seek bar at chapter boundaries",
"specialsOrdering": "Specials in episode order",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Shortcut already assigned to ${action}",
"shortcutUpdated": "Shortcut updated for ${action}",
"saveFailed": "Could not save changes. Try again.",
"autoSkip": "Auto Skip",
"autoPlayAndSkip": "Auto-Play & Skip",
"autoPlayNextEpisode": "Auto-Play Next Episode",
"autoPlayNextEpisodeDescription": "Start the next episode automatically when one ends",
"playNextCountdown": "Play Next Countdown",
"playNextCountdownImmediate": "Play immediately",
"autoSkipIntro": "Auto Skip Intro",
"autoSkipIntroDescription": "Automatically skip intro markers after a few seconds",
"autoSkipCredits": "Auto Skip Credits",
@@ -291,6 +317,8 @@
"autoPipDescription": "Automatically enter picture-in-picture when you leave the app during playback",
"matchContentFrameRate": "Match Content Frame Rate",
"matchContentFrameRateDescription": "Match display refresh rate to video content",
"matchContentResolution": "Match Content Resolution",
"matchContentResolutionDescription": "Switch the display to the video's native resolution so your TV handles upscaling. Menus and subtitles are upscaled too during playback",
"matchRefreshRate": "Match Refresh Rate",
"matchRefreshRateDescription": "Match display refresh rate in fullscreen",
"matchDynamicRange": "Match Dynamic Range",
@@ -310,7 +338,7 @@
"audioDownmixNormalize": "Normalize Volume on Downmix",
"audioDownmixNormalizeDescription": "Lower the mix to prevent clipping. Turn off to keep the original volume (may distort loud scenes).",
"dvConversionMode": "Dolby Vision Conversion",
"dvConversionModeDescription": "Choose how ExoPlayer handles Dolby Vision Profile 7 files.",
"dvConversionModeDescription": "Choose how Dolby Vision Profile 7 files are handled.",
"dvConversionAuto": "Auto",
"dvConversionNative": "Native / Disabled",
"dvConversionDv81": "P7 → P8.1",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Force native DV7 and suppress DV conversion retry",
"dvConversionDv81Description": "Force inline RPU conversion to Dolby Vision profile 8.1",
"dvConversionHevcStripDescription": "Strip Dolby Vision RPU/EL layers and present plain HEVC",
"deinterlace": "Deinterlacing",
"deinterlaceDescription": "Remove combing artifacts from interlaced video (mpv player only)",
"requireProfileSelectionOnOpen": "Ask for profile on app open",
"requireProfileSelectionOnOpenDescription": "Show profile selection every time the app is opened",
"forceTvMode": "Force TV mode",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Display the Explore tab with content from Plex Discover and connected trackers",
"liveTvDefaultFavorites": "Default to Favorite Channels",
"liveTvDefaultFavoritesDescription": "Show only favorite channels when opening Live TV",
"general": "General",
"generalDescription": "Language, startup, and window behavior",
"languageAndRegion": "Language & Region",
"startup": "Startup",
"display": "Display",
"libraryAndCards": "Library & Cards",
"homeScreen": "Home Screen",
"navigation": "Navigation",
"window": "Window",
"content": "Content",
"liveTv": "Live TV",
"player": "Player",
"subtitlesAndConfig": "Subtitles & Configuration",
"videoAndDisplay": "Video & Display",
"audio": "Audio",
"quality": "Quality",
"subtitles": "Subtitles",
"seekAndTiming": "Seek & Timing",
"behavior": "Behavior",
"gestures": "Gestures",
"gestureBrightnessSwipe": "Brightness Swipe",
"gestureBrightnessSwipeDescription": "Swipe up or down on the left edge to adjust brightness",
"gestureVolumeSwipe": "Volume Swipe",
"gestureVolumeSwipeDescription": "Swipe up or down on the right edge to adjust volume",
"gesturePinchToZoom": "Pinch to Zoom",
"gesturePinchToZoomDescription": "Pinch on the video to zoom in or out",
"rememberBrightnessLevel": "Remember Brightness Level",
"rememberBrightnessLevelDescription": "Start playback at the brightness set by the last swipe",
"controls": "Controls",
"rememberPlayerChanges": "Remember Player Changes",
"rememberPlayerChangesDescription": "Where a change made during playback is saved and reapplied from",
"scopePlaybackSpeed": "Playback Speed",
"scopeShaderPreset": "Shader Preset",
"scopeAspectRatio": "Aspect Ratio",
"scopeSyncOffsets": "Audio & Subtitle Sync",
"playerScopeOff": "Don't save",
"playerScopeGlobal": "Everywhere",
"playerScopeLibrary": "Per library",
"playerScopeTitle": "Per show or movie",
"exportDialogTitle": "Export Plezy settings"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Subtitles",
"resetToZero": "Reset to 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} plays later",
"playsEarlier": "${label} plays earlier",
"noOffset": "No offset",
"letterbox": "Letterbox",
"fillScreen": "Fill screen",
"stretch": "Stretch",
@@ -670,6 +723,7 @@
"notSupported": "Device doesn't support picture-in-picture mode",
"voSwitchFailed": "Failed to switch video output for picture-in-picture",
"failed": "Picture-in-picture failed to start",
"prepareFailed": "Picture-in-picture could not be prepared",
"unknown": "An error occurred: ${error}"
},
"chapters": "Chapters",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Edit ${product} connection",
"editMediaBrowserIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency."
},
"accountPreferences": {
"sectionTitle": "Account preferences",
"hubSubtitleSingle": "Audio, subtitle and library options saved on ${account}",
"hubSubtitleMultiple": "Audio, subtitle and library options saved on ${count} accounts",
"pickAccount": "Every account stores its own preferences. Pick the one to edit.",
"storedOnAccount": "These options are saved on the account itself, so every app signed in to it uses them — including Plezy on your other devices.",
"noAccounts": "No accounts to configure",
"noAccountsHint": "Sign in to Plex, or connect a Jellyfin or Emby server, and the preferences stored on that account show up here.",
"unavailable": "Can't reach this account",
"loadFailed": "Couldn't load these preferences",
"noPreference": "No preference",
"notSet": "Not set",
"groups": {
"audioAndSubtitles": "Audio & subtitles",
"libraryDisplay": "Library",
"personalMedia": "Personal media"
},
"preferredAudioLanguage": "Preferred audio language",
"autoSelectAudio": "Choose audio by language",
"autoSelectAudioDescription": "Off keeps whatever audio track the file marks as default.",
"preferredSubtitleLanguage": "Preferred subtitle language",
"subtitleMode": "Turn subtitles on",
"subtitleModes": {
"none": "Manually selected",
"noneDescription": "Never turn subtitles on by itself.",
"defaultMode": "Follow the track flags",
"defaultModeDescription": "Use the default and forced flags stored on each subtitle track.",
"always": "Always enabled",
"alwaysDescription": "Turn on a subtitle track in the preferred language whenever one exists.",
"onlyForced": "Only forced subtitles",
"onlyForcedDescription": "Load only the tracks marked as forced.",
"smart": "Shown with foreign audio",
"smartDescription": "Turn subtitles on only when the audio is in another language."
},
"subtitleAccessibility": "SDH subtitles",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Prefer non-SDH subtitles",
"preferSdh": "Prefer SDH subtitles",
"onlySdh": "Only SDH subtitles",
"onlyNonSdh": "Only non-SDH subtitles"
},
"forcedSubtitles": "Forced subtitles",
"forcedSubtitleOptions": {
"preferNonForced": "Prefer non-forced subtitles",
"preferForced": "Prefer forced subtitles",
"onlyForced": "Only forced subtitles",
"onlyNonForced": "Only non-forced subtitles"
},
"displayMissingEpisodes": "Show missing episodes",
"displayMissingEpisodesDescription": "List episodes the server knows about but has no file for.",
"hidePlayedInLatest": "Hide watched items in Latest",
"hidePlayedInLatestDescription": "Leave items you have already watched out of the server's Latest rows.",
"displayCollectionsView": "Show the Collections view",
"displayCollectionsViewDescription": "Offer the server's Collections view alongside your libraries.",
"rewatchingInNextUp": "Keep rewatched shows in Next Up",
"rewatchingInNextUpDescription": "Once you finish a show, start it again and Next Up follows the rewatch instead of dropping the show.",
"watchedIndicator": "Watched indicators",
"watchedIndicatorOptions": {
"none": "Never",
"moviesAndShows": "Movies and TV shows",
"movies": "Movies only",
"shows": "TV shows only"
},
"mediaReviewsVisibility": "Ratings & reviews",
"mediaReviewsOptions": {
"usersAndCritics": "Users and critics",
"usersOnly": "Users only",
"criticsOnly": "Critics only",
"nobody": "Hidden"
}
},
"discover": {
"title": "Discover",
"noContentAvailable": "No content available",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "No servers found for ${username} (${email})",
"failedToLoadServers": "Failed to load servers: ${error}"
"failedToLoadServers": "Failed to load servers: ${error}",
"noValidServers": "No usable servers were found on this account"
},
"hubDetail": {
"title": "Title",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} popular",
"rankAiring": "#${n} airing",
"rankRated": "#${n} rated",
"rankFavorited": "#${n} favorited",
"rankTrending": "#${n} trending",
"rankSeasonal": "#${n} in ${season}",
"watchingNow": "${n} watching",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Unknown channel",
"live": "LIVE",
"reloadGuide": "Reload Guide",
"searchGuide": "Search Guide",
"searchHint": "Search channels & programs",
"searchNoResults": "No matches for \"${query}\"",
"channelsSection": "Channels",
"programsSection": "Programs",
"now": "Now",
"today": "Today",
"tomorrow": "Tomorrow",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Guide refresh requested",
"rulesProcessRequested": "Rule re-evaluation requested",
"recordShow": "Record show",
"recordSettings": {
"startEarly": "Start early (seconds)",
"endLate": "End late (seconds)",
"newOnly": "Only new episodes",
"anyChannel": "Record on any channel",
"anyTime": "Record at any time",
"skipInLibrary": "Skip episodes already in the library",
"keepUpTo": "Episodes to keep",
"keepUpToHint": "0 keeps every episode"
},
"startingInMinutes": "Starting in ${minutes} min",
"dayAtTime": "${day} at ${time}",
"invalidPlaybackData": "${product} returned invalid Live TV playback data",
@@ -1416,7 +1556,14 @@
"repeatAll": "Repeat all",
"repeatOne": "Repeat one",
"instantMixNoServer": "No server is available for an instant mix",
"noAudioUrl": "No audio URL is available for ${track}"
"instantMixFailed": "Failed to load the instant mix",
"instantMixEmpty": "The instant mix came back with no tracks",
"noAudioUrl": "No audio URL is available for ${track}",
"discography": {
"singlesAndEps": "Singles & EPs",
"live": "Live",
"compilations": "Compilations"
}
},
"watchTogether": {
"title": "Watch Together",
@@ -1442,6 +1589,13 @@
"host": "Host",
"hostBadge": "HOST",
"youAreHost": "You are the host",
"makeHost": "Make host",
"makeHostQuestion": "Transfer host?",
"makeHostConfirm": "${name} will control playback and drive the session for everyone.",
"transfer": "Transfer",
"hostChangedTo": "${name} is now the host",
"youAreNowHost": "You are now the host",
"hostTransferFailed": "Couldn't make ${name} the host",
"watchingWithOthers": "Watching with others",
"endSession": "End Session",
"leaveSession": "Leave Session",
@@ -1484,7 +1638,13 @@
"removeRoom": "Remove",
"guestSwitchUnavailable": "Couldn't switch — server unavailable for sync",
"guestSwitchFailed": "Couldn't switch — content not found on this server",
"defaultDisplayName": "User"
"defaultDisplayName": "User",
"errors": {
"timedOut": "The relay did not respond in time",
"connectionLost": "The connection closed before the session was ready",
"invalidRelayResponse": "The relay sent an unexpected response",
"sessionEnded": "The host ended the session"
}
},
"downloads": {
"title": "Downloads",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Use your mobile device to control this app",
"startServer": "Start Server",
"stopServer": "Stop Server",
"minimize": "Minimize"
"minimize": "Minimize",
"manualAddressHint": "Manual connection address:"
},
"pairing": {
"discoveryDescription": "Plezy devices with the same Plex account appear here",
@@ -1726,6 +1887,7 @@
"decoder": "Decoder",
"rawDecoder": "Raw Decoder",
"tunneling": "Tunneling",
"passthrough": "Passthrough",
"aspect": "Aspect",
"rotation": "Rotation",
"dvSource": "DV Source",
@@ -1857,9 +2019,7 @@
"producer": "Producer",
"country": "Country",
"collection": "Collection",
"label": "Label",
"style": "Style",
"mood": "Mood"
"label": "Label"
},
"matchScreen": {
"match": "Match...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Partially available",
"statusRequested": "Requested",
"statusProcessing": "Processing",
"statusBlocklisted": "Blocklisted",
"couldNotReach": "Could not reach ${url}: ${error}",
"noInstanceAtUrl": "No Seerr instance at ${url} (HTTP ${status})",
"invalidUrl": "Enter a server address like https://seerr.example.com",
"quickConnectUnsupported": "This Seerr instance does not support Quick Connect. It needs Seerr 3.4 or newer.",
"notInitialized": "This Seerr instance has not completed first-run setup",
"noPlexTokenForReauth": "No Plex token is available to sign in again",
"noStoredCredentials": "No stored credentials are available to sign in again",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Services",
"hubSubtitle": "Sync watch progress and request new titles.",
"integrations": "Integrations",
"notConnected": "Not connected",
"connectedAs": "Connected as @${username}",
"scrobble": "Track progress automatically",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Reuse another profile's connection. PIN-protected profiles require a PIN.",
"invalidCredentials": "Invalid username or password",
"authResponseNotJson": "The authentication response was not valid JSON",
"authResponseIncomplete": "The sign-in response from the server was incomplete",
"quickConnectRejected": "Quick Connect was rejected by the server",
"quickConnectNotJson": "The Quick Connect response was not valid JSON",
"quickConnectMissingFields": "The Quick Connect response is missing a code or secret",
+192 -27
View File
@@ -12,6 +12,7 @@
"useBrowser": "Usar navegador",
"or": "o",
"connectToMediaBrowser": "Conectar a ${product}",
"quickConnect": "Quick Connect",
"useQuickConnect": "Usar Quick Connect",
"quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.",
"quickConnectWaiting": "Esperando aprobación…",
@@ -53,6 +54,7 @@
"mute": "Silenciar",
"ok": "OK",
"off": "Desactivado",
"options": "Opciones",
"seasonNumber": "Temporada ${number}",
"episodeNumberTitle": "Episodio ${number} - ${title}",
"chapterNumber": "Capítulo ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/D",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Película",
"show": "Serie",
"season": "Temporada",
"episode": "Episodio",
"artist": "Artista",
"album": "Álbum",
"track": "Pista",
"collection": "Colección",
"playlist": "Lista de reproducción",
"clip": "Clip",
"photo": "Foto",
"folder": "Carpeta"
}
},
"screens": {
"licenses": "Licencias",
@@ -127,6 +143,10 @@
"displayScale": "Escala de visualización",
"compact": "Compacto",
"comfortable": "Cómodo",
"gridSpacing": "Espaciado de la cuadrícula",
"gridSpacingTight": "Ajustado",
"gridSpacingNormal": "Normal",
"gridSpacingSpacious": "Espacioso",
"tvCornerSpotlightBackdrop": "Imagen destacada en la esquina",
"tvCornerSpotlightBackdropDescription": "Mostrar la imagen destacada en la esquina superior derecha en lugar de ocupar toda la pantalla",
"viewMode": "Modo de vista",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Decodificación por hardware",
"hardwareDecodingDescription": "Usar aceleración por hardware cuando esté disponible",
"bufferSize": "Tamaño del búfer",
"bufferSizeMB": "${size} MB",
"bufferSizeAuto": "Automático (recomendado)",
"bufferSizeWarning": "${heap} MB de memoria disponible. Un búfer de ${size} MB puede afectar a la reproducción.",
"playbackBuffer": "Búfer de reproducción",
"playbackBufferAuto": "Automático (recomendado)",
"playbackBufferLarge": "Grande",
"playbackBufferExtraLarge": "Extra grande",
"playbackBufferDescription": "Almacena más en búfer para conexiones inestables. También limitado por el tamaño del búfer.",
"defaultQualityTitle": "Calidad predeterminada",
"cellularQualityTitle": "Calidad predeterminada en datos móviles",
"cellularQualitySameAsDefault": "Igual que la calidad predeterminada",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Calidad de música",
"subtitleStyling": "Estilo de subtítulos",
"subtitleStylingDescription": "Personalizar la apariencia de los subtítulos",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Recordar opciones de audio y subtítulos por título",
"followServerTrackSelections": "Usar la selección de pistas del servidor por episodio",
"followServerTrackSelectionsDescription": "Al cambiar de episodio, aplicar el audio y los subtítulos seleccionados en el servidor en lugar de mantener la elección actual",
"resumeMusicOnLaunch": "Recordar la sesión de música",
"resumeMusicOnLaunchDescription": "Al iniciar la aplicación, reabrir la última canción en pausa donde se quedó",
"showChapterMarkersOnTimeline": "Mostrar marcadores de capítulos en la barra de progreso",
"showChapterMarkersOnTimelineDescription": "Dividir la barra de progreso en los límites de capítulos",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Especiales en orden de episodios",
"specialsOrderingDescription": "Dónde se reproducen los especiales en el orden de visualización de una serie",
"specialsOrderingServer": "Seguir orden del servidor",
"specialsOrderingAirDate": "Intercalar por fecha de emisión",
"specialsOrderingLast": "Después de las temporadas normales",
"clickVideoTogglesPlayback": "Clic en el video para reproducir/pausar",
"clickVideoTogglesPlaybackDescription": "Haz clic en el video para reproducir/pausar en vez de mostrar controles.",
"videoPlayerControls": "Controles del reproductor de video",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "El atajo ya está asignado a ${action}",
"shortcutUpdated": "Atajo actualizado para ${action}",
"saveFailed": "No se pudieron guardar los cambios. Inténtalo de nuevo.",
"autoSkip": "Salto automático",
"autoPlayAndSkip": "Reproducción automática y saltos",
"autoPlayNextEpisode": "Reproducción automática del siguiente episodio",
"autoPlayNextEpisodeDescription": "Iniciar automáticamente el siguiente episodio cuando termine el actual",
"playNextCountdown": "Cuenta atrás para el siguiente episodio",
"playNextCountdownImmediate": "Reproducir de inmediato",
"autoSkipIntro": "Saltar introducción automáticamente",
"autoSkipIntroDescription": "Saltar automáticamente los marcadores de introducción después de unos segundos",
"autoSkipCredits": "Saltar créditos automáticamente",
@@ -291,6 +317,8 @@
"autoPipDescription": "Activar automáticamente el modo de imagen en imagen al salir de la aplicación durante la reproducción",
"matchContentFrameRate": "Ajustar frecuencia de actualización",
"matchContentFrameRateDescription": "Ajustar la frecuencia de pantalla al contenido de video",
"matchContentResolution": "Ajustar a la resolución del contenido",
"matchContentResolutionDescription": "Cambia la pantalla a la resolución nativa del vídeo para que tu televisor se encargue del escalado. Los menús y los subtítulos también se escalan durante la reproducción",
"matchRefreshRate": "Ajustar frecuencia de refresco",
"matchRefreshRateDescription": "Ajustar la frecuencia de pantalla en pantalla completa",
"matchDynamicRange": "Ajustar rango dinámico",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Forzar DV7 nativo y suprimir el reintento de conversión DV",
"dvConversionDv81Description": "Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision",
"dvConversionHevcStripDescription": "Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional",
"deinterlace": "Desentrelazado",
"deinterlaceDescription": "Elimina los artefactos de peine del video entrelazado (solo reproductor mpv)",
"requireProfileSelectionOnOpen": "Pedir perfil al abrir la aplicación",
"requireProfileSelectionOnOpenDescription": "Mostrar selección de perfil cada vez que se abre la aplicación",
"forceTvMode": "Forzar modo TV",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Muestra la pestaña Explorar con contenido de Plex Discover y servicios de seguimiento conectados",
"liveTvDefaultFavorites": "Canales favoritos por defecto",
"liveTvDefaultFavoritesDescription": "Mostrar solo canales favoritos al abrir TV en vivo",
"general": "General",
"generalDescription": "Idioma, inicio y comportamiento de la ventana",
"languageAndRegion": "Idioma y región",
"startup": "Inicio",
"display": "Pantalla",
"libraryAndCards": "Biblioteca y tarjetas",
"homeScreen": "Pantalla de inicio",
"navigation": "Navegación",
"window": "Ventana",
"content": "Contenido",
"liveTv": "TV en vivo",
"player": "Reproductor",
"subtitlesAndConfig": "Subtítulos y configuración",
"videoAndDisplay": "Video y pantalla",
"audio": "Audio",
"quality": "Calidad",
"subtitles": "Subtítulos",
"seekAndTiming": "Desplazamiento y tiempos",
"behavior": "Comportamiento",
"gestures": "Gestos",
"gestureBrightnessSwipe": "Gesto de brillo",
"gestureBrightnessSwipeDescription": "Desliza hacia arriba o abajo en el borde izquierdo para ajustar el brillo",
"gestureVolumeSwipe": "Gesto de volumen",
"gestureVolumeSwipeDescription": "Desliza hacia arriba o abajo en el borde derecho para ajustar el volumen",
"gesturePinchToZoom": "Pellizcar para hacer zoom",
"gesturePinchToZoomDescription": "Pellizca el video para acercar o alejar",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Controles",
"rememberPlayerChanges": "Recordar cambios del reproductor",
"rememberPlayerChangesDescription": "Dónde se guarda y se vuelve a aplicar un cambio realizado durante la reproducción",
"scopePlaybackSpeed": "Velocidad de reproducción",
"scopeShaderPreset": "Preajuste de sombreado",
"scopeAspectRatio": "Relación de aspecto",
"scopeSyncOffsets": "Sincronización de audio y subtítulos",
"playerScopeOff": "No guardar",
"playerScopeGlobal": "En todas partes",
"playerScopeLibrary": "Por biblioteca",
"playerScopeTitle": "Por serie o película",
"exportDialogTitle": "Exportar ajustes de Plezy"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Subtítulos",
"resetToZero": "Restablecer a 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} se reproduce más tarde",
"playsEarlier": "${label} se reproduce antes",
"noOffset": "Sin desfase",
"letterbox": "Con bandas negras",
"fillScreen": "Llenar pantalla",
"stretch": "Estirar",
@@ -670,6 +723,7 @@
"notSupported": "El dispositivo no admite el modo de imagen en imagen",
"voSwitchFailed": "No se pudo cambiar la salida de video para el modo de imagen en imagen",
"failed": "No se pudo iniciar el modo de imagen en imagen",
"prepareFailed": "No se pudo preparar el modo de imagen en imagen",
"unknown": "Ocurrió un error: ${error}"
},
"chapters": "Capítulos",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Editar conexión de ${product}",
"editMediaBrowserIntro": "Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia."
},
"accountPreferences": {
"sectionTitle": "Preferencias de cuenta",
"hubSubtitleSingle": "Opciones de audio, subtítulos y biblioteca guardadas en ${account}",
"hubSubtitleMultiple": "Opciones de audio, subtítulos y biblioteca guardadas en ${count} cuentas",
"pickAccount": "Cada cuenta guarda sus propias preferencias. Elige la que quieras editar.",
"storedOnAccount": "Estas opciones se guardan en la propia cuenta, así que cualquier aplicación con la sesión iniciada las usa, incluido Plezy en tus otros dispositivos.",
"noAccounts": "No hay cuentas que configurar",
"noAccountsHint": "Inicia sesión con Plex o conecta un servidor de Jellyfin o Emby y las preferencias guardadas en esa cuenta aparecerán aquí.",
"unavailable": "No se puede acceder a esta cuenta",
"loadFailed": "No se pudieron cargar estas preferencias",
"noPreference": "Sin preferencia",
"notSet": "No establecido",
"groups": {
"audioAndSubtitles": "Audio y subtítulos",
"libraryDisplay": "Biblioteca",
"personalMedia": "Medios personales"
},
"preferredAudioLanguage": "Idioma de audio preferido",
"autoSelectAudio": "Elegir audio por idioma",
"autoSelectAudioDescription": "Desactivado mantiene la pista de audio que el archivo marca como predeterminada.",
"preferredSubtitleLanguage": "Idioma de subtítulos preferido",
"subtitleMode": "Activar subtítulos",
"subtitleModes": {
"none": "Seleccionado manualmente",
"noneDescription": "No activar los subtítulos por sí solo.",
"defaultMode": "Seguir las marcas de la pista",
"defaultModeDescription": "Usar las marcas de predeterminada y forzada guardadas en cada pista de subtítulos.",
"always": "Siempre activado",
"alwaysDescription": "Activar una pista de subtítulos en el idioma preferido siempre que exista.",
"onlyForced": "Solo subtítulos forzados",
"onlyForcedDescription": "Cargar solo las pistas marcadas como forzadas.",
"smart": "Mostrar con audio extranjero",
"smartDescription": "Activar los subtítulos solo cuando el audio está en otro idioma."
},
"subtitleAccessibility": "Subtítulos SDH",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Preferir subtítulos no SDH",
"preferSdh": "Preferir subtítulos SDH",
"onlySdh": "Solo subtítulos SDH",
"onlyNonSdh": "Solo subtítulos no SDH"
},
"forcedSubtitles": "Subtítulos forzados",
"forcedSubtitleOptions": {
"preferNonForced": "Preferir subtítulos no forzados",
"preferForced": "Preferir subtítulos forzados",
"onlyForced": "Solo subtítulos forzados",
"onlyNonForced": "Solo subtítulos no forzados"
},
"displayMissingEpisodes": "Mostrar episodios faltantes",
"displayMissingEpisodesDescription": "Enumera los episodios que el servidor conoce pero para los que no tiene ningún archivo.",
"hidePlayedInLatest": "Ocultar elementos vistos en Recientes",
"hidePlayedInLatestDescription": "Deja fuera de las filas de Recientes del servidor los elementos que ya has visto.",
"displayCollectionsView": "Mostrar la vista de colecciones",
"displayCollectionsViewDescription": "Ofrecer la vista de colecciones del servidor junto a tus bibliotecas.",
"rewatchingInNextUp": "Mantener las series revistas en A continuación",
"rewatchingInNextUpDescription": "Cuando termines una serie y la vuelvas a empezar, A continuación seguirá tu revisión en lugar de descartarla.",
"watchedIndicator": "Indicadores de visto",
"watchedIndicatorOptions": {
"none": "Nunca",
"moviesAndShows": "Películas y series de TV",
"movies": "Solo películas",
"shows": "Solo series de TV"
},
"mediaReviewsVisibility": "Valoraciones y reseñas",
"mediaReviewsOptions": {
"usersAndCritics": "Usuarios y críticos",
"usersOnly": "Solo usuarios",
"criticsOnly": "Solo críticos",
"nobody": "Ocultos"
}
},
"discover": {
"title": "Descubrir",
"noContentAvailable": "No hay contenido disponible",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "No se encontraron servidores para ${username} (${email})",
"failedToLoadServers": "Error al cargar servidores: ${error}"
"failedToLoadServers": "Error al cargar servidores: ${error}",
"noValidServers": "No se encontraron servidores utilizables en esta cuenta"
},
"hubDetail": {
"title": "Título",
@@ -1132,10 +1258,9 @@
"searchPrompt": "Busca películas y series en ${source}.",
"searchFailed": "La búsqueda falló. Comprueba tu conexión e inténtalo de nuevo.",
"badge": {
"rankPopular": "#${n} popular",
"rankPopular": "#${n} más popular",
"rankAiring": "#${n} en emisión",
"rankRated": "#${n} puntuado",
"rankFavorited": "#${n} favorito",
"rankTrending": "#${n} en tendencia",
"rankSeasonal": "#${n} en ${season}",
"watchingNow": "${n} viendo",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Canal desconocido",
"live": "EN VIVO",
"reloadGuide": "Recargar guía",
"searchGuide": "Buscar en la guía",
"searchHint": "Buscar canales y programas",
"searchNoResults": "Sin coincidencias para \"${query}\"",
"channelsSection": "Canales",
"programsSection": "Programas",
"now": "Ahora",
"today": "Hoy",
"tomorrow": "Mañana",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Solicitada actualización de la guía",
"rulesProcessRequested": "Solicitada reevaluación de reglas",
"recordShow": "Grabar programa",
"recordSettings": {
"startEarly": "Empezar antes (segundos)",
"endLate": "Terminar después (segundos)",
"newOnly": "Solo episodios nuevos",
"anyChannel": "Grabar en cualquier canal",
"anyTime": "Grabar a cualquier hora",
"skipInLibrary": "Omitir episodios que ya están en la biblioteca",
"keepUpTo": "Episodios que conservar",
"keepUpToHint": "0 conserva todos los episodios"
},
"startingInMinutes": "Empieza en ${minutes} min",
"dayAtTime": "${day} a las ${time}",
"invalidPlaybackData": "${product} devolvió datos de reproducción de TV en vivo no válidos",
@@ -1416,7 +1556,14 @@
"repeatAll": "Repetir todo",
"repeatOne": "Repetir una",
"instantMixNoServer": "No hay ningún servidor disponible para una mezcla instantánea",
"noAudioUrl": "No hay ninguna URL de audio disponible para ${track}"
"instantMixFailed": "Error al cargar la mezcla instantánea",
"instantMixEmpty": "La mezcla instantánea no devolvió ninguna canción",
"noAudioUrl": "No hay ninguna URL de audio disponible para ${track}",
"discography": {
"singlesAndEps": "Sencillos y EP",
"live": "En directo",
"compilations": "Recopilaciones"
}
},
"watchTogether": {
"title": "Ver juntos",
@@ -1442,6 +1589,13 @@
"host": "Anfitrión",
"hostBadge": "ANFITRIÓN",
"youAreHost": "Eres el anfitrión",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Viendo contenido con otras personas",
"endSession": "Finalizar sesión",
"leaveSession": "Salir de la sesión",
@@ -1484,7 +1638,13 @@
"removeRoom": "Eliminar",
"guestSwitchUnavailable": "No se pudo cambiar — servidor no disponible para sincronización",
"guestSwitchFailed": "No se pudo cambiar — contenido no encontrado en este servidor",
"defaultDisplayName": "Usuario"
"defaultDisplayName": "Usuario",
"errors": {
"timedOut": "El servidor de retransmisión no respondió a tiempo",
"connectionLost": "La conexión se cerró antes de que la sesión estuviera lista",
"invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada",
"sessionEnded": "El anfitrión finalizó la sesión"
}
},
"downloads": {
"title": "Descargas",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Usa tu dispositivo móvil para controlar esta aplicación",
"startServer": "Iniciar servidor",
"stopServer": "Detener servidor",
"minimize": "Minimizar"
"minimize": "Minimizar",
"manualAddressHint": "Dirección de conexión manual:"
},
"pairing": {
"discoveryDescription": "Los dispositivos Plezy con la misma cuenta Plex aparecen aquí",
@@ -1726,6 +1887,7 @@
"decoder": "Decodificador",
"rawDecoder": "Decodificador sin procesar",
"tunneling": "Túnel",
"passthrough": "Transferencia directa",
"aspect": "Aspecto",
"rotation": "Rotación",
"dvSource": "Origen DV",
@@ -1857,9 +2019,7 @@
"producer": "Productor",
"country": "País",
"collection": "Colección",
"label": "Etiqueta",
"style": "Estilo",
"mood": "Estado de ánimo"
"label": "Etiqueta"
},
"matchScreen": {
"match": "Asociar...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Parcialmente disponible",
"statusRequested": "Solicitado",
"statusProcessing": "Procesando",
"statusBlocklisted": "En la lista de bloqueo",
"couldNotReach": "No se pudo conectar con ${url}: ${error}",
"noInstanceAtUrl": "No hay ninguna instancia de Seerr en ${url} (HTTP ${status})",
"invalidUrl": "Introduce una dirección de servidor como https://seerr.example.com",
"quickConnectUnsupported": "Esta instancia de Seerr no admite Quick Connect. Necesita Seerr 3.4 o más reciente.",
"notInitialized": "Esta instancia de Seerr no ha completado la configuración inicial",
"noPlexTokenForReauth": "No hay ningún token de Plex disponible para volver a iniciar sesión",
"noStoredCredentials": "No hay credenciales guardadas disponibles para volver a iniciar sesión",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Servicios",
"hubSubtitle": "Sincroniza tu progreso de visualización y solicita nuevos títulos.",
"integrations": "Integraciones",
"notConnected": "No conectado",
"connectedAs": "Conectado como @${username}",
"scrobble": "Registrar progreso automáticamente",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN.",
"invalidCredentials": "Usuario o contraseña no válidos",
"authResponseNotJson": "La respuesta de autenticación no era un JSON válido",
"authResponseIncomplete": "La respuesta de inicio de sesión del servidor estaba incompleta",
"quickConnectRejected": "Quick Connect fue rechazado por el servidor",
"quickConnectNotJson": "La respuesta de Quick Connect no era un JSON válido",
"quickConnectMissingFields": "La respuesta de Quick Connect no incluye el código o el secreto",
+192 -27
View File
@@ -12,6 +12,7 @@
"useBrowser": "Utiliser le navigateur",
"or": "ou",
"connectToMediaBrowser": "Se connecter à ${product}",
"quickConnect": "Quick Connect",
"useQuickConnect": "Utiliser Quick Connect",
"quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.",
"quickConnectWaiting": "En attente d'approbation…",
@@ -53,6 +54,7 @@
"mute": "Couper le son",
"ok": "OK",
"off": "Désactivé",
"options": "Options",
"seasonNumber": "Saison ${number}",
"episodeNumberTitle": "Épisode ${number} – ${title}",
"chapterNumber": "Chapitre ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Film",
"show": "Série",
"season": "Saison",
"episode": "Épisode",
"artist": "Artiste",
"album": "Album",
"track": "Titre",
"collection": "Collection",
"playlist": "Playlist",
"clip": "Clip",
"photo": "Photo",
"folder": "Dossier"
}
},
"screens": {
"licenses": "Licences",
@@ -127,6 +143,10 @@
"displayScale": "Échelle d'affichage",
"compact": "Compact",
"comfortable": "Confortable",
"gridSpacing": "Espacement de la grille",
"gridSpacingTight": "Resserré",
"gridSpacingNormal": "Normal",
"gridSpacingSpacious": "Aéré",
"tvCornerSpotlightBackdrop": "Illustration en vedette dans le coin",
"tvCornerSpotlightBackdropDescription": "Afficher l’illustration en vedette dans le coin supérieur droit plutôt qu’en plein écran",
"viewMode": "Mode d'affichage",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Décodage matériel",
"hardwareDecodingDescription": "Utiliser l’accélération matérielle lorsqu’elle est disponible",
"bufferSize": "Taille du tampon",
"bufferSizeMB": "${size} Mo",
"bufferSizeAuto": "Automatique (recommandé)",
"bufferSizeWarning": "${heap} Mo de mémoire disponible. Un tampon de ${size} Mo peut affecter la lecture.",
"playbackBuffer": "Tampon de lecture",
"playbackBufferAuto": "Auto (recommandé)",
"playbackBufferLarge": "Grand",
"playbackBufferExtraLarge": "Très grand",
"playbackBufferDescription": "Tampon supplémentaire contre les connexions instables. Également limité par la taille du tampon.",
"defaultQualityTitle": "Qualité par défaut",
"cellularQualityTitle": "Qualité par défaut sur les données mobiles",
"cellularQualitySameAsDefault": "Identique à la qualité par défaut",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Qualité de la musique",
"subtitleStyling": "Style des sous-titres",
"subtitleStylingDescription": "Personnaliser l’apparence des sous-titres",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Mémoriser les choix audio et sous-titres par titre",
"followServerTrackSelections": "Utiliser les pistes sélectionnées sur le serveur pour chaque épisode",
"followServerTrackSelectionsDescription": "Au changement d'épisode, appliquer l'audio et les sous-titres sélectionnés sur le serveur au lieu de conserver le choix en cours",
"resumeMusicOnLaunch": "Mémoriser la session musicale",
"resumeMusicOnLaunchDescription": "Au lancement de l'application, rouvrir le dernier titre en pause là où il s'était arrêté",
"showChapterMarkersOnTimeline": "Afficher les marqueurs de chapitres sur la barre de lecture",
"showChapterMarkersOnTimelineDescription": "Segmenter la barre de lecture aux limites des chapitres",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Épisodes spéciaux dans l’ordre des épisodes",
"specialsOrderingDescription": "Position des épisodes spéciaux dans l’ordre de visionnage d’une série",
"specialsOrderingServer": "Suivre l’ordre du serveur",
"specialsOrderingAirDate": "Intercaler selon la date de diffusion",
"specialsOrderingLast": "Après les saisons normales",
"clickVideoTogglesPlayback": "Cliquer sur la vidéo pour alterner entre lecture et pause",
"clickVideoTogglesPlaybackDescription": "Cliquer sur la vidéo pour lire ou mettre en pause plutôt que d’afficher les commandes",
"videoPlayerControls": "Commandes du lecteur vidéo",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Raccourci déjà attribué à ${action}",
"shortcutUpdated": "Raccourci mis à jour pour ${action}",
"saveFailed": "Impossible d’enregistrer les modifications. Réessayez.",
"autoSkip": "Saut automatique",
"autoPlayAndSkip": "Lecture automatique et saut",
"autoPlayNextEpisode": "Lire automatiquement l'épisode suivant",
"autoPlayNextEpisodeDescription": "Lancer automatiquement l'épisode suivant lorsqu'un épisode se termine",
"playNextCountdown": "Compte à rebours avant l'épisode suivant",
"playNextCountdownImmediate": "Lire immédiatement",
"autoSkipIntro": "Passer automatiquement l’introduction",
"autoSkipIntroDescription": "Passer automatiquement les marqueurs d’introduction après quelques secondes",
"autoSkipCredits": "Passer automatiquement le générique",
@@ -291,6 +317,8 @@
"autoPipDescription": "Passer en mode image dans l’image si vous quittez l’application pendant la lecture",
"matchContentFrameRate": "Adapter la fréquence d’images au contenu",
"matchContentFrameRateDescription": "Adapter la fréquence de rafraîchissement de l’écran au contenu vidéo",
"matchContentResolution": "Adapter à la résolution du contenu",
"matchContentResolutionDescription": "Bascule l’affichage sur la résolution native de la vidéo pour que votre téléviseur gère la mise à l’échelle. Les menus et les sous-titres sont eux aussi mis à l’échelle pendant la lecture",
"matchRefreshRate": "Adapter la fréquence de rafraîchissement",
"matchRefreshRateDescription": "Adapter la fréquence d'affichage en plein écran",
"matchDynamicRange": "Adapter la plage dynamique",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV",
"dvConversionDv81Description": "Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision",
"dvConversionHevcStripDescription": "Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple",
"deinterlace": "Désentrelacement",
"deinterlaceDescription": "Supprimer les artefacts de peignage des vidéos entrelacées (lecteur mpv uniquement)",
"requireProfileSelectionOnOpen": "Demander le profil à l'ouverture",
"requireProfileSelectionOnOpenDescription": "Afficher la sélection de profil à chaque ouverture de l'application",
"forceTvMode": "Forcer le mode TV",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Afficher l’onglet Explorer avec le contenu de Plex Discover et des services de suivi connectés",
"liveTvDefaultFavorites": "Chaînes favorites par défaut",
"liveTvDefaultFavoritesDescription": "Afficher uniquement les chaînes favorites à l'ouverture de la TV en direct",
"general": "Général",
"generalDescription": "Langue, démarrage et comportement de la fenêtre",
"languageAndRegion": "Langue et région",
"startup": "Démarrage",
"display": "Affichage",
"libraryAndCards": "Bibliothèque et cartes",
"homeScreen": "Écran d'accueil",
"navigation": "Navigation",
"window": "Fenêtre",
"content": "Contenu",
"liveTv": "TV en direct",
"player": "Lecteur",
"subtitlesAndConfig": "Sous-titres et configuration",
"videoAndDisplay": "Vidéo et affichage",
"audio": "Audio",
"quality": "Qualité",
"subtitles": "Sous-titres",
"seekAndTiming": "Déplacement et minutage",
"behavior": "Comportement",
"gestures": "Gestes",
"gestureBrightnessSwipe": "Balayage de luminosité",
"gestureBrightnessSwipeDescription": "Balayez vers le haut ou le bas sur le bord gauche pour régler la luminosité",
"gestureVolumeSwipe": "Balayage de volume",
"gestureVolumeSwipeDescription": "Balayez vers le haut ou le bas sur le bord droit pour régler le volume",
"gesturePinchToZoom": "Pincer pour zoomer",
"gesturePinchToZoomDescription": "Pincez la vidéo pour zoomer ou dézoomer",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Commandes",
"rememberPlayerChanges": "Mémoriser les modifications du lecteur",
"rememberPlayerChangesDescription": "Où une modification effectuée pendant la lecture est enregistrée et réappliquée",
"scopePlaybackSpeed": "Vitesse de lecture",
"scopeShaderPreset": "Préréglage du shader",
"scopeAspectRatio": "Format d’image",
"scopeSyncOffsets": "Synchronisation audio et sous-titres",
"playerScopeOff": "Ne pas enregistrer",
"playerScopeGlobal": "Partout",
"playerScopeLibrary": "Par bibliothèque",
"playerScopeTitle": "Par série ou film",
"exportDialogTitle": "Exporter les paramètres de Plezy"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Sous-titres",
"resetToZero": "Réinitialiser à 0 ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} : lecture retardée",
"playsEarlier": "${label} : lecture avancée",
"noOffset": "Pas de décalage",
"letterbox": "Format letterbox",
"fillScreen": "Remplir l’écran",
"stretch": "Étirer",
@@ -670,6 +723,7 @@
"notSupported": "Cet appareil ne prend pas en charge le mode image dans l'image",
"voSwitchFailed": "Échec du changement de sortie vidéo pour l'image dans l'image",
"failed": "Échec du démarrage du mode image dans l'image",
"prepareFailed": "Impossible de préparer le mode image dans l'image",
"unknown": "Une erreur s'est produite : ${error}"
},
"chapters": "Chapitres",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Modifier la connexion ${product}",
"editMediaBrowserIntro": "Ajoutez ou retirez des URL pour ${serverName}. Plezy utilisera l'URL joignable avec la latence la plus faible."
},
"accountPreferences": {
"sectionTitle": "Préférences du compte",
"hubSubtitleSingle": "Options audio, sous-titres et bibliothèque enregistrées sur ${account}",
"hubSubtitleMultiple": "Options audio, sous-titres et bibliothèque enregistrées sur ${count} comptes",
"pickAccount": "Chaque compte enregistre ses propres préférences. Choisissez celui à modifier.",
"storedOnAccount": "Ces options sont enregistrées sur le compte lui-même : toutes les applications connectées les utilisent, y compris Plezy sur vos autres appareils.",
"noAccounts": "Aucun compte à configurer",
"noAccountsHint": "Connectez-vous à Plex, ou connectez un serveur Jellyfin ou Emby : les préférences enregistrées sur ce compte apparaîtront ici.",
"unavailable": "Impossible de joindre ce compte",
"loadFailed": "Impossible de charger ces préférences",
"noPreference": "Aucune préférence",
"notSet": "Non défini",
"groups": {
"audioAndSubtitles": "Audio et sous-titres",
"libraryDisplay": "Bibliothèque",
"personalMedia": "Médias personnels"
},
"preferredAudioLanguage": "Langue audio préférée",
"autoSelectAudio": "Choisir l'audio par langue",
"autoSelectAudioDescription": "Désactivé : la piste audio marquée par défaut dans le fichier est conservée.",
"preferredSubtitleLanguage": "Langue de sous-titres préférée",
"subtitleMode": "Activer les sous-titres",
"subtitleModes": {
"none": "Sélection manuelle",
"noneDescription": "Ne jamais activer les sous-titres automatiquement.",
"defaultMode": "Suivre les indicateurs des pistes",
"defaultModeDescription": "Utiliser les indicateurs par défaut et forcés enregistrés sur chaque piste de sous-titres.",
"always": "Toujours activé",
"alwaysDescription": "Activer une piste de sous-titres dans la langue préférée lorsqu'elle existe.",
"onlyForced": "Sous-titres forcés uniquement",
"onlyForcedDescription": "Charger uniquement les pistes marquées comme forcées.",
"smart": "Avec l'audio en langue étrangère",
"smartDescription": "Activer les sous-titres uniquement lorsque l'audio est dans une autre langue."
},
"subtitleAccessibility": "Sous-titres SDH",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Préférer les sous-titres non SDH",
"preferSdh": "Préférer les sous-titres SDH",
"onlySdh": "Sous-titres SDH uniquement",
"onlyNonSdh": "Sous-titres non SDH uniquement"
},
"forcedSubtitles": "Sous-titres forcés",
"forcedSubtitleOptions": {
"preferNonForced": "Préférer les sous-titres non forcés",
"preferForced": "Préférer les sous-titres forcés",
"onlyForced": "Sous-titres forcés uniquement",
"onlyNonForced": "Sous-titres non forcés uniquement"
},
"displayMissingEpisodes": "Afficher les épisodes manquants",
"displayMissingEpisodesDescription": "Répertorier les épisodes connus du serveur mais sans fichier.",
"hidePlayedInLatest": "Masquer les éléments vus dans « Récemment ajouté »",
"hidePlayedInLatestDescription": "Exclure les éléments déjà vus des lignes « Récemment ajouté » du serveur.",
"displayCollectionsView": "Afficher la vue Collections",
"displayCollectionsViewDescription": "Proposer la vue Collections du serveur en plus de vos bibliothèques.",
"rewatchingInNextUp": "Conserver les séries revues dans « À suivre »",
"rewatchingInNextUpDescription": "Une fois une série terminée, si vous la recommencez, « À suivre » suit le revisionnage au lieu de la retirer.",
"watchedIndicator": "Indicateurs de visionnage",
"watchedIndicatorOptions": {
"none": "Jamais",
"moviesAndShows": "Films et séries TV",
"movies": "Films uniquement",
"shows": "Séries TV uniquement"
},
"mediaReviewsVisibility": "Notes et critiques",
"mediaReviewsOptions": {
"usersAndCritics": "Utilisateurs et critiques",
"usersOnly": "Utilisateurs uniquement",
"criticsOnly": "Critiques uniquement",
"nobody": "Masquées"
}
},
"discover": {
"title": "Découvrir",
"noContentAvailable": "Aucun contenu disponible",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "Aucun serveur trouvé pour ${username} (${email})",
"failedToLoadServers": "Échec du chargement des serveurs : ${error}"
"failedToLoadServers": "Échec du chargement des serveurs : ${error}",
"noValidServers": "Aucun serveur utilisable n’a été trouvé sur ce compte"
},
"hubDetail": {
"title": "Titre",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} populaire",
"rankAiring": "#${n} en diffusion",
"rankRated": "#${n} mieux noté",
"rankFavorited": "#${n} favoris",
"rankTrending": "#${n} tendance",
"rankSeasonal": "#${n} de ${season}",
"watchingNow": "${n} regardent",
@@ -1237,7 +1362,7 @@
"budget": "Budget",
"revenue": "Box-office",
"contentAdvisory": "Recommandation d'âge",
"tags": "Tags",
"tags": "Étiquettes",
"revealSpoilerTags": "Afficher les tags spoiler",
"links": "Liens",
"watchOn": "Regarder sur",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Chaîne inconnue",
"live": "EN DIRECT",
"reloadGuide": "Recharger le guide",
"searchGuide": "Rechercher dans le guide",
"searchHint": "Rechercher des chaînes et des programmes",
"searchNoResults": "Aucune correspondance pour \"${query}\"",
"channelsSection": "Chaînes",
"programsSection": "Programmes",
"now": "Maintenant",
"today": "Aujourd'hui",
"tomorrow": "Demain",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Mise à jour du guide demandée",
"rulesProcessRequested": "Réévaluation des règles demandée",
"recordShow": "Enregistrer l'émission",
"recordSettings": {
"startEarly": "Démarrer en avance (secondes)",
"endLate": "Terminer en retard (secondes)",
"newOnly": "Uniquement les nouveaux épisodes",
"anyChannel": "Enregistrer sur n’importe quelle chaîne",
"anyTime": "Enregistrer à n’importe quelle heure",
"skipInLibrary": "Ignorer les épisodes déjà présents dans la bibliothèque",
"keepUpTo": "Épisodes à conserver",
"keepUpToHint": "0 conserve tous les épisodes"
},
"startingInMinutes": "Commence dans ${minutes} min",
"dayAtTime": "${day} à ${time}",
"invalidPlaybackData": "${product} a renvoyé des données de lecture de TV en direct non valides",
@@ -1416,7 +1556,14 @@
"repeatAll": "Tout répéter",
"repeatOne": "Répéter le titre",
"instantMixNoServer": "Aucun serveur n’est disponible pour créer un mix instantané",
"noAudioUrl": "Aucune URL audio n’est disponible pour ${track}"
"instantMixFailed": "Impossible de charger le mix instantané",
"instantMixEmpty": "Le mix instantané n'a renvoyé aucun titre",
"noAudioUrl": "Aucune URL audio n’est disponible pour ${track}",
"discography": {
"singlesAndEps": "Singles et EP",
"live": "Live",
"compilations": "Compilations"
}
},
"watchTogether": {
"title": "Regarder ensemble",
@@ -1442,6 +1589,13 @@
"host": "Hôte",
"hostBadge": "HÔTE",
"youAreHost": "Vous êtes l'hôte",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Regarder avec d'autres personnes",
"endSession": "Terminer la session",
"leaveSession": "Quitter la session",
@@ -1484,7 +1638,13 @@
"removeRoom": "Supprimer",
"guestSwitchUnavailable": "Impossible de changer — serveur indisponible pour la synchronisation",
"guestSwitchFailed": "Impossible de changer — contenu introuvable sur ce serveur",
"defaultDisplayName": "Utilisateur"
"defaultDisplayName": "Utilisateur",
"errors": {
"timedOut": "Le serveur relais n’a pas répondu à temps",
"connectionLost": "La connexion s’est fermée avant que la session ne soit prête",
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
"sessionEnded": "L’hôte a mis fin à la session"
}
},
"downloads": {
"title": "Téléchargements",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Utilisez votre appareil mobile pour contrôler cette application",
"startServer": "Démarrer le serveur",
"stopServer": "Arrêter le serveur",
"minimize": "Réduire"
"minimize": "Réduire",
"manualAddressHint": "Adresse de connexion manuelle :"
},
"pairing": {
"discoveryDescription": "Les appareils Plezy avec le même compte Plex apparaissent ici",
@@ -1726,6 +1887,7 @@
"decoder": "Décodeur",
"rawDecoder": "Décodeur brut",
"tunneling": "Tunnel",
"passthrough": "Transmission directe",
"aspect": "Format",
"rotation": "Rotation",
"dvSource": "Source DV",
@@ -1857,9 +2019,7 @@
"producer": "Producteur",
"country": "Pays",
"collection": "Collection",
"label": "Label",
"style": "Style",
"mood": "Ambiance"
"label": "Label"
},
"matchScreen": {
"match": "Associer...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Partiellement disponible",
"statusRequested": "Demandé",
"statusProcessing": "En cours de traitement",
"statusBlocklisted": "Sur la liste de blocage",
"couldNotReach": "Impossible de joindre ${url} : ${error}",
"noInstanceAtUrl": "Aucune instance Seerr à l’adresse ${url} (HTTP ${status})",
"invalidUrl": "Saisissez une adresse de serveur comme https://seerr.example.com",
"quickConnectUnsupported": "Cette instance Seerr ne prend pas en charge Quick Connect. Elle nécessite Seerr 3.4 ou version ultérieure.",
"notInitialized": "La configuration initiale de cette instance Seerr n’est pas terminée",
"noPlexTokenForReauth": "Aucun jeton Plex n’est disponible pour se reconnecter",
"noStoredCredentials": "Aucun identifiant enregistré n’est disponible pour se reconnecter",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Services",
"hubSubtitle": "Synchronisez votre progression et demandez de nouveaux titres.",
"integrations": "Intégrations",
"notConnected": "Non connecté",
"connectedAs": "Connecté en tant que @${username}",
"scrobble": "Suivre la progression automatiquement",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Réutiliser la connexion d'un autre profil. Les profils protégés par PIN exigent un PIN.",
"invalidCredentials": "Nom d’utilisateur ou mot de passe incorrect",
"authResponseNotJson": "La réponse d’authentification n’était pas au format JSON valide",
"authResponseIncomplete": "La réponse de connexion du serveur était incomplète",
"quickConnectRejected": "Quick Connect a été refusé par le serveur",
"quickConnectNotJson": "La réponse de Quick Connect n’était pas au format JSON valide",
"quickConnectMissingFields": "Il manque un code ou un secret dans la réponse de Quick Connect",
+191 -26
View File
@@ -12,6 +12,7 @@
"useBrowser": "Böngésző használata",
"or": "vagy",
"connectToMediaBrowser": "Csatlakozás a(z) ${product} szolgáltatáshoz",
"quickConnect": "Quick Connect",
"useQuickConnect": "Quick Connect használata",
"quickConnectInstructions": "Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.",
"quickConnectWaiting": "Várakozás a jóváhagyásra…",
@@ -53,6 +54,7 @@
"mute": "Némítás",
"ok": "OK",
"off": "Ki",
"options": "Lehetőségek",
"seasonNumber": "${number}. évad",
"episodeNumberTitle": "${number}. epizód - ${title}",
"chapterNumber": "${number}. fejezet",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Film",
"show": "Sorozat",
"season": "Évad",
"episode": "Epizód",
"artist": "Előadó",
"album": "Album",
"track": "Szám",
"collection": "Gyűjtemény",
"playlist": "Lejátszási lista",
"clip": "Klip",
"photo": "Fénykép",
"folder": "Mappa"
}
},
"screens": {
"licenses": "Licencek",
@@ -127,6 +143,10 @@
"displayScale": "Kijelző méretezése",
"compact": "Kompakt",
"comfortable": "Kényelmes",
"gridSpacing": "Rácstávolság",
"gridSpacingTight": "Sűrű",
"gridSpacingNormal": "Normál",
"gridSpacingSpacious": "Tágas",
"tvCornerSpotlightBackdrop": "Sarokban megjelenő kiemelt háttérkép",
"tvCornerSpotlightBackdropDescription": "A kiemelt háttérkép megjelenítése a jobb felső sarokban a teljes képernyő helyett",
"viewMode": "Nézetmód",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Hardveres dekódolás",
"hardwareDecodingDescription": "Hardveres gyorsítás használata, ha elérhető",
"bufferSize": "Puffer mérete",
"bufferSizeMB": "${size} MB",
"bufferSizeAuto": "Automatikus (ajánlott)",
"bufferSizeWarning": "${heap} MB memória érhető el. A(z) ${size} MB méretű puffer befolyásolhatja a lejátszást.",
"playbackBuffer": "Lejátszási puffer",
"playbackBufferAuto": "Automatikus (ajánlott)",
"playbackBufferLarge": "Nagy",
"playbackBufferExtraLarge": "Extra nagy",
"playbackBufferDescription": "Több puffert használ instabil kapcsolatok ellen. A pufferméret is korlátozza.",
"defaultQualityTitle": "Alapértelmezett minőség",
"cellularQualityTitle": "Alapértelmezett minőség mobilhálózaton",
"cellularQualitySameAsDefault": "Ugyanaz, mint az alapértelmezett minőség",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Zene minősége",
"subtitleStyling": "Feliratok stílusa",
"subtitleStylingDescription": "Feliratok megjelenésének testreszabása",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Hang- és feliratválasztások megjegyzése címenként",
"followServerTrackSelections": "A szerver epizódonkénti sávválasztásának használata",
"followServerTrackSelectionsDescription": "Epizódváltáskor a szerveren kiválasztott hang és felirat lép életbe az aktuális választás átvitele helyett",
"resumeMusicOnLaunch": "Zenei munkamenet megjegyzése",
"resumeMusicOnLaunchDescription": "Az alkalmazás indításakor az utolsó szám szüneteltetve nyílik meg ott, ahol abbamaradt",
"showChapterMarkersOnTimeline": "Fejezetjelölők megjelenítése az idősávon",
"showChapterMarkersOnTimelineDescription": "Az idősáv felosztása a fejezetek határainál",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Különkiadások epizódsorrendben",
"specialsOrderingDescription": "A különkiadások helye a sorozat lejátszási sorrendjében",
"specialsOrderingServer": "Szerver szerinti sorrend",
"specialsOrderingAirDate": "Adás dátuma szerint közbeszúrva",
"specialsOrderingLast": "A normál évadok után",
"clickVideoTogglesPlayback": "Kattintás a videóra a lejátszás/szünet váltásához",
"clickVideoTogglesPlaybackDescription": "A videóra kattintva vált a lejátszás/szünet, a vezérlők megjelenítése helyett.",
"videoPlayerControls": "Videólejátszó vezérlői",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "A billentyűparancs már hozzá van rendelve a következőhöz: ${action}",
"shortcutUpdated": "Billentyűparancs frissítve a következőhöz: ${action}",
"saveFailed": "Nem sikerült menteni a módosításokat. Próbáld újra.",
"autoSkip": "Automatikus átugrás",
"autoPlayAndSkip": "Automatikus lejátszás és átugrás",
"autoPlayNextEpisode": "Következő epizód automatikus lejátszása",
"autoPlayNextEpisodeDescription": "A következő epizód automatikus elindítása, amikor az aktuális véget ér",
"playNextCountdown": "Visszaszámlálás a következő epizódig",
"playNextCountdownImmediate": "Azonnali lejátszás",
"autoSkipIntro": "Intró automatikus átugrása",
"autoSkipIntroDescription": "Az intrójelölők automatikus átugrása néhány másodperc után",
"autoSkipCredits": "Stáblista automatikus átugrása",
@@ -291,6 +317,8 @@
"autoPipDescription": "Lejátszás közben az alkalmazás elhagyásakor automatikusan kép a képben módra vált",
"matchContentFrameRate": "Képkockasebesség illesztése a tartalomhoz",
"matchContentFrameRateDescription": "A kijelző frissítési frekvenciájának igazítása a videóhoz",
"matchContentResolution": "Igazítás a tartalom felbontásához",
"matchContentResolutionDescription": "A kijelzőt a videó natív felbontására váltja, hogy a felskálázást a tévé végezze. Lejátszás közben a menük és a feliratok is felskálázódnak",
"matchRefreshRate": "Frissítési frekvencia illesztése",
"matchRefreshRateDescription": "A kijelző frissítési frekvenciájának igazítása teljes képernyőn",
"matchDynamicRange": "Dinamikatartomány illesztése",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása",
"dvConversionDv81Description": "A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re",
"dvConversionHevcStripDescription": "A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés",
"deinterlace": "Deinterlacing",
"deinterlaceDescription": "Fésűfog-műtermékek eltávolítása a váltott soros videóból (csak mpv lejátszóval)",
"requireProfileSelectionOnOpen": "Profil kérése az alkalmazás megnyitásakor",
"requireProfileSelectionOnOpenDescription": "Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod",
"forceTvMode": "TV-mód kényszerítése",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "A Plex Discover és a csatlakoztatott követők tartalmait megjelenítő Felfedezés lap megjelenítése",
"liveTvDefaultFavorites": "Kedvenc csatornák alapértelmezés szerint",
"liveTvDefaultFavoritesDescription": "Csak a kedvenc csatornák megjelenítése az Élő TV megnyitásakor",
"general": "Általános",
"generalDescription": "Nyelv, indítás és ablakviselkedés",
"languageAndRegion": "Nyelv és régió",
"startup": "Indítás",
"display": "Kijelző",
"libraryAndCards": "Könyvtár és kártyák",
"homeScreen": "Kezdőképernyő",
"navigation": "Navigáció",
"window": "Ablak",
"content": "Tartalom",
"liveTv": "Élő TV",
"player": "Lejátszó",
"subtitlesAndConfig": "Feliratok és konfiguráció",
"videoAndDisplay": "Videó és kijelző",
"audio": "Hang",
"quality": "Minőség",
"subtitles": "Feliratok",
"seekAndTiming": "Tekerés és időzítés",
"behavior": "Viselkedés",
"gestures": "Mozdulatok",
"gestureBrightnessSwipe": "Fényerő-húzás",
"gestureBrightnessSwipeDescription": "Húzd felfelé vagy lefelé a bal szélén a fényerő beállításához",
"gestureVolumeSwipe": "Hangerő-húzás",
"gestureVolumeSwipeDescription": "Húzd felfelé vagy lefelé a jobb szélén a hangerő beállításához",
"gesturePinchToZoom": "Csípés a nagyításhoz",
"gesturePinchToZoomDescription": "Csípj a videóra a nagyításhoz vagy kicsinyítéshez",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Vezérlők",
"rememberPlayerChanges": "Lejátszó módosításainak megjegyzése",
"rememberPlayerChangesDescription": "A lejátszás közben végzett módosítások mentési és újbóli alkalmazási helye",
"scopePlaybackSpeed": "Lejátszási sebesség",
"scopeShaderPreset": "Shader-előbeállítás",
"scopeAspectRatio": "Képarány",
"scopeSyncOffsets": "Hang- és feliratszinkronizálás",
"playerScopeOff": "Ne mentse",
"playerScopeGlobal": "Mindenhol",
"playerScopeLibrary": "Könyvtáronként",
"playerScopeTitle": "Sorozatonként vagy filmenként",
"exportDialogTitle": "Plezy-beállítások exportálása"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Hang",
"subtitlesLabel": "Feliratok",
"resetToZero": "Visszaállítás 0 ms-ra",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label}: későbbre állítva",
"playsEarlier": "${label}: korábbra állítva",
"noOffset": "Nincs eltolás",
"letterbox": "Fekete sávok",
"fillScreen": "Képernyő kitöltése",
"stretch": "Nyújtás",
@@ -670,6 +723,7 @@
"notSupported": "Az eszköz nem támogatja a kép a képben módot",
"voSwitchFailed": "Nem sikerült átváltani a videókimenetet a kép a képben módhoz",
"failed": "Nem sikerült elindítani a kép a képben módot",
"prepareFailed": "Nem sikerült előkészíteni a kép a képben módot",
"unknown": "Hiba történt: ${error}"
},
"chapters": "Fejezetek",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "A(z) ${product} kapcsolat szerkesztése",
"editMediaBrowserIntro": "Adjon hozzá vagy távolítson el URL-eket a(z) ${serverName} esetén. A Plezy a legkisebb késleltetésű, elérhető URL-t fogja használni."
},
"accountPreferences": {
"sectionTitle": "Fiókbeállítások",
"hubSubtitleSingle": "Hang-, felirat- és könyvtárbeállítások mentve a(z) ${account} fiókra",
"hubSubtitleMultiple": "Hang-, felirat- és könyvtárbeállítások mentve ${count} fiókra",
"pickAccount": "Minden fiók a saját beállításait tárolja. Válaszd ki a szerkesztendőt.",
"storedOnAccount": "Ezek a beállítások magán a fiókon tárolódnak, így minden bejelentkezett alkalmazás használja őket — beleértve a Plezyt a többi eszközödön.",
"noAccounts": "Nincs beállítható fiók",
"noAccountsHint": "Jelentkezz be a Plexbe, vagy csatlakoztass egy Jellyfin- vagy Emby-szervert, és az azon a fiókon tárolt beállítások itt jelennek meg.",
"unavailable": "Ez a fiók nem érhető el",
"loadFailed": "Nem sikerült betölteni ezeket a beállításokat",
"noPreference": "Nincs preferencia",
"notSet": "Nincs beállítva",
"groups": {
"audioAndSubtitles": "Hang és feliratok",
"libraryDisplay": "Könyvtár",
"personalMedia": "Személyes média"
},
"preferredAudioLanguage": "Elsődleges hangnyelv",
"autoSelectAudio": "Hangsáv kiválasztása nyelv alapján",
"autoSelectAudioDescription": "Ki kapcsolásnál a fájl által alapértelmezettként megjelölt hangsáv marad.",
"preferredSubtitleLanguage": "Elsődleges feliratnyelv",
"subtitleMode": "Feliratok bekapcsolása",
"subtitleModes": {
"none": "Kézzel kiválasztva",
"noneDescription": "Soha ne kapcsolja be magától a feliratokat.",
"defaultMode": "A sáv jelzőinek követése",
"defaultModeDescription": "Az egyes feliratsávokon tárolt alapértelmezett és kényszerített jelzők használata.",
"always": "Mindig engedélyezve",
"alwaysDescription": "Feliratsáv bekapcsolása az elsődleges nyelven, ha van ilyen.",
"onlyForced": "Csak kényszerített feliratok",
"onlyForcedDescription": "Csak a kényszerítettként megjelölt sávok betöltése.",
"smart": "Idegen nyelvű hang esetén megjelenítve",
"smartDescription": "Feliratok bekapcsolása csak akkor, ha a hang más nyelven szól."
},
"subtitleAccessibility": "SDH-feliratok",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Nem SDH-feliratok előnyben részesítése",
"preferSdh": "SDH-feliratok előnyben részesítése",
"onlySdh": "Csak SDH-feliratok",
"onlyNonSdh": "Csak nem SDH-feliratok"
},
"forcedSubtitles": "Kényszerített feliratok",
"forcedSubtitleOptions": {
"preferNonForced": "Nem kényszerített feliratok előnyben részesítése",
"preferForced": "Kényszerített feliratok előnyben részesítése",
"onlyForced": "Csak kényszerített feliratok",
"onlyNonForced": "Csak nem kényszerített feliratok"
},
"displayMissingEpisodes": "Hiányzó epizódok megjelenítése",
"displayMissingEpisodesDescription": "Azoknak az epizódoknak a listázása, amelyeket a szerver ismer, de nincs hozzájuk fájl.",
"hidePlayedInLatest": "Megnézett elemek elrejtése a Legújabbak között",
"hidePlayedInLatestDescription": "A már megnézett elemek kimaradnak a szerver Legújabbak soraiból.",
"displayCollectionsView": "Gyűjtemények nézet megjelenítése",
"displayCollectionsViewDescription": "A szerver Gyűjtemények nézetének megjelenítése a könyvtárak mellett.",
"rewatchingInNextUp": "Újranézett sorozatok megtartása a Következikben",
"rewatchingInNextUpDescription": "Ha befejezel egy sorozatot, és újra elkezded, a Következik az újranézést követi, ahelyett hogy eldobná a sorozatot.",
"watchedIndicator": "Megtekintett jelzések",
"watchedIndicatorOptions": {
"none": "Soha",
"moviesAndShows": "Filmek és TV-sorozatok",
"movies": "Csak filmek",
"shows": "Csak TV-sorozatok"
},
"mediaReviewsVisibility": "Értékelések és kritikák",
"mediaReviewsOptions": {
"usersAndCritics": "Felhasználók és kritikusok",
"usersOnly": "Csak felhasználók",
"criticsOnly": "Csak kritikusok",
"nobody": "Rejtett"
}
},
"discover": {
"title": "Felfedezés",
"noContentAvailable": "Nincs elérhető tartalom",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "Nem találhatók szerverek ehhez a fiókhoz: ${username} (${email})",
"failedToLoadServers": "Nem sikerült a szerverek betöltése: ${error}"
"failedToLoadServers": "Nem sikerült a szerverek betöltése: ${error}",
"noValidServers": "Nem található használható szerver ehhez a fiókhoz"
},
"hubDetail": {
"title": "Cím",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} népszerű",
"rankAiring": "#${n} adásban",
"rankRated": "#${n} értékelt",
"rankFavorited": "#${n} kedvenc",
"rankTrending": "#${n} felkapott",
"rankSeasonal": "#${n} a(z) ${season} évadban",
"watchingNow": "${n} nézi",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Ismeretlen csatorna",
"live": "ÉLŐ",
"reloadGuide": "Műsorújság újratöltése",
"searchGuide": "Keresés a műsorújságban",
"searchHint": "Csatornák és műsorok keresése",
"searchNoResults": "Nincs találat a következőre: \"${query}\"",
"channelsSection": "Csatornák",
"programsSection": "Műsorok",
"now": "Most",
"today": "Ma",
"tomorrow": "Holnap",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Műsorújság frissítése kérve",
"rulesProcessRequested": "Szabályok újraértékelése kérve",
"recordShow": "Műsor rögzítése",
"recordSettings": {
"startEarly": "Korábbi kezdés (másodperc)",
"endLate": "Későbbi befejezés (másodperc)",
"newOnly": "Csak új epizódok",
"anyChannel": "Felvétel bármelyik csatornáról",
"anyTime": "Felvétel bármikor",
"skipInLibrary": "A könyvtárban már meglévő epizódok kihagyása",
"keepUpTo": "Megtartandó epizódok",
"keepUpToHint": "A 0 minden epizódot megtart"
},
"startingInMinutes": "${minutes} perc múlva kezdődik",
"dayAtTime": "${day}, ${time}",
"invalidPlaybackData": "A(z) ${product} érvénytelen Élő TV-lejátszási adatokat adott vissza",
@@ -1416,7 +1556,14 @@
"repeatAll": "Összes ismétlése",
"repeatOne": "Egy szám ismétlése",
"instantMixNoServer": "Nem érhető el szerver az azonnali mixhez",
"noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}"
"instantMixFailed": "Nem sikerült betölteni az azonnali mixet",
"instantMixEmpty": "Az azonnali mix nem adott vissza zeneszámokat",
"noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}",
"discography": {
"singlesAndEps": "Kislemezek és EP-k",
"live": "Élő",
"compilations": "Válogatások"
}
},
"watchTogether": {
"title": "Watch Together (Közös nézés)",
@@ -1442,6 +1589,13 @@
"host": "Házigazda",
"hostBadge": "HÁZIGAZDA",
"youAreHost": "Te vagy a házigazda",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Nézés másokkal",
"endSession": "Munkamenet befejezése",
"leaveSession": "Munkamenet elhagyása",
@@ -1484,7 +1638,13 @@
"removeRoom": "Eltávolítás",
"guestSwitchUnavailable": "Nem sikerült a váltás — a szerver nem érhető el szinkronizáláshoz",
"guestSwitchFailed": "Nem sikerült a váltás — a tartalom nem található ezen a szerveren",
"defaultDisplayName": "Felhasználó"
"defaultDisplayName": "Felhasználó",
"errors": {
"timedOut": "A relészerver nem válaszolt időben",
"connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna",
"invalidRelayResponse": "A relészerver váratlan választ küldött",
"sessionEnded": "A házigazda befejezte a munkamenetet"
}
},
"downloads": {
"title": "Letöltések",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Használd a mobileszközödet az alkalmazás vezérléséhez",
"startServer": "Szerver indítása",
"stopServer": "Szerver leállítása",
"minimize": "Kis méret"
"minimize": "Kis méret",
"manualAddressHint": "Kézi kapcsolódási cím:"
},
"pairing": {
"discoveryDescription": "Az azonos Plex-fiókot használó Plezy-eszközök itt jelennek meg",
@@ -1726,6 +1887,7 @@
"decoder": "Dekóder",
"rawDecoder": "Nyers dekóder",
"tunneling": "Alagutazás",
"passthrough": "Passthrough",
"aspect": "Méretarány",
"rotation": "Forgatás",
"dvSource": "DV-forrás",
@@ -1857,9 +2019,7 @@
"producer": "Producer",
"country": "Ország",
"collection": "Gyűjtemény",
"label": "Kiadó",
"style": "Stílus",
"mood": "Hangulat"
"label": "Kiadó"
},
"matchScreen": {
"match": "Párosítás...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Részben elérhető",
"statusRequested": "Igényelve",
"statusProcessing": "Feldolgozás alatt",
"statusBlocklisted": "Tiltólistán",
"couldNotReach": "Nem sikerült elérni ezt: ${url}: ${error}",
"noInstanceAtUrl": "Nem található Seerr-példány ezen a címen: ${url} (HTTP ${status})",
"invalidUrl": "Adj meg egy szervercímet, például: https://seerr.example.com",
"quickConnectUnsupported": "Ez a Seerr-példány nem támogatja a Quick Connectet. Seerr 3.4 vagy újabb verzió szükséges.",
"notInitialized": "Ennek a Seerr-példánynak a kezdeti beállítása még nem fejeződött be",
"noPlexTokenForReauth": "Nem áll rendelkezésre Plex-token az újbóli bejelentkezéshez",
"noStoredCredentials": "Nem állnak rendelkezésre mentett hitelesítő adatok az újbóli bejelentkezéshez",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Szolgáltatások",
"hubSubtitle": "Megtekintési haladás szinkronizálása és új tartalmak igénylése.",
"integrations": "Integrációk",
"notConnected": "Nincs csatlakoztatva",
"connectedAs": "@${username} néven csatlakoztatva",
"scrobble": "Haladás automatikus követése",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges.",
"invalidCredentials": "Érvénytelen felhasználónév vagy jelszó",
"authResponseNotJson": "A hitelesítési válasz nem érvényes JSON",
"authResponseIncomplete": "A szerver bejelentkezési válasza hiányos volt",
"quickConnectRejected": "A szerver elutasította a Quick Connect-kérést",
"quickConnectNotJson": "A Quick Connect válasza nem érvényes JSON",
"quickConnectMissingFields": "A Quick Connect válaszából hiányzik a kód vagy a titkos kulcs",
+192 -27
View File
@@ -12,6 +12,7 @@
"useBrowser": "Usa il browser",
"or": "o",
"connectToMediaBrowser": "Connettiti a ${product}",
"quickConnect": "Quick Connect",
"useQuickConnect": "Usa Quick Connect",
"quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.",
"quickConnectWaiting": "In attesa di approvazione…",
@@ -53,6 +54,7 @@
"mute": "Disattiva audio",
"ok": "OK",
"off": "Disattivato",
"options": "Opzioni",
"seasonNumber": "Stagione ${number}",
"episodeNumberTitle": "Episodio ${number} - ${title}",
"chapterNumber": "Capitolo ${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/D",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Film",
"show": "Serie",
"season": "Stagione",
"episode": "Episodio",
"artist": "Artista",
"album": "Album",
"track": "Traccia",
"collection": "Raccolta",
"playlist": "Playlist",
"clip": "Clip",
"photo": "Foto",
"folder": "Cartella"
}
},
"screens": {
"licenses": "Licenze",
@@ -127,6 +143,10 @@
"displayScale": "Scala di visualizzazione",
"compact": "Compatta",
"comfortable": "Comoda",
"gridSpacing": "Spaziatura della griglia",
"gridSpacingTight": "Ristretta",
"gridSpacingNormal": "Normale",
"gridSpacingSpacious": "Ampia",
"tvCornerSpotlightBackdrop": "Sfondo in evidenza nell'angolo",
"tvCornerSpotlightBackdropDescription": "Mostra l'immagine in evidenza nell'angolo in alto a destra anziché a schermo intero",
"viewMode": "Modalità di visualizzazione",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Decodifica hardware",
"hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile",
"bufferSize": "Dimensione buffer",
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automatica (consigliata)",
"bufferSizeWarning": "${heap}MB di memoria disponibile. Un buffer di ${size}MB può influire sulla riproduzione.",
"playbackBuffer": "Buffer di riproduzione",
"playbackBufferAuto": "Auto (consigliato)",
"playbackBufferLarge": "Grande",
"playbackBufferExtraLarge": "Extra grande",
"playbackBufferDescription": "Bufferizza di più contro connessioni instabili. Limitato anche dalla dimensione del buffer.",
"defaultQualityTitle": "Qualità predefinita",
"cellularQualityTitle": "Qualità predefinita sulla rete mobile",
"cellularQualitySameAsDefault": "Come la qualità predefinita",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Qualità musicale",
"subtitleStyling": "Stile sottotitoli",
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Ricorda le scelte di audio e sottotitoli per ogni titolo",
"followServerTrackSelections": "Usa le tracce selezionate sul server per ogni episodio",
"followServerTrackSelectionsDescription": "Al cambio di episodio applica l'audio e i sottotitoli selezionati sul server invece di mantenere la scelta corrente",
"resumeMusicOnLaunch": "Ricorda la sessione musicale",
"resumeMusicOnLaunchDescription": "All'avvio dell'app riapri l'ultimo brano in pausa dal punto in cui era rimasto",
"showChapterMarkersOnTimeline": "Mostra i marcatori dei capitoli sulla barra di avanzamento",
"showChapterMarkersOnTimelineDescription": "Segmenta la barra di avanzamento ai confini dei capitoli",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Speciali nell'ordine degli episodi",
"specialsOrderingDescription": "Dove vengono riprodotti gli speciali nell'ordine di visione di una serie",
"specialsOrderingServer": "Segui l'ordine del server",
"specialsOrderingAirDate": "Intercalati per data di trasmissione",
"specialsOrderingLast": "Dopo le stagioni normali",
"clickVideoTogglesPlayback": "Fai clic sul video per alternare riproduzione e pausa",
"clickVideoTogglesPlaybackDescription": "Fai clic sul video per riprodurre o mettere in pausa anziché mostrare i controlli.",
"videoPlayerControls": "Controlli del lettore video",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}",
"shortcutUpdated": "Scorciatoia aggiornata per ${action}",
"saveFailed": "Impossibile salvare le modifiche. Riprova.",
"autoSkip": "Salto automatico",
"autoPlayAndSkip": "Riproduzione automatica e salti",
"autoPlayNextEpisode": "Riproduci automaticamente l'episodio successivo",
"autoPlayNextEpisodeDescription": "Avvia automaticamente l'episodio successivo quando termina quello in riproduzione",
"playNextCountdown": "Conto alla rovescia del successivo",
"playNextCountdownImmediate": "Riproduci subito",
"autoSkipIntro": "Salta automaticamente la sigla iniziale",
"autoSkipIntroDescription": "Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi",
"autoSkipCredits": "Salta automaticamente i titoli di coda",
@@ -291,6 +317,8 @@
"autoPipDescription": "Attiva automaticamente la modalità Picture-in-Picture quando esci dall'app durante la riproduzione",
"matchContentFrameRate": "Adatta la frequenza dei fotogrammi",
"matchContentFrameRateDescription": "Adatta la frequenza di aggiornamento dello schermo al contenuto video",
"matchContentResolution": "Adatta alla risoluzione del contenuto",
"matchContentResolutionDescription": "Passa lo schermo alla risoluzione nativa del video, così è la TV a occuparsi dell'upscaling. Durante la riproduzione vengono ridimensionati anche menu e sottotitoli",
"matchRefreshRate": "Adatta la frequenza di aggiornamento",
"matchRefreshRateDescription": "Adatta la frequenza di aggiornamento dello schermo in modalità a schermo intero",
"matchDynamicRange": "Adatta la gamma dinamica",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV",
"dvConversionDv81Description": "Forza la conversione RPU diretta al profilo Dolby Vision 8.1",
"dvConversionHevcStripDescription": "Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC",
"deinterlace": "Deinterlacciamento",
"deinterlaceDescription": "Rimuove gli artefatti a pettine dai video interlacciati (solo lettore mpv)",
"requireProfileSelectionOnOpen": "Chiedi di scegliere il profilo all'apertura",
"requireProfileSelectionOnOpenDescription": "Mostra la selezione del profilo ogni volta che l'app viene aperta",
"forceTvMode": "Forza modalità TV",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Visualizza la scheda Esplora con contenuti di Esplora di Plex e dei tracker collegati",
"liveTvDefaultFavorites": "Apri sui canali preferiti",
"liveTvDefaultFavoritesDescription": "Mostra solo i canali preferiti quando apri la TV in diretta",
"general": "Generali",
"generalDescription": "Lingua, avvio e comportamento della finestra",
"languageAndRegion": "Lingua e regione",
"startup": "Avvio",
"display": "Schermo",
"libraryAndCards": "Libreria e schede",
"homeScreen": "Schermata iniziale",
"navigation": "Navigazione",
"window": "Finestra",
"content": "Contenuti",
"liveTv": "TV in diretta",
"player": "Lettore",
"subtitlesAndConfig": "Sottotitoli e impostazioni",
"videoAndDisplay": "Video e schermo",
"audio": "Audio",
"quality": "Qualità",
"subtitles": "Sottotitoli",
"seekAndTiming": "Avanzamento e tempi",
"behavior": "Comportamento",
"gestures": "Gesti",
"gestureBrightnessSwipe": "Scorrimento per la luminosità",
"gestureBrightnessSwipeDescription": "Scorri verso l'alto o il basso sul bordo sinistro per regolare la luminosità",
"gestureVolumeSwipe": "Scorrimento per il volume",
"gestureVolumeSwipeDescription": "Scorri verso l'alto o il basso sul bordo destro per regolare il volume",
"gesturePinchToZoom": "Pizzica per lo zoom",
"gesturePinchToZoomDescription": "Pizzica il video per ingrandire o ridurre",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Controlli",
"rememberPlayerChanges": "Ricorda le modifiche del lettore",
"rememberPlayerChangesDescription": "Dove viene salvata e riapplicata una modifica effettuata durante la riproduzione",
"scopePlaybackSpeed": "Velocità di riproduzione",
"scopeShaderPreset": "Preimpostazione shader",
"scopeAspectRatio": "Proporzioni",
"scopeSyncOffsets": "Sincronizzazione audio e sottotitoli",
"playerScopeOff": "Non salvare",
"playerScopeGlobal": "Ovunque",
"playerScopeLibrary": "Per libreria",
"playerScopeTitle": "Per serie o film",
"exportDialogTitle": "Esporta le impostazioni di Plezy"
},
"search": {
@@ -436,7 +494,7 @@
"interlaced": "Interlacciato",
"anamorphic": "Anamorfico",
"referenceFrames": "Frame di riferimento",
"dynamicRange": "Dynamic Range",
"dynamicRange": "Gamma dinamica",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision Level",
"dolbyVisionVersion": "Dolby Vision Version",
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Sottotitoli",
"resetToZero": "Ripristina a 0 ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label}: riproduzione ritardata",
"playsEarlier": "${label}: riproduzione anticipata",
"noOffset": "Nessun ritardo",
"letterbox": "Letterbox",
"fillScreen": "Riempi lo schermo",
"stretch": "Allunga",
@@ -670,6 +723,7 @@
"notSupported": "Questo dispositivo non supporta la modalità Picture-in-Picture",
"voSwitchFailed": "Impossibile cambiare l'uscita video per Picture-in-Picture",
"failed": "Impossibile avviare la modalità Picture-in-Picture",
"prepareFailed": "Impossibile preparare la modalità Picture-in-Picture",
"unknown": "Si è verificato un errore: ${error}"
},
"chapters": "Capitoli",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "Modifica connessione ${product}",
"editMediaBrowserIntro": "Aggiungi o rimuovi gli URL per ${serverName}. Plezy userà l'URL raggiungibile con la latenza più bassa."
},
"accountPreferences": {
"sectionTitle": "Preferenze dell'account",
"hubSubtitleSingle": "Opzioni di audio, sottotitoli e libreria salvate su ${account}",
"hubSubtitleMultiple": "Opzioni di audio, sottotitoli e libreria salvate su ${count} account",
"pickAccount": "Ogni account conserva le proprie preferenze. Scegli quale modificare.",
"storedOnAccount": "Queste opzioni vengono salvate sull'account stesso, quindi ogni app che vi accede le usa — inclusa Plezy sugli altri tuoi dispositivi.",
"noAccounts": "Nessun account da configurare",
"noAccountsHint": "Accedi a Plex oppure connetti un server Jellyfin o Emby: le preferenze salvate su quell'account appariranno qui.",
"unavailable": "Impossibile raggiungere questo account",
"loadFailed": "Impossibile caricare queste preferenze",
"noPreference": "Nessuna preferenza",
"notSet": "Non impostato",
"groups": {
"audioAndSubtitles": "Audio e sottotitoli",
"libraryDisplay": "Libreria",
"personalMedia": "Media personali"
},
"preferredAudioLanguage": "Lingua audio preferita",
"autoSelectAudio": "Scegli l'audio in base alla lingua",
"autoSelectAudioDescription": "Se disattivata, viene mantenuta la traccia audio che il file indica come predefinita.",
"preferredSubtitleLanguage": "Lingua dei sottotitoli preferita",
"subtitleMode": "Attiva i sottotitoli",
"subtitleModes": {
"none": "Selezionati manualmente",
"noneDescription": "Non attivare mai i sottotitoli automaticamente.",
"defaultMode": "Segui le flag della traccia",
"defaultModeDescription": "Usa le flag predefinite e forzate salvate su ogni traccia di sottotitoli.",
"always": "Sempre attivi",
"alwaysDescription": "Attiva una traccia di sottotitoli nella lingua preferita quando disponibile.",
"onlyForced": "Solo sottotitoli forzati",
"onlyForcedDescription": "Carica solo le tracce contrassegnate come forzate.",
"smart": "Mostrati con audio straniero",
"smartDescription": "Attiva i sottotitoli solo quando l'audio è in un'altra lingua."
},
"subtitleAccessibility": "Sottotitoli SDH",
"subtitleAccessibilityOptions": {
"preferNonSdh": "Preferisci i sottotitoli non SDH",
"preferSdh": "Preferisci i sottotitoli SDH",
"onlySdh": "Solo sottotitoli SDH",
"onlyNonSdh": "Solo sottotitoli non SDH"
},
"forcedSubtitles": "Sottotitoli forzati",
"forcedSubtitleOptions": {
"preferNonForced": "Preferisci i sottotitoli non forzati",
"preferForced": "Preferisci i sottotitoli forzati",
"onlyForced": "Solo sottotitoli forzati",
"onlyNonForced": "Solo sottotitoli non forzati"
},
"displayMissingEpisodes": "Mostra gli episodi mancanti",
"displayMissingEpisodesDescription": "Elenca gli episodi noti al server ma senza un file associato.",
"hidePlayedInLatest": "Nascondi gli elementi visti da Aggiunti di recente",
"hidePlayedInLatestDescription": "Esclude dagli Aggiunti di recente del server gli elementi che hai già visto.",
"displayCollectionsView": "Mostra la vista Raccolte",
"displayCollectionsViewDescription": "Mostra la vista Raccolte del server insieme alle tue librerie.",
"rewatchingInNextUp": "Mantieni le serie riviste in Prossimi episodi",
"rewatchingInNextUpDescription": "Quando finisci una serie e la ricominci, Prossimi episodi segue la nuova visione invece di rimuovere la serie.",
"watchedIndicator": "Indicatori di visione",
"watchedIndicatorOptions": {
"none": "Mai",
"moviesAndShows": "Film e serie TV",
"movies": "Solo film",
"shows": "Solo serie TV"
},
"mediaReviewsVisibility": "Valutazioni e recensioni",
"mediaReviewsOptions": {
"usersAndCritics": "Utenti e critici",
"usersOnly": "Solo utenti",
"criticsOnly": "Solo critici",
"nobody": "Nascoste"
}
},
"discover": {
"title": "Esplora",
"noContentAvailable": "Nessun contenuto disponibile",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "Nessun server trovato per ${username} (${email})",
"failedToLoadServers": "Impossibile caricare i server: ${error}"
"failedToLoadServers": "Impossibile caricare i server: ${error}",
"noValidServers": "Nessun server utilizzabile trovato per questo account"
},
"hubDetail": {
"title": "Titolo",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} popolari",
"rankAiring": "#${n} in onda",
"rankRated": "#${n} valutati",
"rankFavorited": "#${n} preferiti",
"rankTrending": "#${n} di tendenza",
"rankSeasonal": "#${n} in ${season}",
"watchingNow": "${n} in visione",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Canale sconosciuto",
"live": "IN DIRETTA",
"reloadGuide": "Ricarica la guida",
"searchGuide": "Cerca nella guida",
"searchHint": "Cerca canali e programmi",
"searchNoResults": "Nessuna corrispondenza per \"${query}\"",
"channelsSection": "Canali",
"programsSection": "Programmi",
"now": "Ora",
"today": "Oggi",
"tomorrow": "Domani",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Aggiornamento della guida richiesto",
"rulesProcessRequested": "Rielaborazione delle regole richiesta",
"recordShow": "Registra programma",
"recordSettings": {
"startEarly": "Inizia in anticipo (secondi)",
"endLate": "Termina in ritardo (secondi)",
"newOnly": "Solo nuovi episodi",
"anyChannel": "Registra su qualsiasi canale",
"anyTime": "Registra a qualsiasi ora",
"skipInLibrary": "Salta gli episodi già presenti nella libreria",
"keepUpTo": "Episodi da conservare",
"keepUpToHint": "0 conserva tutti gli episodi"
},
"startingInMinutes": "Inizia tra ${minutes} min",
"dayAtTime": "${day} alle ${time}",
"invalidPlaybackData": "${product} ha restituito dati di riproduzione della TV in diretta non validi",
@@ -1416,7 +1556,14 @@
"repeatAll": "Ripeti tutto",
"repeatOne": "Ripeti il brano",
"instantMixNoServer": "Nessun server disponibile per un mix istantaneo",
"noAudioUrl": "Nessun URL audio disponibile per ${track}"
"instantMixFailed": "Impossibile caricare il mix istantaneo",
"instantMixEmpty": "Il mix istantaneo non ha prodotto alcun brano",
"noAudioUrl": "Nessun URL audio disponibile per ${track}",
"discography": {
"singlesAndEps": "Singoli ed EP",
"live": "Dal vivo",
"compilations": "Compilation"
}
},
"watchTogether": {
"title": "Guarda insieme",
@@ -1442,6 +1589,13 @@
"host": "Host",
"hostBadge": "HOST",
"youAreHost": "Sei l'host",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "In visione con altri partecipanti",
"endSession": "Termina la sessione",
"leaveSession": "Abbandona la sessione",
@@ -1484,7 +1638,13 @@
"removeRoom": "Rimuovi",
"guestSwitchUnavailable": "Impossibile cambiare — server non disponibile per la sincronizzazione",
"guestSwitchFailed": "Impossibile cambiare — contenuto non trovato su questo server",
"defaultDisplayName": "Utente"
"defaultDisplayName": "Utente",
"errors": {
"timedOut": "Il server relay non ha risposto in tempo",
"connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta",
"invalidRelayResponse": "Il server relay ha inviato una risposta imprevista",
"sessionEnded": "L’host ha terminato la sessione"
}
},
"downloads": {
"title": "Download",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Usa il tuo dispositivo mobile per controllare questa app",
"startServer": "Avvia server",
"stopServer": "Arresta server",
"minimize": "Riduci"
"minimize": "Riduci",
"manualAddressHint": "Indirizzo di connessione manuale:"
},
"pairing": {
"discoveryDescription": "I dispositivi Plezy con lo stesso account Plex appaiono qui",
@@ -1726,6 +1887,7 @@
"decoder": "Decoder",
"rawDecoder": "Decoder raw",
"tunneling": "Tunneling",
"passthrough": "Passthrough",
"aspect": "Proporzioni",
"rotation": "Rotazione",
"dvSource": "Sorgente DV",
@@ -1857,9 +2019,7 @@
"producer": "Produttore",
"country": "Paese",
"collection": "Raccolta",
"label": "Etichetta",
"style": "Stile",
"mood": "Atmosfera"
"label": "Etichetta"
},
"matchScreen": {
"match": "Abbina...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Disponibile in parte",
"statusRequested": "Richiesto",
"statusProcessing": "In elaborazione",
"statusBlocklisted": "Nella lista di blocco",
"couldNotReach": "Impossibile raggiungere ${url}: ${error}",
"noInstanceAtUrl": "Nessuna istanza Seerr all'indirizzo ${url} (HTTP ${status})",
"invalidUrl": "Inserisci un indirizzo del server come https://seerr.example.com",
"quickConnectUnsupported": "Questa istanza Seerr non supporta Quick Connect. È necessaria la versione 3.4 o successiva di Seerr.",
"notInitialized": "Questa istanza Seerr non ha completato la configurazione iniziale",
"noPlexTokenForReauth": "Nessun token Plex disponibile per effettuare nuovamente l'accesso",
"noStoredCredentials": "Nessuna credenziale salvata disponibile per effettuare nuovamente l'accesso",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Servizi",
"hubSubtitle": "Sincronizza i progressi di visione e richiedi nuovi titoli.",
"integrations": "Integrazioni",
"notConnected": "Non connesso",
"connectedAs": "Connesso come @${username}",
"scrobble": "Registra automaticamente i progressi",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN.",
"invalidCredentials": "Nome utente o password non validi",
"authResponseNotJson": "La risposta di autenticazione non era un JSON valido",
"authResponseIncomplete": "La risposta di accesso del server era incompleta",
"quickConnectRejected": "Quick Connect è stato rifiutato dal server",
"quickConnectNotJson": "La risposta di Quick Connect non era un JSON valido",
"quickConnectMissingFields": "Nella risposta di Quick Connect manca un codice o un segreto",
+191 -26
View File
@@ -12,6 +12,7 @@
"useBrowser": "ブラウザを使用",
"or": "または",
"connectToMediaBrowser": "${product}に接続",
"quickConnect": "Quick Connect",
"useQuickConnect": "Quick Connect を使う",
"quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。",
"quickConnectWaiting": "承認を待っています…",
@@ -53,6 +54,7 @@
"mute": "ミュート",
"ok": "OK",
"off": "オフ",
"options": "オプション",
"seasonNumber": "シーズン${number}",
"episodeNumberTitle": "エピソード${number} - ${title}",
"chapterNumber": "チャプター${number}",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "映画",
"show": "テレビ番組",
"season": "シーズン",
"episode": "エピソード",
"artist": "アーティスト",
"album": "アルバム",
"track": "曲",
"collection": "コレクション",
"playlist": "プレイリスト",
"clip": "クリップ",
"photo": "写真",
"folder": "フォルダ"
}
},
"screens": {
"licenses": "ライセンス",
@@ -127,6 +143,10 @@
"displayScale": "表示スケール",
"compact": "コンパクト",
"comfortable": "ゆったり",
"gridSpacing": "グリッド間隔",
"gridSpacingTight": "密",
"gridSpacingNormal": "標準",
"gridSpacingSpacious": "ゆったり",
"tvCornerSpotlightBackdrop": "画面隅の注目コンテンツ背景",
"tvCornerSpotlightBackdropDescription": "画面全体ではなく、右上隅に注目コンテンツのアートワークを表示します",
"viewMode": "表示モード",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "ハードウェアデコード",
"hardwareDecodingDescription": "利用可能な場合にハードウェアアクセラレーションを使用",
"bufferSize": "バッファサイズ",
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "自動(推奨)",
"bufferSizeWarning": "${heap}MBのメモリが利用可能です。${size}MBのバッファは再生に影響する可能性があります。",
"playbackBuffer": "再生バッファー",
"playbackBufferAuto": "自動(推奨)",
"playbackBufferLarge": "大",
"playbackBufferExtraLarge": "特大",
"playbackBufferDescription": "不安定な接続に備えてより多くのバッファーを使用します。バッファーサイズによっても制限されます。",
"defaultQualityTitle": "デフォルト画質",
"cellularQualityTitle": "モバイルデータ通信時のデフォルト画質",
"cellularQualitySameAsDefault": "デフォルト画質と同じ",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "音楽の音質",
"subtitleStyling": "字幕スタイル",
"subtitleStylingDescription": "字幕の外観をカスタマイズ",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "タイトルごとに音声と字幕の選択を記憶します",
"followServerTrackSelections": "サーバーのエピソードごとのトラック選択を使用",
"followServerTrackSelectionsDescription": "エピソード切り替え時に、現在の選択を引き継ぐ代わりにサーバーで選択された音声と字幕を適用します",
"resumeMusicOnLaunch": "音楽セッションを記憶",
"resumeMusicOnLaunchDescription": "アプリ起動時に最後の曲を停止位置で一時停止のまま開きます",
"showChapterMarkersOnTimeline": "シークバーにチャプターマーカーを表示",
"showChapterMarkersOnTimelineDescription": "チャプターの境界でシークバーを区切る",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "エピソード順にスペシャルを表示",
"specialsOrderingDescription": "シリーズの視聴順でスペシャルを再生する位置",
"specialsOrderingServer": "サーバーの順序に従う",
"specialsOrderingAirDate": "放送日順に挿入",
"specialsOrderingLast": "通常シーズンの後",
"clickVideoTogglesPlayback": "動画クリックで再生/一時停止を切替",
"clickVideoTogglesPlaybackDescription": "コントロール表示ではなく、動画クリックで再生/一時停止します。",
"videoPlayerControls": "動画プレーヤーコントロール",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "ショートカットは既に${action}に割り当てられています",
"shortcutUpdated": "${action}のショートカットを更新しました",
"saveFailed": "変更を保存できませんでした。もう一度お試しください。",
"autoSkip": "自動スキップ",
"autoPlayAndSkip": "自動再生とスキップ",
"autoPlayNextEpisode": "次のエピソードを自動再生",
"autoPlayNextEpisodeDescription": "エピソードが終了すると、次のエピソードを自動的に再生します",
"playNextCountdown": "次回再生のカウントダウン",
"playNextCountdownImmediate": "すぐに再生",
"autoSkipIntro": "イントロを自動スキップ",
"autoSkipIntroDescription": "数秒後にイントロマーカーを自動的にスキップ",
"autoSkipCredits": "クレジットを自動スキップ",
@@ -291,6 +317,8 @@
"autoPipDescription": "再生中にアプリを離れると、自動的にピクチャーインピクチャーに切り替えます",
"matchContentFrameRate": "コンテンツのフレームレートに合わせる",
"matchContentFrameRateDescription": "表示のリフレッシュレートを動画コンテンツに合わせます",
"matchContentResolution": "コンテンツの解像度に合わせる",
"matchContentResolutionDescription": "テレビ側でアップスケーリングを処理できるよう、ディスプレイを映像本来の解像度に切り替えます。再生中はメニューや字幕もアップスケールされます",
"matchRefreshRate": "リフレッシュレートを合わせる",
"matchRefreshRateDescription": "全画面時に表示のリフレッシュレートを合わせます",
"matchDynamicRange": "ダイナミックレンジを合わせる",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "ネイティブ DV7 を強制し、DV 変換の再試行を抑制します",
"dvConversionDv81Description": "Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します",
"dvConversionHevcStripDescription": "Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います",
"deinterlace": "デインターレース",
"deinterlaceDescription": "インターレース映像のくし型ノイズを除去します(mpvプレーヤーのみ)",
"requireProfileSelectionOnOpen": "アプリ起動時にプロフィールを確認",
"requireProfileSelectionOnOpenDescription": "アプリを開くたびにプロフィール選択を表示",
"forceTvMode": "TVモードを強制",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Plex Discoverと接続済みのトラッカーのコンテンツを「探す」タブに表示します",
"liveTvDefaultFavorites": "お気に入りチャンネルをデフォルトに",
"liveTvDefaultFavoritesDescription": "ライブTVを開いたときにお気に入りチャンネルのみ表示",
"general": "一般",
"generalDescription": "言語、起動、ウィンドウの動作",
"languageAndRegion": "言語と地域",
"startup": "起動",
"display": "ディスプレイ",
"libraryAndCards": "ライブラリとカード",
"homeScreen": "ホーム画面",
"navigation": "ナビゲーション",
"window": "ウィンドウ",
"content": "コンテンツ",
"liveTv": "ライブTV",
"player": "プレーヤー",
"subtitlesAndConfig": "字幕と設定",
"videoAndDisplay": "映像とディスプレイ",
"audio": "音声",
"quality": "画質",
"subtitles": "字幕",
"seekAndTiming": "シークとタイミング",
"behavior": "動作",
"gestures": "ジェスチャー",
"gestureBrightnessSwipe": "明るさスワイプ",
"gestureBrightnessSwipeDescription": "左端を上下にスワイプして明るさを調整します",
"gestureVolumeSwipe": "音量スワイプ",
"gestureVolumeSwipeDescription": "右端を上下にスワイプして音量を調整します",
"gesturePinchToZoom": "ピンチでズーム",
"gesturePinchToZoomDescription": "動画をピンチしてズームイン・アウト",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "コントロール",
"rememberPlayerChanges": "プレーヤーの変更を記憶",
"rememberPlayerChangesDescription": "再生中に行った変更を保存し、再適用する場所",
"scopePlaybackSpeed": "再生速度",
"scopeShaderPreset": "シェーダープリセット",
"scopeAspectRatio": "アスペクト比",
"scopeSyncOffsets": "音声と字幕の同期",
"playerScopeOff": "保存しない",
"playerScopeGlobal": "すべて",
"playerScopeLibrary": "ライブラリごと",
"playerScopeTitle": "作品ごと",
"exportDialogTitle": "Plezyの設定をエクスポート"
},
"search": {
@@ -596,12 +654,7 @@
"videoControls": {
"audioLabel": "音声",
"subtitlesLabel": "字幕",
"resetToZero": "0msにリセット",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label}を遅らせる",
"playsEarlier": "${label}を早める",
"noOffset": "オフセットなし",
"letterbox": "レターボックス",
"fillScreen": "画面を埋める",
"stretch": "引き延ばす",
@@ -667,6 +720,7 @@
"notSupported": "デバイスはピクチャーインピクチャーモードをサポートしていません",
"voSwitchFailed": "ピクチャーインピクチャーの映像出力切替に失敗しました",
"failed": "ピクチャーインピクチャーの開始に失敗しました",
"prepareFailed": "ピクチャーインピクチャーを準備できませんでした",
"unknown": "エラーが発生しました: ${error}"
},
"chapters": "チャプター",
@@ -878,6 +932,77 @@
"editMediaBrowserTitle": "${product}接続を編集",
"editMediaBrowserIntro": "${serverName}のURLを追加または削除します。Plezyは到達可能なURLのうち、レイテンシーが最も低いものを使用します。"
},
"accountPreferences": {
"sectionTitle": "アカウント設定",
"hubSubtitleSingle": "音声・字幕・ライブラリのオプションは${account}に保存されています",
"hubSubtitleMultiple": "音声・字幕・ライブラリのオプションは${count}個のアカウントに保存されています",
"pickAccount": "各アカウントに独自の設定が保存されています。編集するアカウントを選択してください。",
"storedOnAccount": "これらのオプションはアカウント自体に保存されるため、サインインしているすべてのアプリ(他のデバイスのPlezyを含む)で使用されます。",
"noAccounts": "設定できるアカウントがありません",
"noAccountsHint": "Plexにサインインするか、JellyfinまたはEmbyサーバーに接続すると、そのアカウントに保存された設定がここに表示されます。",
"unavailable": "このアカウントにアクセスできません",
"loadFailed": "この設定を読み込めませんでした",
"noPreference": "指定なし",
"notSet": "未設定",
"groups": {
"audioAndSubtitles": "音声と字幕",
"libraryDisplay": "ライブラリ",
"personalMedia": "個人メディア"
},
"preferredAudioLanguage": "優先音声言語",
"autoSelectAudio": "言語で音声を選択",
"autoSelectAudioDescription": "オフにすると、ファイルでデフォルトとマークされた音声トラックがそのまま使われます。",
"preferredSubtitleLanguage": "優先字幕言語",
"subtitleMode": "字幕の自動表示",
"subtitleModes": {
"none": "手動選択",
"noneDescription": "自動では字幕をオンにしません。",
"defaultMode": "トラックのフラグに従う",
"defaultModeDescription": "各字幕トラックに保存されたデフォルトと強制のフラグを使用します。",
"always": "常に有効",
"alwaysDescription": "優先言語の字幕トラックがあれば、常にオンにします。",
"onlyForced": "強制字幕のみ",
"onlyForcedDescription": "強制とマークされたトラックのみ読み込みます。",
"smart": "外国語音声時に表示",
"smartDescription": "音声が別の言語の場合にのみ字幕をオンにします。"
},
"subtitleAccessibility": "SDH字幕",
"subtitleAccessibilityOptions": {
"preferNonSdh": "非SDH字幕を優先",
"preferSdh": "SDH字幕を優先",
"onlySdh": "SDH字幕のみ",
"onlyNonSdh": "非SDH字幕のみ"
},
"forcedSubtitles": "強制字幕",
"forcedSubtitleOptions": {
"preferNonForced": "非強制字幕を優先",
"preferForced": "強制字幕を優先",
"onlyForced": "強制字幕のみ",
"onlyNonForced": "非強制字幕のみ"
},
"displayMissingEpisodes": "欠落エピソードを表示",
"displayMissingEpisodesDescription": "サーバーが把握しているがファイルがないエピソードを一覧表示します。",
"hidePlayedInLatest": "「最新」の視聴済みアイテムを非表示",
"hidePlayedInLatestDescription": "すでに視聴したアイテムは、サーバーの「最新」に表示されません。",
"displayCollectionsView": "コレクションビューを表示",
"displayCollectionsViewDescription": "ライブラリに加えて、サーバーのコレクションビューを表示します。",
"rewatchingInNextUp": "再視聴した番組を「次のエピソード」に残す",
"rewatchingInNextUpDescription": "番組を見終えた後に再び見始めると、「次のエピソード」は番組を外さずに再視聴に追従します。",
"watchedIndicator": "視聴済みマーク",
"watchedIndicatorOptions": {
"none": "しない",
"moviesAndShows": "映画とテレビ番組",
"movies": "映画のみ",
"shows": "テレビ番組のみ"
},
"mediaReviewsVisibility": "評価とレビュー",
"mediaReviewsOptions": {
"usersAndCritics": "ユーザーと批評家",
"usersOnly": "ユーザーのみ",
"criticsOnly": "批評家のみ",
"nobody": "非表示"
}
},
"discover": {
"title": "探す",
"noContentAvailable": "コンテンツがありません",
@@ -1022,7 +1147,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "${username} (${email})のサーバーが見つかりません",
"failedToLoadServers": "サーバーの読み込みに失敗しました: ${error}"
"failedToLoadServers": "サーバーの読み込みに失敗しました: ${error}",
"noValidServers": "このアカウントで利用可能なサーバーが見つかりませんでした"
},
"hubDetail": {
"title": "タイトル",
@@ -1129,7 +1255,6 @@
"rankPopular": "人気 #${n}",
"rankAiring": "放送中 #${n}",
"rankRated": "評価 #${n}",
"rankFavorited": "お気に入り #${n}",
"rankTrending": "急上昇 #${n}",
"rankSeasonal": "#${n}(${season})",
"watchingNow": "${n}人視聴中",
@@ -1269,6 +1394,11 @@
"unknownChannel": "不明なチャンネル",
"live": "ライブ",
"reloadGuide": "番組表を再読み込み",
"searchGuide": "番組表を検索",
"searchHint": "チャンネル・番組を検索",
"searchNoResults": "「${query}」に一致するものがありません",
"channelsSection": "チャンネル",
"programsSection": "番組",
"now": "現在",
"today": "今日",
"tomorrow": "明日",
@@ -1324,6 +1454,16 @@
"guideReloadRequested": "番組表の更新をリクエストしました",
"rulesProcessRequested": "録画ルールの再評価をリクエストしました",
"recordShow": "番組を録画",
"recordSettings": {
"startEarly": "早めに開始(秒)",
"endLate": "遅めに終了(秒)",
"newOnly": "新しいエピソードのみ",
"anyChannel": "すべてのチャンネルで録画",
"anyTime": "すべての時間帯で録画",
"skipInLibrary": "ライブラリにあるエピソードをスキップ",
"keepUpTo": "保持するエピソード数",
"keepUpToHint": "0 ですべてのエピソードを保持"
},
"startingInMinutes": "${minutes}分後に開始",
"dayAtTime": "${day} ${time}",
"invalidPlaybackData": "${product}から無効なライブTV再生データが返されました",
@@ -1406,7 +1546,14 @@
"repeatAll": "全曲リピート",
"repeatOne": "1曲リピート",
"instantMixNoServer": "インスタントミックスに利用できるサーバーがありません",
"noAudioUrl": "${track}で利用可能な音声URLがありません"
"instantMixFailed": "インスタントミックスを読み込めませんでした",
"instantMixEmpty": "インスタントミックスにトラックがありませんでした",
"noAudioUrl": "${track}で利用可能な音声URLがありません",
"discography": {
"singlesAndEps": "シングル・EP",
"live": "ライブ",
"compilations": "コンピレーション"
}
},
"watchTogether": {
"title": "一緒に見る",
@@ -1432,6 +1579,13 @@
"host": "ホスト",
"hostBadge": "ホスト",
"youAreHost": "あなたはホストです",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "他の人と視聴中",
"endSession": "セッションを終了",
"leaveSession": "セッションを退出",
@@ -1474,7 +1628,13 @@
"removeRoom": "削除",
"guestSwitchUnavailable": "切り替えられません — 同期に必要なサーバーを利用できません",
"guestSwitchFailed": "切り替えられません — このサーバーにコンテンツが見つかりません",
"defaultDisplayName": "ユーザー"
"defaultDisplayName": "ユーザー",
"errors": {
"timedOut": "リレーサーバーが時間内に応答しませんでした",
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
"sessionEnded": "ホストがセッションを終了しました"
}
},
"downloads": {
"title": "ダウンロード",
@@ -1622,7 +1782,8 @@
"usePhoneToControl": "モバイルデバイスでこのアプリを操作できます",
"startServer": "サーバーを起動",
"stopServer": "サーバーを停止",
"minimize": "最小化"
"minimize": "最小化",
"manualAddressHint": "手動接続アドレス:"
},
"pairing": {
"discoveryDescription": "同じPlexアカウントを使用しているPlezyデバイスがここに表示されます",
@@ -1716,6 +1877,7 @@
"decoder": "デコーダー",
"rawDecoder": "Raw デコーダー",
"tunneling": "トンネリング",
"passthrough": "パススルー",
"aspect": "アスペクト",
"rotation": "回転",
"dvSource": "DV ソース",
@@ -1847,9 +2009,7 @@
"producer": "プロデューサー",
"country": "国",
"collection": "コレクション",
"label": "ラベル",
"style": "スタイル",
"mood": "ムード"
"label": "ラベル"
},
"matchScreen": {
"match": "照合…",
@@ -1912,8 +2072,11 @@
"statusPartiallyAvailable": "一部利用可能",
"statusRequested": "リクエスト済み",
"statusProcessing": "処理中",
"statusBlocklisted": "ブロックリスト登録済み",
"couldNotReach": "${url}に接続できませんでした: ${error}",
"noInstanceAtUrl": "${url}にSeerrインスタンスがありません(HTTP ${status})",
"invalidUrl": "https://seerr.example.comのようなサーバーアドレスを入力してください",
"quickConnectUnsupported": "このSeerrインスタンスはQuick Connectに対応していません。Seerr 3.4以降が必要です。",
"notInitialized": "このSeerrインスタンスでは初回セットアップが完了していません",
"noPlexTokenForReauth": "再サインインに使用できるPlexトークンがありません",
"noStoredCredentials": "再サインインに使用できる保存済みの認証情報がありません",
@@ -1926,6 +2089,7 @@
"services": {
"title": "サービス",
"hubSubtitle": "視聴の進捗を同期して、新しいタイトルをリクエストします。",
"integrations": "インテグレーション",
"notConnected": "未接続",
"connectedAs": "@${username} として接続済み",
"scrobble": "進捗を自動で記録",
@@ -2003,6 +2167,7 @@
"borrowFromAnotherProfileSubtitle": "別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。",
"invalidCredentials": "ユーザー名またはパスワードが正しくありません",
"authResponseNotJson": "認証レスポンスが有効なJSONではありません",
"authResponseIncomplete": "サーバーからのサインイン応答が不完全です",
"quickConnectRejected": "Quick Connectがサーバーに拒否されました",
"quickConnectNotJson": "Quick Connectのレスポンスが有効なJSONではありません",
"quickConnectMissingFields": "Quick Connectのレスポンスにコードまたはシークレットがありません",
+191 -26
View File
@@ -12,6 +12,7 @@
"useBrowser": "Браузерді пайдалану",
"or": "немесе",
"connectToMediaBrowser": "${product} қосылу",
"quickConnect": "Жылдам қосылу",
"useQuickConnect": "Жылдам қосылуды пайдалану",
"quickConnectInstructions": "Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.",
"quickConnectWaiting": "Растау күтілуде…",
@@ -53,6 +54,7 @@
"mute": "Дыбысты өшіру",
"ok": "Түсінікті",
"off": "Өшірулі",
"options": "Параметрлер",
"seasonNumber": "${number}-маусым",
"episodeNumberTitle": "${number}-бөлім - ${title}",
"chapterNumber": "${number}-бөлім",
@@ -81,7 +83,21 @@
},
"notAvailable": "N/A",
"url": "URL",
"letterKeys": "ABC"
"letterKeys": "ABC",
"mediaKind": {
"movie": "Фильм",
"show": "Сериал",
"season": "Маусым",
"episode": "Эпизод",
"artist": "Орындаушы",
"album": "Альбом",
"track": "Трек",
"collection": "Жинақ",
"playlist": "Ойнату тізімі",
"clip": "Клип",
"photo": "Фото",
"folder": "Қалта"
}
},
"screens": {
"licenses": "Лицензиялар",
@@ -127,6 +143,10 @@
"displayScale": "Дисплей масштабы",
"compact": "Тығыз",
"comfortable": "Ыңғайлы",
"gridSpacing": "Тор аралығы",
"gridSpacingTight": "Тығыз",
"gridSpacingNormal": "Қалыпты",
"gridSpacingSpacious": "Кең",
"tvCornerSpotlightBackdrop": "Бұрыштық жарық фоны",
"tvCornerSpotlightBackdropDescription": "Фонды толық экран орнына жоғарғы оң жақ бұрышта көрсету",
"viewMode": "Көрініс режимі",
@@ -170,16 +190,16 @@
"mpv": "mpv",
"hardwareDecoding": "Аппараттық декодтау",
"hardwareDecodingDescription": "Мүмкіндігінше аппараттық үдетуді пайдалану",
"bufferSize": "Буфер көлемі",
"bufferSizeMB": "${size} МБ",
"bufferSizeAuto": "Автоматты (Ұсынылатын)",
"bufferSizeWarning": "${heap} МБ ЖҰД қолжетімді. ${size} МБ буфер ойнатуға әсер етуі мүмкін.",
"playbackBuffer": "Ойнату буфері",
"playbackBufferAuto": "Авто (ұсынылады)",
"playbackBufferLarge": "Үлкен",
"playbackBufferExtraLarge": "Өте үлкен",
"playbackBufferDescription": "Тұрақсыз қосылыстарға қарсы көбірек буферлейді. Буфер өлшемімен де шектеледі.",
"defaultQualityTitle": "Әдепкі сапа",
"cellularQualityTitle": "Мобильді желідегі әдепкі сапа",
"cellularQualitySameAsDefault": "Әдепкі сапамен бірдей",
"directPlayCoveredQuality": "",
"directPlayCoveredQualityDescription": "",
"musicQualityTitle": "Музыка сапасы",
"subtitleStyling": "Субтитр баптаулары",
"subtitleStylingDescription": "Субтитрлердің сыртқы келбетін теңшеу",
@@ -193,13 +213,15 @@
"rememberTrackSelectionsDescription": "Әр медиа үшін дыбыс пен субтитр таңдауын сақтау",
"followServerTrackSelections": "Әр бөлім үшін сервердегі жолдар таңдауын қолдану",
"followServerTrackSelectionsDescription": "Бөлім ауысқанда ағымдағы таңдауды көшірудің орнына серверде таңдалған дыбыс пен субтитрлерді қолдану",
"resumeMusicOnLaunch": "Музыка сессиясын есте сақтау",
"resumeMusicOnLaunchDescription": "Қолданба ашылғанда соңғы әнді тоқтаған жерінен кідіртілген күйде ашу",
"showChapterMarkersOnTimeline": "Уақыт шкаласында бөлім белгілерін көрсету",
"showChapterMarkersOnTimelineDescription": "Уақыт шкаласын бөлімдерге бөлу",
"specialsOrdering": "",
"specialsOrderingDescription": "",
"specialsOrderingServer": "",
"specialsOrderingAirDate": "",
"specialsOrderingLast": "",
"specialsOrdering": "Арнайы бөлімдер эпизодтар ретімен",
"specialsOrderingDescription": "Арнайы бөлімдер сериалдың көру ретінің қай жерінде ойнатылады",
"specialsOrderingServer": "Сервер ретін сақтау",
"specialsOrderingAirDate": "Эфир күні бойынша араластыру",
"specialsOrderingLast": "Қалыпты маусымдардан кейін",
"clickVideoTogglesPlayback": "Ойнату/кідірту үшін видеоны басу",
"clickVideoTogglesPlaybackDescription": "Басқару элементтерін көрсету орнына ойнату немесе кідірту",
"videoPlayerControls": "Видео ойнатқыш басқару элементтері",
@@ -246,7 +268,11 @@
"shortcutAlreadyAssigned": "Бұл тіркес ${action} әрекетіне тағайындалған",
"shortcutUpdated": "${action} үшін пернелер тіркесі жаңартылды",
"saveFailed": "Өзгерістер сақталмады. Қайтадан байқап көріңіз.",
"autoSkip": "Автоматты өткізу",
"autoPlayAndSkip": "Автоойнату және өткізу",
"autoPlayNextEpisode": "Келесі бөлімді автоматты ойнату",
"autoPlayNextEpisodeDescription": "Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау",
"playNextCountdown": "Келесіні ойнату санағы",
"playNextCountdownImmediate": "Дереу ойнату",
"autoSkipIntro": "Киріс бөлімді (Intro) автоматты өткізу",
"autoSkipIntroDescription": "Бірнеше секундтан кейін киріс белгілерін автоматты өткізу",
"autoSkipCredits": "Титрлерді автоматты өткізу",
@@ -291,6 +317,8 @@
"autoPipDescription": "Видео ойнап жатқанда қолданбадан шыққанда авто-PiP режиміне өту",
"matchContentFrameRate": "Кадр жиілігін сәйкестендіру",
"matchContentFrameRateDescription": "Экран жиілігін видео мазмұнына сәйкестендіру",
"matchContentResolution": "Мазмұнның ажыратымдылығына бейімдеу",
"matchContentResolutionDescription": "Масштабтауды теледидар орындауы үшін экранды бейненің өз ажыратымдылығына ауыстырады. Ойнату кезінде мәзірлер мен субтитрлер де масштабталады",
"matchRefreshRate": "Жаңарту жиілігін сәйкестендіру",
"matchRefreshRateDescription": "Толық экранда экран жаңарту жиілігін сәйкестендіру",
"matchDynamicRange": "Динамикалық диапазонды сәйкестендіру",
@@ -319,6 +347,8 @@
"dvConversionNativeDescription": "Ішкі DV7 режимін мәжбүрлеу",
"dvConversionDv81Description": "Dolby Vision profile 8.1 форматына түрлендіру",
"dvConversionHevcStripDescription": "Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету",
"deinterlace": "Деинтерлейсинг",
"deinterlaceDescription": "Жоларалық видеодағы тарақ тәрізді артефактілерді жою (тек mpv ойнатқышында)",
"requireProfileSelectionOnOpen": "Ашқанда профильді сұрау",
"requireProfileSelectionOnOpenDescription": "Қолданба ашылған сайын профильді таңдауды көрсету",
"forceTvMode": "TV режимін мәжбүрлеу",
@@ -336,15 +366,43 @@
"showExploreTabDescription": "Plex шолуынан және қосылған трекерлерден алынған контенті бар «Шолу» қойындысын көрсету",
"liveTvDefaultFavorites": "Тікелей TV-де таңдаулы арналарды әдепкі ету",
"liveTvDefaultFavoritesDescription": "Тікелей TV ашылғанда тек таңдаулы арналарды көрсету",
"general": "Жалпы",
"generalDescription": "Тіл, іске қосу және терезе мінез-құлқы",
"languageAndRegion": "Тіл және аймақ",
"startup": "Іске қосу",
"display": "Дисплей",
"libraryAndCards": "Кітапхана және карточкалар",
"homeScreen": "Басты экран",
"navigation": "Навигация",
"window": "Терезе",
"content": "Мазмұн",
"liveTv": "Тікелей TV",
"player": "Ойнатқыш",
"subtitlesAndConfig": "Субтитрлер мен конфигурация",
"videoAndDisplay": "Видео және дисплей",
"audio": "Аудио",
"quality": "Сапа",
"subtitles": "Субтитрлер",
"seekAndTiming": "Айналдыру және уақытты реттеу",
"behavior": "Мінез-құлық",
"gestures": "Ым-ишаралар",
"gestureBrightnessSwipe": "Жарықтық сырғытуы",
"gestureBrightnessSwipeDescription": "Сол жақ шетінде жоғары немесе төмен сырғытып, жарықтықты реттеу",
"gestureVolumeSwipe": "Дыбыс сырғытуы",
"gestureVolumeSwipeDescription": "Оң жақ шетінде жоғары немесе төмен сырғытып, дыбыс деңгейін реттеу",
"gesturePinchToZoom": "Шымшу арқылы масштабтау",
"gesturePinchToZoomDescription": "Видеода шымшып, жақындату немесе алыстату",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Басқару элементтері",
"rememberPlayerChanges": "Ойнатқыш өзгерістерін есте сақтау",
"rememberPlayerChangesDescription": "Ойнату кезінде жасалған өзгеріс сақталатын және қайта қолданылатын орын",
"scopePlaybackSpeed": "Ойнату жылдамдығы",
"scopeShaderPreset": "Шейдер алдын ала орнатымы",
"scopeAspectRatio": "Кадр пішімі",
"scopeSyncOffsets": "Аудио және субтитр синхрондауы",
"playerScopeOff": "Сақтамау",
"playerScopeGlobal": "Барлық жерде",
"playerScopeLibrary": "Кітапхана бойынша",
"playerScopeTitle": "Сериал немесе фильм бойынша",
"exportDialogTitle": "Plezy параметрлерін экспорттау"
},
"search": {
@@ -599,12 +657,7 @@
"videoControls": {
"audioLabel": "Дыбыс",
"subtitlesLabel": "Субтитр",
"resetToZero": "0мс-қа қалпына келтіру",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} кейін ойнатылады",
"playsEarlier": "${label} бұрын ойнатылады",
"noOffset": "Ығысусыз",
"letterbox": "Кең экран (Letterbox)",
"fillScreen": "Экранды толтыру",
"stretch": "Созу",
@@ -670,6 +723,7 @@
"notSupported": "Құрылғы PiP режимін қолдамайды",
"voSwitchFailed": "PiP үшін видео шығысын ауыстыру мүмкін болмады",
"failed": "PiP режимін іске қосу қатесі",
"prepareFailed": "PiP режимін дайындау мүмкін болмады",
"unknown": "Қате орын алды: ${error}"
},
"chapters": "Бөлімдер",
@@ -882,6 +936,77 @@
"editMediaBrowserTitle": "${product} қосылымын өңдеу",
"editMediaBrowserIntro": "${serverName} үшін URL мекенжайларды қосыңыз немесе өшіріңіз. Plezy ең төмен кідірісі бар қолжетімді URL мекенжайды пайдаланады."
},
"accountPreferences": {
"sectionTitle": "Тіркелгі баптаулары",
"hubSubtitleSingle": "${account} тіркелгісінде сақталған аудио, субтитр және кітапхана баптаулары",
"hubSubtitleMultiple": "${count} тіркелгіде сақталған аудио, субтитр және кітапхана баптаулары",
"pickAccount": "Әр тіркелгі өз баптауларын сақтайды. Өңдеу үшін біреуін таңдаңыз.",
"storedOnAccount": "Бұл баптаулар тіркелгінің өзінде сақталады, сондықтан оған кірген әрбір қолданба оларды пайдаланады — басқа құрылғыларыңыздағы Plezy қоса.",
"noAccounts": "Баптауға тіркелгілер жоқ",
"noAccountsHint": "Plex-ке кіріңіз немесе Jellyfin не Emby серверін қосыңыз, сонда сол тіркелгіде сақталған баптаулар осында көрсетіледі.",
"unavailable": "Бұл тіркелгіге қол жеткізу мүмкін емес",
"loadFailed": "Бұл баптауларды жүктеу мүмкін болмады",
"noPreference": "Таңдау жоқ",
"notSet": "Орнатылмаған",
"groups": {
"audioAndSubtitles": "Аудио және субтитрлер",
"libraryDisplay": "Кітапхана",
"personalMedia": "Жеке медиа"
},
"preferredAudioLanguage": "Қалаулы аудио тілі",
"autoSelectAudio": "Аудионы тілі бойынша таңдау",
"autoSelectAudioDescription": "Өшірулі күйде файл әдепкі деп белгілеген аудио трек қолданылады.",
"preferredSubtitleLanguage": "Қалаулы субтитр тілі",
"subtitleMode": "Субтитрлерді қосу",
"subtitleModes": {
"none": "Қолмен таңдалған",
"noneDescription": "Субтитрлерді өзінен-өзі ешқашан қоспау.",
"defaultMode": "Трек белгілерін қолдану",
"defaultModeDescription": "Әр субтитр трегінде сақталған әдепкі және мәжбүрлі белгілерді пайдалану.",
"always": "Әрдайым қосылған",
"alwaysDescription": "Қалаулы тілдегі субтитр трегі бар болса, оны қосу.",
"onlyForced": "Тек мәжбүрлі субтитрлер",
"onlyForcedDescription": "Тек мәжбүрлі деп белгіленген тректерді жүктеу.",
"smart": "Шет тіліндегі аудио кезінде көрсету",
"smartDescription": "Субтитрлерді тек аудио басқа тілде болғанда қосу."
},
"subtitleAccessibility": "SDH субтитрлері",
"subtitleAccessibilityOptions": {
"preferNonSdh": "SDH емес субтитрлерді артық көру",
"preferSdh": "SDH субтитрлерін артық көру",
"onlySdh": "Тек SDH субтитрлері",
"onlyNonSdh": "Тек SDH емес субтитрлер"
},
"forcedSubtitles": "Мәжбүрлі субтитрлер",
"forcedSubtitleOptions": {
"preferNonForced": "Мәжбүрлі емес субтитрлерді артық көру",
"preferForced": "Мәжбүрлі субтитрлерді артық көру",
"onlyForced": "Тек мәжбүрлі субтитрлер",
"onlyNonForced": "Тек мәжбүрлі емес субтитрлер"
},
"displayMissingEpisodes": "Жоқ бөлімдерді көрсету",
"displayMissingEpisodesDescription": "Сервер білетін, бірақ файлы жоқ бөлімдерді тізімдеу.",
"hidePlayedInLatest": "«Latest» айдарында көрілген элементтерді жасыру",
"hidePlayedInLatestDescription": "Сервердің «Latest» қатарларында сіз көрген элементтерді көрсетпеу.",
"displayCollectionsView": "Топтамалар көрінісін көрсету",
"displayCollectionsViewDescription": "Кітапханаларыңызбен қатар сервердің Топтамалар көрінісін ұсыну.",
"rewatchingInNextUp": "Қайта көрілген сериалдарды «Next Up» айдарында қалдыру",
"rewatchingInNextUpDescription": "Сериалды аяқтаған соң оны қайта бастасаңыз, «Next Up» сериалды тастамай, қайта көруді жалғастырады.",
"watchedIndicator": "Көрілген белгілері",
"watchedIndicatorOptions": {
"none": "Ешқашан",
"moviesAndShows": "Фильмдер және сериалдар",
"movies": "Тек фильмдер",
"shows": "Тек сериалдар"
},
"mediaReviewsVisibility": "Бағалар мен пікірлер",
"mediaReviewsOptions": {
"usersAndCritics": "Пайдаланушылар және сыншылар",
"usersOnly": "Тек пайдаланушылар",
"criticsOnly": "Тек сыншылар",
"nobody": "Жасырылған"
}
},
"discover": {
"title": "Шолу",
"noContentAvailable": "Мазмұн қолжетімсіз",
@@ -1027,7 +1152,8 @@
},
"serverSelection": {
"noServersFoundForAccount": "${username} (${email}) үшін серверлер табылмады",
"failedToLoadServers": "Серверлерді жүктеу қатесі: ${error}"
"failedToLoadServers": "Серверлерді жүктеу қатесі: ${error}",
"noValidServers": "Бұл тіркелгіде жарамды сервер табылмады"
},
"hubDetail": {
"title": "Атауы",
@@ -1135,7 +1261,6 @@
"rankPopular": "#${n} танымал",
"rankAiring": "#${n} эфирде",
"rankRated": "#${n} бағаланған",
"rankFavorited": "#${n} таңдаулы",
"rankTrending": "#${n} трендте",
"rankSeasonal": "#${n} — ${season}",
"watchingNow": "${n} қарауда",
@@ -1278,6 +1403,11 @@
"unknownChannel": "Белгісіз арна",
"live": "ТІКЕЛЕЙ ЭФИР",
"reloadGuide": "Бағдарламаны жаңарту",
"searchGuide": "Телебағдарламадан іздеу",
"searchHint": "Арналар мен бағдарламаларды іздеу",
"searchNoResults": "\"${query}\" бойынша сәйкестік табылмады",
"channelsSection": "Арналар",
"programsSection": "Бағдарламалар",
"now": "Қазір",
"today": "Бүгін",
"tomorrow": "Ертең",
@@ -1333,6 +1463,16 @@
"guideReloadRequested": "Бағдарламаны жаңарту сұралды",
"rulesProcessRequested": "Ережелерді қайта бағалау сұралды",
"recordShow": "Шоуды жазу",
"recordSettings": {
"startEarly": "Ертерек бастау (секунд)",
"endLate": "Кешірек аяқтау (секунд)",
"newOnly": "Тек жаңа эпизодтар",
"anyChannel": "Кез келген арнадан жазу",
"anyTime": "Кез келген уақытта жазу",
"skipInLibrary": "Кітапханада бар эпизодтарды өткізіп жіберу",
"keepUpTo": "Сақталатын эпизодтар",
"keepUpToHint": "0 барлық эпизодты сақтайды"
},
"startingInMinutes": "${minutes} мин кейін басталады",
"dayAtTime": "${day}, ${time}",
"invalidPlaybackData": "${product} жарамсыз тікелей TV ойнату деректерін қайтарды",
@@ -1416,7 +1556,14 @@
"repeatAll": "Барлығын қайталау",
"repeatOne": "Біреуін қайталау",
"instantMixNoServer": "Жедел микс үшін қолжетімді сервер жоқ",
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес"
"instantMixFailed": "Сәттік микс жүктелмеді",
"instantMixEmpty": "Сәттік микс ешбір тректі қамтымады",
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес",
"discography": {
"singlesAndEps": "Синглдер және EP",
"live": "Жанды",
"compilations": "Жинақтар"
}
},
"watchTogether": {
"title": "Бірге көру",
@@ -1442,6 +1589,13 @@
"host": "Ұйымдастырушы",
"hostBadge": "ҰЙЫМДАСТЫРУШЫ",
"youAreHost": "Сіз ұйымдастырушысыз",
"makeHost": "",
"makeHostQuestion": "",
"makeHostConfirm": "",
"transfer": "",
"hostChangedTo": "",
"youAreNowHost": "",
"hostTransferFailed": "",
"watchingWithOthers": "Басқалармен бірге көрілуде",
"endSession": "Сеансты аяқтау",
"leaveSession": "Сеанстан шығу",
@@ -1484,7 +1638,13 @@
"removeRoom": "Өшіру",
"guestSwitchUnavailable": "Ауысу мүмкін болмады",
"guestSwitchFailed": "Ауысу мүмкін болмады — мазмұн табылмады",
"defaultDisplayName": "Пайдаланушы"
"defaultDisplayName": "Пайдаланушы",
"errors": {
"timedOut": "Реле сервері уақытында жауап бермеді",
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
}
},
"downloads": {
"title": "Жүктеулер",
@@ -1632,7 +1792,8 @@
"usePhoneToControl": "Осы қолданбаны басқару үшін телефоныңызды пайдаланыңыз",
"startServer": "Серверді іске қосу",
"stopServer": "Серверді тоқтату",
"minimize": "Жию"
"minimize": "Жию",
"manualAddressHint": "Қолмен қосылу мекенжайы:"
},
"pairing": {
"discoveryDescription": "Бірдей Plex тіркелгісіндегі Plezy құрылғылары мұнда көрінеді",
@@ -1726,6 +1887,7 @@
"decoder": "Декодер",
"rawDecoder": "Өңделмеген декодер",
"tunneling": "Туннельдеу",
"passthrough": "Тікелей өткізу",
"aspect": "Қатынас",
"rotation": "Бұрылыс",
"dvSource": "DV дереккөзі",
@@ -1857,9 +2019,7 @@
"producer": "Продюсер",
"country": "Ел",
"collection": "Топтама",
"label": "Белгі",
"style": "Стиль",
"mood": "Көңіл-күй"
"label": "Белгі"
},
"matchScreen": {
"match": "Сәйкестендіру...",
@@ -1922,8 +2082,11 @@
"statusPartiallyAvailable": "Жартылай қолжетімді",
"statusRequested": "Сұралды",
"statusProcessing": "Өңделуде",
"statusBlocklisted": "Бұғаттау тізімінде",
"couldNotReach": "${url} мекенжайына қосылу мүмкін болмады: ${error}",
"noInstanceAtUrl": "${url} мекенжайында Seerr данасы жоқ (HTTP ${status})",
"invalidUrl": "Сервер мекенжайын енгізіңіз, мысалы: https://seerr.example.com",
"quickConnectUnsupported": "Бұл Seerr данасы Жылдам қосылуды қолдамайды. Оған Seerr 3.4 немесе одан жаңарақ нұсқа қажет.",
"notInitialized": "Бұл Seerr данасының бастапқы баптауы аяқталмаған",
"noPlexTokenForReauth": "Қайта кіру үшін Plex токені қолжетімді емес",
"noStoredCredentials": "Қайта кіру үшін сақталған тіркелгі деректері жоқ",
@@ -1936,6 +2099,7 @@
"services": {
"title": "Қызметтер",
"hubSubtitle": "Көру барысын синхрондаңыз және жаңа мазмұн сұраңыз.",
"integrations": "Интеграциялар",
"notConnected": "Қосылмаған",
"connectedAs": "@${username} ретінде қосылды",
"scrobble": "Барысты автоматты бақылау",
@@ -2013,6 +2177,7 @@
"borrowFromAnotherProfileSubtitle": "Басқа профильдің қосылымын қайта пайдалану.",
"invalidCredentials": "Пайдаланушы аты немесе құпия сөз қате",
"authResponseNotJson": "Аутентификация жауабы жарамды JSON болмады",
"authResponseIncomplete": "Сервердің кіру жауабы толық емес",
"quickConnectRejected": "Сервер Quick Connect сұрауын қабылдамады",
"quickConnectNotJson": "Quick Connect жауабы жарамды JSON болмады",
"quickConnectMissingFields": "Quick Connect жауабында код немесе құпия кілт жоқ",

Some files were not shown because too many files have changed in this diff Show More