Compare commits

...
Author SHA1 Message Date
github-actions[bot] dfdb38707d chore: bump version to 2.13.0 2026-08-10 07:26:07 +00:00
edde746 e9a213807f ci(linux): check the runner's libraries reach the package metadata
The plane added three runtime libraries that bundle-libs.sh deliberately does not
bundle, so they have to be declared per distro by hand - and two hand-maintained
lists drifting apart is the failure this guard exists to prevent.

check_linux_package_deps.py parses the runner's CMake for every pkg-config module
it links, follows target_link_libraries to prove each one actually reaches the
binary, and requires a package name for it in every distro's depends list. It
fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call
naming several modules, options preceding the module name, and version
constraints like mpv>=0.40 that would otherwise be read as a package nobody
ships.

The smoke job builds the three packages and reads the dependencies back out of
the artifacts, deriving what to expect from build-packages.py rather than
restating it - so a library is declared once and verified everywhere. That job is
off by default, which is exactly why it must not carry its own copy of the list.

The Linux native job names libwayland-dev and libegl-dev instead of riding
GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own
rationale. In CI the host-dependency guard runs once: the named step covers the
staged bundle, and build-packages.py's internal run - which exists for by-hand
packaging - is skipped. The smoke job also drops patchelf, which nothing
invokes.
2026-08-10 08:48:14 +02:00
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00
edde746 c27dc0a1a7 build(linux): vendor the color-management-v1 protocol bindings
wayland-scanner output for the staging colour-management protocol, which the
native video plane uses to describe itself to the compositor as PQ / BT.2020.

Committed rather than generated at build time: the protocol only appeared in
wayland-protocols 1.41, newer than the version the distributions this app is
built for ship. Vendoring keeps the build working regardless of the host and
adds no build dependency on wayland-scanner. Generated from wayland-protocols
1.49 with wayland-scanner 1.25.0.

Nothing links these yet; the CMake wiring and the plane that uses them follow.
The local .clang-format exempts the generated sources from the runner's style.
2026-08-10 08:47:51 +02:00
edde746 f5665df43f fix(music): report a gaplessly advanced track's first timeline at its own start
When a gapless advance was announced, the new track's tracker sent its
initial report from live player state, which still carried the finished
track's position and duration - telling Plex the new track was already
at ~100%. PMS recorded a play (and a Last.fm scrobble) at track start on
top of the one from the real playthrough, and the tracker latched the
new track watched locally the moment it began.

The music bind now pins the initial report to the track's own start
(position zero, metadata duration); timer ticks keep reading live state.

close #1849
2026-08-10 08:33:11 +02:00
edde746 f1d4be70e2 feat(trackers): show a QR code in every tracker sign-in dialog and fit them on TV screens
Every tracker auth dialog (Trakt/Simkl/MDBList device-code and MAL/AniList
OAuth proxy) now shares the same PendingAuthDialog affordances: a QR code for
the sign-in URL, a large copyable URL with the scheme stripped, the browser
launch button (hidden on Apple TV, which has no browser), and the polling
spinner. On wide viewports (TV logical 960x540, desktop, phone landscape) the
QR pane sits beside the instructions so the dialog no longer clips on tvOS,
and the content is scrollable as an overflow safety net. Device activation
codes scale down instead of wrapping.
2026-08-10 08:33:11 +02:00
Tolu AdegbehingbeandGitHub 85d1672909 fix(player): accept clock-sync pongs only from the host (#1850)
Every authoritative message a guest acts on is gated on the relay-stamped
`senderId` matching `_session.hostPeerId` — room state, `hostExitedPlayer`
— except `pong`, which `_handleMessage` fed to `ClockSync` on nothing more
than "I am a guest and this pingId is one I am waiting for".

That matters because the guest's clock, not just the state it receives, is
part of the trust boundary. A guest estimates the host's clock offset from
the round trip: it sends `ping` at its own local time, the host answers
`pong` stamped with the host clock, and the guest takes the midpoint as the
one-way delay. Every anchor the host publishes — `anchorHostTimeMs` on a
state, a scheduled synchronized start — is translated into local time
through that offset, so a wrong offset silently shifts the target position
`GuestPlaybackReconciler` computes from otherwise authentic state. Past the
2000 ms hard-seek threshold the guest seeks, then keeps mistranslating the
corrections that follow, while the host and every other guest stay fine.

Any peer in the room could therefore reply to another guest's ping. This is
a weak primitive rather than playback takeover: the forged pong has to name
a `pingId` that is currently outstanding and land inside its RTT window,
and `ClockSync` discards samples over a second. But the relay already
stamps the sender on every inbound message, so the check costs one
conjunct.

Verified: full suite green (5788 tests, 5 skipped), 186 of them under
test/watch_together, plus dart format and analyzer parity.
2026-08-10 07:13:39 +02:00
Tolu AdegbehingbeandGitHub 5c08b29756 fix(plex): keep compatible audio on capped transcodes (#1845)
`maxVideoBitrate` budgets the whole stream, so a capped transcode forced
even profile-compatible audio down to low-rate AAC (measured on Plex
1.43: EAC3 5.1 640k became 360k AAC). The client profile already carries
the video cap through `add-limitation(video.bitrate)`, so drop the
redundant `maxVideoBitrate` param and send `directStreamAudio=1`: the
video stays capped per preset while audio in the codecs the profile
declares (aac/ac3/eac3/mp3) is copied through untouched. Audio the HLS
target cannot carry still transcodes as before.
2026-08-10 07:05:23 +02:00
edde746 672047924c fix(test): stop the desktop prefs preflight deadlocking widget tests on Linux
PrefsRecovery.assertStoreReadable reads the real on-disk store through
path_provider, and on the hosts where it is active (Linux and Windows)
that genuine file IO can never complete inside testWidgets' fake async
zone. Since 7f0cad33 wired the preflight into shared-preference init,
every widget test that initialises settings on the Linux CI runner hung
for its full ten-minute timeout, turning the unit-test job into a
six-hour cancellation. The hermetic prefs fixture swaps the backends for
in-memory fakes anyway, so it now disables the preflight and restores it
on teardown; the repair-flow suite keeps opting in explicitly.

Verified in an ubuntu-24.04 container on Flutter 3.44.0: the full suite
now completes in nine minutes with zero failures.
2026-08-09 18:51:47 +02:00
edde746 e92cc3cebf fix(website): update undici, postcss, nanoid, and SvelteKit past open advisories
Overrides move undici to 7.29.0, postcss to 8.5.26, and nanoid to 3.3.18;
SvelteKit updates in range to 2.70.2. The eleven undici acceptances in the
Bun audit baseline are stale once undici is current, so they are removed.
2026-08-09 18:51:47 +02:00
edde746 8e1be64d8d fix(windows): rewrite the preference store in place when a reader vetoes the rename
dart:io opens files without FILE_SHARE_DELETE, so MoveFileExW with
MOVEFILE_REPLACE_EXISTING - and any other replacement strategy - fails
while such a reader holds the document open. Fall back to the upstream
in-place rewrite so a hostile reader costs at most crash-atomicity for
that one write instead of silently dropping it, and sweep the staging
copy the rename did not consume.
2026-08-09 18:51:47 +02:00
edde746 5a3a1f3c70 fix(ios): drop RunnerTests coverage of the removed Atmos raw EC3 loader
RawEc3Loader and its ProbeURLProtocol harness were deleted with the Atmos
output diagnostics, but the iOS RunnerTests kept exercising them, so the
Apple native reliability job no longer compiled.
2026-08-09 18:51:47 +02:00
edde746 0ee5672426 fix(ci): replace unused main.dart test wrappers with the public root shell
The unused-code gate flags debugSetCrashReporterReady (referenced nowhere)
and the formFactorScaleForTesting/rootShellForTesting wrappers (referenced
only from tests, which the lib-scoped check cannot see). Delete the dead
setter and let the car-scale test build the real rootShell and
FormFactorScale directly.
2026-08-09 18:51:47 +02:00
edde746 df0570b861 style: apply dart format to 21 drifted test files 2026-08-09 17:50:58 +02:00
edde746 8d284efa39 chore(android): fix ktlint function-signature violation in ShelfRefreshWorkerTest 2026-08-09 17:21:01 +02:00
edde746 716fe9b51b feat(watchlist): add watchlist toggle to library context menus
Watchlist membership was only reachable from Explore cards and the
detail screen's action row, which drops the bookmark first on narrow
screens with no fallback in the overflow menu. Add an entry to
MediaContextMenu for movies and shows whenever a connected catalog
source can hold the item, covering card long-press everywhere and the
detail screen's overflow.

External-id resolution is session-cached per item on
CatalogSourcesProvider and shared with the detail screen. A cold cache
labels the entry "Add to Watchlist" and always adds (idempotent), so a
press can never turn into a surprise removal; "Remove" is offered once
cached membership proves it. Several capable sources open the same
per-source chooser the detail screen uses.

close #1822
2026-08-09 17:14:48 +02:00
edde746 9d13584c00 feat(settings): add appearance toggle to hide the Explore tab
The Explore tab appears for every Plex-backed profile because the Plex
Discover catalog source connects implicitly, with no way to opt out
short of a Jellyfin-only profile. Add a Show Explore Tab switch to
Appearance > Navigation that hides the tab in the bottom navigation and
side rail. UI-only: catalog sources stay connected so watchlist
surfaces keep working while the tab is hidden.

close #1844
2026-08-09 17:14:47 +02:00
edde746 8e5279a487 feat(subtitles): optionally anchor text subtitles to the screen bottom
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android +
ExoPlayer only, default off). When enabled, the text SubtitleView is
sized to the full container instead of the letterboxed video rect, so
SRT/VTT/mov_text cues render in the black bars below widescreen video
and font size and the position setting become relative to the physical
screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged;
mpv already places plaintext subtitles in the margins by default.

close #1730
2026-08-09 17:14:34 +02:00
edde746 8ad13e94cf chore(mpv): bump MPVKit to 1.0.17 2026-08-09 12:31:48 +02:00
edde746 e0a364e26a chore(tvos): remove the Atmos output diagnostics 2026-08-09 11:52:48 +02:00
edde746 63f2bedf2c fix(livetv): navigate guide rows in displayed source-group order
Vertical D-pad/arrow navigation stepped through the flat channel list,
which is number-sorted across servers. With overlapping channel numbers
from multiple DVRs, focus interleaved source groups and could dead-end
before the last displayed row. Derive the up/down order from the same
grouped rows the guide renders.

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

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

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

Refs #887.
2026-08-09 11:12:08 +02:00
edde746 de76c0a515 feat(tv): refresh the Watch Next row without the app open
The Watch Next row previously only updated while the app was in the
foreground, so it drifted stale until the next launch. A WorkManager
periodic job (6h, network-connected, KEEP) now runs a headless Flutter
engine executing `systemShelfBackgroundMain`, which mirrors the
cold-start profile bind from cached tokens (never prompting for a PIN),
fetches Continue Watching through the existing multi-server aggregation,
and republishes the shelf through the normal Watch Next pipeline.

The job is armed by a committed foreground sync, cancelled when the
shelf is cleared, and re-armed after boot or app update only when
persisted shelf state exists. It skips entirely while a foreground
engine holds the shelf lifecycle lease, both to defer to the live app
and to avoid two engines sharing the database in one process. The Dart
isolate always reports completion over `backgroundSyncComplete`; the
worker hard-caps the run at 90 seconds and destroys the engine on the
main thread.
2026-08-09 10:59:30 +02:00
edde746 291a22a4a4 feat(tvos): fetch Top Shelf content live and show poster art
The Top Shelf extension now fetches Continue Watching directly from
Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its
last foreground Discover pass. The app publishes per-profile server
descriptors on every shelf sync (`updateSources`): token-free metadata
in the app group, tokens in an app-group-shared keychain item, both
wiped by `clear`. On success the extension rewrites the cached payload
as the offline fallback; any fetch failure falls back to the previous
cache-replay behavior. Poster images are passed as remote URLs, so the
extension no longer depends on app-side artwork downloads.

Episodes now render season/series poster art (2:3, `.poster` shape)
instead of 16:9 episode stills, and labels lead with the S/E marker so
long titles no longer hide it behind the focused-item marquee. Shelf
schema v3 (Dart, Android, tvOS envelopes bumped together) discards
stale wide-art caches instead of letterboxing them into poster slots.

close #1474
close #1835
2026-08-09 10:59:16 +02:00
edde746 0b4fd9e8f3 fix(tv): host automatic multiline input in the Android IME
Android TV's docked keyboard handles multiline editors natively, so
`automatic` no longer diverts them to the Flutter overlay there. Only
Apple TV keeps the overlay for multiline input — its modal fullscreen
system keyboard cannot edit multiline text. Surfaces that want the
overlay for editing ergonomics (mpv config, connection editor, dialog
text areas) already pin flutterOverlay explicitly.
2026-08-09 10:06:29 +02:00
edde746 ce9556db22 fix(tv): restore the native Android IME for single-line text input
Android TV returns to the platform keyboard for single-line fields; the
Flutter overlay stays for multiline and explicit call sites. The bugs
that forced the overlay (#1051, #1079) were an engine show/bind ordering
race, now repaired at the app level:

- MainActivity retries a soft-input show the engine dropped while the
  FlutterView was not yet served (flutter/flutter#177360), rebinds the
  IME key session once at first show, and consumes leaked D-pad keys
  while the keyboard is visible (bounded restartInput budget) so focus
  cannot wander behind a stuck keyboard.
- The platform text-input hint is activation-based, so gamepad pause and
  the pre-IME D-pad intercept track a live session instead of mere field
  focus.
- While a session is live with the keyboard away, Back closes it and is
  consumed once, Select re-raises the keyboard, and arrows keep
  caret-aware edge-escape navigation instead of dead-ending.
2026-08-09 09:56:53 +02:00
edde746 9d51f5aadd feat(tvos): scale Siri Remote swipe distance to the focused item
A focus step cost a fixed 180pt of pan travel regardless of what was
focused, so small controls felt sluggish and large cards hair-triggered
compared with the native focus engine, which prices a step by on-screen
geometry. Derive per-axis thresholds from the focused control's rect
(gain 1.1, clamped 100-360pt) and normalize axis resolution by them, so
a wide-flat tile steps vertically once the finger covers its height.
Focus scopes, the player's screen-sized catch-all surfaces, and nodes
without layout fall back to the fixed threshold, keeping player chrome
behavior unchanged.
2026-08-09 08:08:59 +02:00
edde746 fe79817e76 fix(tvos): stop a single Siri Remote flick moving focus two steps
Touch travel banked during the swipe repeat cooldown was released as a
second focus step by the first post-cooldown move frame, even when the
finger had stopped or was lifting. Re-anchor the swipe delta on every
frame inside the cooldown so a discrete flick emits exactly one step
while a sustained drag keeps repeating.

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

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

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

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

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

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

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00
toluLikesToCodeandedde746 8740a19f36 feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision
request never sent offset=, so any non-zero open - resuming a
transcoded title, or switching from Direct Play to a transcoded
quality mid-playback - opened a session whose produced window begins
at the start of the file and seeked it. mpv immediately requests a
segment the transcoder has not produced, PMS answers 404 for it and
every subsequent segment, and playback buffers forever.

Send offset=<seconds> (6dp) with the decision and start request - the
view offset on initial open, the resolved resume position on every
in-place reload - so the session begins producing at the position the
player consumes first. The playlist timeline is unchanged: an offset
session's media playlist still covers the full title from segment
zero, so the player keeps opening with start: at the resume position
and in-stream seeks work as before.

Before a native player opens an offset playlist, waitForTranscodeReady
walks the master playlist, the media playlist, and the segment
containing the offset, because PMS can publish a manifest before that
segment is fetchable and mpv treats the 404 as an HLS error. The probe
is best-effort: it never fails an open, hands off immediately on HTTP
500 (on the response and exception paths alike) so the server-limit
dialog stays prompt, stops on cancellation, skips itself when the
playlist durations never reach the offset, and stays out of the
endpoint-failover cascade. In-place reloads resolve the replacement
source only after the old stop report has gone out, so Plex cannot use
that stop to terminate the replacement transcode.

close #1840
2026-08-09 06:28:59 +02:00
edde746 bb3762ed63 ci: remove the Android Maestro e2e workflow
The Maestro suites remain runnable locally through scripts/run_maestro.py
and scripts/run_maestro_ci.py; drop the workflow, the test that parsed it,
and the CONTRIBUTING reference to automatic PR coverage.
2026-08-08 12:36:49 +02:00
edde746 7437b43207 fix(plex): validate a failover candidate before switching the live endpoint
A transient GET failure on a healthy endpoint could park the client on an
unreachable fallback (e.g. the server host's Docker bridge gateway, which
plex.tv advertises as a local connection) for a full connect timeout, failing
every request in flight during that window (log bbr90).

The cascade now probes each candidate with an unauthenticated /identity
request under the discovery-race budget and only switches when it answers as
the expected server, mirroring the Jellyfin trust gate. Unreachable-looking
private IPv4 candidates stay in the list — a client on the server host can
legitimately reach them, so reachability is probed, not inferred.
2026-08-08 12:18:32 +02:00
edde746 e6be5f9fef fix(player): surface a persistent HTTP 503 at open instead of retrying forever
ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a
server that keeps refusing the stream at open time left a silent black screen:
ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached
the screen. A new open-phase watchdog arms on the first 503 seen before any
frame renders and, after 20s without one, synthesizes a server-http-503 error
that shows an actionable dialog. Mid-stream 503s and live TV keep their
existing ride-out paths.

close #1830
2026-08-08 12:06:32 +02:00
edde746 f0debe2c32 feat(ui): show a system-format clock on TV home and in the player
The clock renders through the existing formatClockTime helper driven by
MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour
setting instead of introducing an app preference. It re-arms a one-shot
timer onto each wall-clock minute boundary rather than polling, and
resyncs on resume because a suspended process runs no timers.

The player header is shared by the mobile and desktop/TV controls, so one
insertion point covers every form factor: the player is fullscreen
everywhere, so it never has an OS clock to defer to. Home is the
exception and only gets one on TV, where a leanback app hides the system
clock; a phone status bar and a desktop menu bar already show the time.
2026-08-08 10:58:59 +02:00
edde746 109f1eda4d fix(player): fall back to decoding when a TrueHD stream contradicts its container
Selection reads Format.sampleRate, but the rate family is only certain once a
major sync is parsed. When a container announces the 48kHz family and the
bitstream announces 44.1kHz, the packer emits nothing: handleBuffer consumed the
input and reported success, so the stream played as silence for as long as it
lasted. TrueHdMatPacker.reset also left the flag latched, so every later stream
on that packer emitted nothing too.

Leave the offending access unit in the buffer, signal the capability change, and
let the decoder take the stream over. The packer clears the flag on reset.

The latch has to outlive both flush and reset. media3 resets every renderer
disabled by a new selection before enabling its replacement
(ExoPlayerImplInternal.enableRenderers), and both audio renderers share this
sink, so the outgoing renderer's reset arrives in the middle of the handover the
latch exists to cause; clearing it there loops straight back into the mismatch.
The real boundary is a new media item, which only ExoPlayerCore knows, so it
signals one before setting a new source. The same-item recovery, DV-mode and
subtitle reloads deliberately do not.

It is a generation rather than a flag because that hook runs on the app thread
while the mismatch is found on the playback thread: a late buffer from the
outgoing stream would otherwise disable the carrier for its successor.

Verified on the SEI Box R (Android 14, armv7) with a genuine 44.1kHz TrueHD
stream in a container patched to announce 48000, so the bitstream and its
checksums stay valid. The sink enters the carrier at 192kHz, reports the
mismatch, hands over to FFmpeg and plays on. The device test asserts that
sequence from the sink's own diagnostics, because the mismatch fires before the
carrier opens an AudioTrack and the rate sequence alone cannot distinguish it
from never having selected the carrier.
2026-08-08 10:51:15 +02:00
edde746 be99f27b92 fix(player): move TrueHD off the carrier when playback speed leaves 1x
A bitstream cannot be resampled, so the carrier only ever accepts 1x. The
selection gate covered that, but nothing re-ran it: setPlaybackSpeed reaches
the sink and returns, and the renderer only re-asks when audio capabilities are
invalidated. A speed change during carrier playback therefore left the carrier
live and handed it parameters its empty processor chain cannot apply.

Signal the capability change from the sink, which reaches
onRendererCapabilitiesChanged and moves TrueHD onto the decoder; returning to
1x re-offers the carrier, so a speed nudge no longer costs Atmos for the rest
of the session. The carrier delegate is never given a non-1x speed while that
selection is in flight.

Report the requested parameters rather than the delegate's while the carrier is
active. The player polls the sink through the media clock and adopts what it
reads, so reporting the pinned 1x pushed it back into the player and silently
undid the speed change.

Rebuilding the track selector parameters is not an alternative:
DefaultTrackSelector skips invalidation when the rebuilt parameters compare
equal, so a forced reselection can silently no-op.

Verified on the SEI Box R (Android 14, armv7): carrier at 192kHz with no
decoder, speed to 1.5x moves it to the FFmpeg decoder at 48kHz with the clock
advancing faster than real time, and returning to 1x restores the carrier. The
device test skips itself on hardware that never takes the carrier, as the
Nvidia Shield does.
2026-08-08 10:51:15 +02:00
edde746 3b76cf3948 fix(player): make TrueHD carrier-or-decode and never lose access units
Three defects in the carrier path, two of them found on hardware (#1804).

Falling through to the normal sink when the carrier was unavailable handed
TrueHD straight back to media3's raw ENCODING_DOLBY_TRUEHD path — the exact
configuration this issue is about. TrueHD is now binary: the carrier, or
reported unsupported so the bundled FFmpeg decoder takes it. media3's raw path
has no demonstrated working case here and two broken ones, and even Kodi's raw
fallback is a different thing, offered only after verifying at 192kHz.

The 44.1kHz family was decided from a packer flag that is only set once a major
sync has been parsed, long after selection. The carrier was therefore chosen for
those streams and then packed nothing, which is silence rather than a glitch. It
is decided from Format.sampleRate now, with the packer flag left as a loud
runtime backstop for a bitstream that disagrees with its container.

handleBuffer consumed the whole input buffer even when a burst was refused
part-way through, dropping every access unit behind it — a media3 sample holds
sixteen. The buffer position now advances per unit and the method returns false
with the remainder intact, which is media3's own retry contract. A test rejects
a burst mid-sample and asserts the carrier output is still byte-identical.

The capability gate also needed tightening. getMinBufferSize answers yes for the
192kHz/7.1 IEC tuple on a Shield and the AudioTrack then fails to initialise: it
reports that a buffer can be sized, not that the route will carry the format.
Without getDirectPlaybackSupport there is no way to separate the two, so the
carrier is not offered below API 33 and TrueHD decodes exactly as before.

Verified on both connected boxes. SEI Box R (Android 14): carrier selected,
AudioTrack built as IEC61937 at 192kHz/7.1, no decoder instantiated, clock
tracks wall time. Nvidia Shield (Android 11): carrier declined, FFmpeg decoder
selected, identical to its behaviour before this work.
2026-08-08 10:51:15 +02:00
edde746 b7a438789f feat(player): bitstream TrueHD through the MAT/IEC 61937 carrier
Copies the path Kodi uses, and replaces nothing-but-detection with a route that
actually plays (#1804).

Android will not bitstream raw TrueHD on the TV routes measured here. Both
connected boxes report ENCODING_DOLBY_TRUEHD as offload-only while reporting
ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable. Kodi models exactly that
split: it packs the carrier itself and offers "AudioTrack (IEC)" as the
recommended sink, treating raw TrueHD as a fallback that it still runs at
192kHz. Media3 only ever hands Android raw TrueHD at the stream rate, which on
the reporter's box takes one write and then never advances the playback head.

TrueHdCarrierSink routes TrueHD onto a dedicated delegate and leaves everything
else on the existing processed sink. The split is deliberate rather than
enforcing that the normal processors stay inactive: the carrier is a bit-exact
byte stream shaped like PCM, so a downmix, Sonic pass or silence skip turns it
into full-scale noise at the receiver. A delegate built with an empty
AudioProcessorChain makes that impossible by construction, instead of putting
the guarantee in a different class from the thing it protects.

The carrier delegate keeps OutputConfig at PCM 16-bit so media3's position,
pending-data and release accounting all stay on their mature PCM path — correct
here, because after packing the stream really is a fixed-rate 192kHz 8-channel
carrier. Only the AudioTrack itself is switched, through the builder modifier
upstream applies just before AudioTrack.Builder.build(). That avoids
reimplementing AudioOutput and avoids the encoded frame-domain mismatch in
androidx/media#3329.

Burst timestamps come from the carrier cadence rather than from whichever
access unit closed the frame; anchoring on the closing unit drifts against the
time the sink derives from written frames and reports a discontinuity on nearly
every frame.

Availability is Kodi's test, not media3's: getMinBufferSize for the exact
192kHz/7.1 IEC tuple, plus getDirectPlaybackSupport where it exists to confirm
the route will bitstream rather than quietly decode. Speed changes, downmix,
normalization and 44.1kHz-family streams all decline the carrier and decode.

Verified on a SEI Robotics Box R 4K Plus (Android 14, armeabi-v7a): the carrier
is selected, the AudioTrack is built as IEC61937 at 192kHz/7.1, no audio decoder
is instantiated, zero timestamp discontinuities, and the clock tracks wall time
with no frozen samples. The same box freezes for ten seconds on raw TrueHD.
2026-08-08 10:51:15 +02:00
edde746 33c33c3d57 feat(player): pack TrueHD into a MAT/IEC 61937 carrier
Groundwork for bitstreaming TrueHD the way other players do (#1804).

Android will not bitstream raw TrueHD on the TV routes measured so far. Both
connected Android TV boxes report ENCODING_DOLBY_TRUEHD as offload-only while
reporting ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable, and the
reporter's box takes a raw TrueHD AudioTrack and then never advances its
playback head. Kodi models this split explicitly: it offers an "AudioTrack
(IEC)" sink where it packs the carrier itself and treats handing raw TrueHD to
Android as the fallback, and even that fallback runs at 192kHz. Media3 only
ever does the raw form, at the stream rate.

This adds the packer half: split a sample into TrueHD access units, assemble
MAT frames with timing-derived padding, and emit IEC 61937 bursts. It is a port
of FFmpeg's spdif_header_truehd rather than Kodi's CAEBitstreamPacker, because
Kodi's is a thin wrapper over an already-assembled buffer while the MAT code
placement and padding live in FFmpeg's stateful packer.

Details the port has to get right. Media3's Matroska path concatenates 16
syncframes into one sample, so access units are split here; reading a single
input_timing for sixteen frames would desynchronise the carrier. Burst buffers
alternate and are reused rather than allocated, because a fresh 61,440 byte
array every 20ms is roughly 3MB/s of garbage on the low-power hardware this
runs on. A 44.1kHz-family stream rides a 176.4kHz carrier instead of 192kHz,
which changes the whole AudioTrack tuple, so it is reported as unsupported for
the caller to decode instead.

A wrong byte here is not subtle — the receiver drops sync or renders full-scale
noise — so the test compares against FFmpeg's own output byte for byte, using
its input and output as fixtures.

No caller yet; the sink that routes TrueHD through this follows.
2026-08-08 10:51:15 +02:00
edde746 636fd48f40 fix(player): settle the watched patch the backend recorded itself
Watching an episode to the end left it stuck as watched for the rest of the
session. Unmarking it on another device and refreshing did nothing; only a
restart cleared it. Unlike #1829 this needs no second device to cause -- a
normal watch-through is enough, and the second device only makes it visible.

A threshold crossing writes an unacknowledged overlay patch, deliberately:
reporting success proves the backend received the report, not that it
classified the item as played, so the patch stays owed until something
settles it. _settleServerMark has three settled outcomes and only one of them
did. The explicit-mark branch promoted; the two branches that skip the mark
because the backend already recorded the watch itself -- Jellyfin from
/Sessions/Playing/Stopped, Plex from a timeline crossing past
LibraryVideoPlayedThreshold -- returned without promoting. Those are the
common paths, so nearly every completed playback stranded a patch that the
store then refused to suppress, because an unacknowledged entry is never
retired by an authoritative read.

Both now promote, through one idempotent helper that clears the id so the
delivery callback and the settle paths cannot promote twice.

Promotion has to follow delivery rather than the settle decision. A
marks-on-stop backend settles when the crossing latches, which happens before
the stop is sent, and until that stop lands the watch really is still owed --
promoting there would let a refresh retire a patch the server had never
heard about. MediaBrowser also drops a stop for a session it never opened, in
which case the watch it would have recorded never happens at all. So the stop
path promotes only once the report reached a session able to act on it, which
is the same condition that already governs whether the stop persists its
position; that condition is now named rather than recomputed, and reset with
its siblings when a session re-arms. The crossing branch needs no such gate:
it is assembled from two delivered reports, so delivery is already proven.

Verified against a live Jellyfin server driving the real client and tracker:
before, the server reported the item unwatched after a second device cleared
it while the overlay still rendered watched; after, the overlay follows the
server. The optimistic mark still appears immediately during playback -- it
now yields to a later authoritative read instead of outliving one.

The #1287 and #1740 contracts are unchanged: neither branch issues an
explicit mark, and the tests assert that alongside the promotion.
2026-08-08 10:02:04 +02:00
edde746 5f397a99d9 fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing
Refresh left the first device showing the old "minutes left". Restarting the
app showed the right value. Two independent defects produce that, and either
alone reproduces the report.

The first is the watch-state overlay. Every local watch event lands in
WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites
viewOffsetMs unconditionally; isNewerThan only ever orders one patch against
another, never against the server row underneath. Nothing expires a patch and
nothing clears the map except a profile switch, so the Mac's own paused
position kept winning over every subsequent fetch until the process died.

A patch exists to bridge the gap between a local action and the next server
read of that item, so it should stop applying once that read happens. The
store now records the watermark at which a successful authoritative response
returned each key, and suppresses an acknowledged session patch at or below
it. Only a watermark is stored, never the observed state: WatchStateSnapshot
cannot hold a container's leaf counts, and keeping max() per key makes the
order two concurrent responses complete irrelevant. Suppression is a
read-time predicate, so nothing mutates during build.

The barrier covers the parentChain too. patchForItem picks the newest of the
item's own entry and its ancestors', so retiring only the item's entry would
let an older season mark win and render watched/0 -- worse than either the
stale value or the fresh one. An authoritative read of a child already
reflects any container mark that preceded it, so the child's observation
judges its ancestors as well; a newer container action still wins.

Provenance decides what may be suppressed at all. WatchStateEvent now carries
serverAcknowledged, defaulting to false so an unclassified emit site degrades
to today's behaviour rather than silently becoming retireable. An offline
write is owed to the server and a read must never retire it, so it stays
until a WatchPatchPromotionNotifier promotion says the queue replayed it. That
channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts
to watched/unwatched by purging queued progress, so replaying one there would
delete a newer rewatch. Promotion matches an exact WatchPatchId -- session
minted for live crossings, derived from the persisted (profile, row, revision)
for queued ones so it still joins after a restart.

Report acceptance is not delivery: PlaybackReportSession resolves true for a
same-state startup heartbeat it drops, so acknowledgement now keys on
onDelivered. A MediaBrowser Started saves play count and last-played date but
not the position, so it cannot acknowledge an offset. No report-derived
watched crossing is acknowledged on any backend -- Jellyfin hard-codes its
threshold and Plex never loads the server pref that would tell it the real
one -- so only an awaited explicit markWatched settles one.

The second defect is that a failed Refresh reported success. Plex _fetchHubs
and the Jellyfin hub legs both degrade a failure to an empty list, and the
library prefetch discarded its failures, so a server whose every hub request
failed was recorded as succeeded; DiscoverProvider then kept the previous rows,
set loaded and surfaced nothing. Worse, the background Continue Watching
refresh wiped the row outright on zero success.

Hub legs now report what they degraded through a HubFetchDiagnostics sink,
which keeps partial rows alongside the failure and leaves every existing
caller untouched. Failures ride through the aggregation results, a leg that
could not run because discovery failed contributes that failure rather than a
successful no-op, and loaded-server ids became succeeded - failed - cancelled
so one bad leg no longer caches a server as covered and blocks its retry. The
toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad
snackbar on failure while the retained rows stay on screen. Rollback after a
mid-pass exception is version-guarded, refilters against the current hidden
libraries and no longer publishes a system shelf the pass never committed.

Observations are staged with the pass and flushed only once the same disposal,
generation and exception checks that authorise committing those rows have
passed, so a discarded or rolled-back response can never suppress a patch.

Also fixes a live data-loss race the promotion work would have built on:
upsertProgressAction stamped a millisecond timestamp and updated the row in
place, so a rewatch queued during an in-flight replay was deleted by id.
Revisions are now strictly monotonic per row, replay deletes and retry updates
compare against them, and the upsert resets the retry fields because a new
revision is a new logical action.

close #1829
2026-08-08 09:09:48 +02:00
edde746 3364b3c22c fix(startup): keep the platform launch screen behind the loading frame
Since 2.10.0 the app opens on a Flutter-owned startup frame, and that frame
paints an opaque themed Scaffold before any preference is readable. Its
themeMode defaults to system, so the theme comes from platform brightness --
and a TV has no system dark-mode toggle, so Fire TV and Shield report light.
The result was a near-white #F7F7F8 sheet held for the whole gate, from
prefs through Sentry to the database open, over an Android window the
television resource qualifier had already painted black. Before 2.10.0 the
gate ran ahead of runApp and no Flutter frame existed to cover it.

Nothing in the loading frame is worth covering the launch screen for. Android
composites Flutter in TransparencyMode.transparent over a window whose colour
MainActivity already restored from plezy_prefs, so the loading Scaffold is
transparent there and the launch screen carries the launch. Every other
platform composites opaquely with nothing behind Flutter, so they keep
painting their own background.

The spinner and the failure screen still need a colour, and platform
brightness is the wrong one for exactly the devices this bug is about, so the
startup frames now adopt the persisted theme once it can be read. TV
detection has to run before that read: the theme_mode default is TV-aware and
isTVSync answers false until its singleton exists, which would resolve a
fresh Android TV install to the light theme. Both singletons are memoised and
awaited again by the gate. The read is best-effort -- an unreadable store is
the gate's failure to report, not this path's -- and it also stops a startup
failure from rendering as a full-screen white error page on a TV.

darkThemeFor and materialThemeModeFor move onto ThemeProvider so the startup
frames and the provider resolve OLED from one mapping rather than two.

Verified on an Android TV emulator in television/notnight mode, clean install,
cold start: peak frame luma 228 for 78 frames before, 0 frames above 120
after, and the same on a returning launch.

close #1833
2026-08-08 08:29:37 +02:00
edde746 24a041977b fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.

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

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

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

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

Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
2026-08-08 00:37:17 +02:00
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00
edde746 feb34caeb7 fix(i18n): shorten nav labels and complete translations in all locales
Nav bar labels that overflow their tab slot on phones are shortened to
idiomatic short forms: fr (Bibliothèque, Téléchargement, Recherche),
ru/bg/it (Live TV), pl (Home).

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

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

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

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

Close #1823
2026-08-07 10:15:48 +02:00
edde746 4816e3928f fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make
the next press rebase off a position the seek has not reached yet. Nothing
retired that pin when something else moved the playhead, so for the ten
seconds it survived, a skip taken after a timeline tap, a chapter jump, an
OS media control or a peer sync resumed from the superseded target and threw
the user back across their own jump.

Publish every playhead movement on the player and retire the pin whenever
the announced destination is not the accumulator's own commit. Overlapping
seeks and backend-chosen relocations arbitrate by which operation the
backend accepted, so a request that was merely asked for cannot speak for
where the playhead ended up.

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

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

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

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

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

close #1816
2026-08-07 08:43:47 +02:00
edde746 f5ccaab3ab test(player): anchor the passthrough absence check to the audio block
The check that keeps Audio Passthrough out of the in-player settings
sheet dragged the first Scrollable ten times and then asserted the
label was absent. That scrolling never moved: at the pumped 900x700
viewport the sheet fits its own content, so maxScrollExtent is 0 and
the offset stays there through every drag. The assertion passed
identically with no drags at all.

It caught a reintroduced toggle only because the whole list happens to
sit in the element tree at rest. Grow the sheet, shrink the viewport or
give it a lazy delegate and findsNothing starts passing because the
label is offscreen rather than gone, with nothing in the test to say
so.

Land on the audio block that used to hold the toggle first, then
assert the absence. scrollUntilVisible throws when that block is
missing entirely, so the guard fails loudly instead of quietly
weakening.
2026-08-07 08:43:47 +02:00
edde746 f63d0fe49e fix(music): shuffle the head of a shuffled queue too
Starting a music playlist, album, or artist on shuffle always opened on
the list's first track: MusicQueueController.load anchored _order[cursor]
and shuffled only the rest, and _startQueue collapsed "no start track"
into startIndex 0, so the anchor was always the head.

Anchoring is right for the two callers that do have a track which must
play first -- the now-playing shuffle toggle, and a load with an explicit
start track -- so make "no explicit start" representable instead of
inferring it from the index: load takes int? startIndex and shuffles the
whole list, head included, when it is null. A start track the list turns
out not to contain now drops the anchor rather than falling back to 0.

Video playback was never affected: Plex shuffles server-side via
/playQueues and Jellyfin already shuffles its full local list.

The queue's Random is injectable so the service-level regression is
deterministic without depending on the SDK's seeded-PRNG sequence.

Close #1811
2026-08-06 06:11:12 +02:00
Tolu AdegbehingbeandGitHub f5488cb7ff fix(player): hold the Watch Together anchor while the host reloads (#1809)
An in-place source switch — audio, subtitle, version or quality — detaches
the host's player for the duration of the reload. `_broadcast` falls back to
a position of 0 when no player is attached, so any state published in that
window names 0:00 as the authoritative position and every guest hard-seeks
to the start of the item.

Heartbeats already suppress themselves while detached, which is why this
hides: the paths that leak the zero are the ones that answer on demand.
`onStateRequested`, `onPeerJoined` and `onReconnected` all broadcast
regardless of whether a player is attached, so a guest entering the player,
joining, or reconnecting mid-reload is the trigger.

Fall back to the last broadcast anchor instead. That field is only assigned
for untargeted broadcasts, so it holds the last position the room was
actually told, and the reload's own re-attach path already re-anchors from
it once the player comes back.
2026-08-06 05:54:17 +02:00
edde746 db4f7a643b test: prune low-value coverage 2026-08-06 05:33:18 +02:00
edde746 094be1fa3e fix(continue-watching): clear the resume position when an item is marked watched
Marking a movie or episode watched left it sitting in Continue Watching with a
checkmark, and the only way to shift it was to play it and skip to the end.

Continue Watching membership on a MediaBrowser server is derived from
UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played
normally zeroes that position as a side effect, so the row usually disappears
and nothing ever checked that it had. When something writes a position back
afterwards the item is left played *and* resumable, which the resume route
happily keeps returning forever. markWatched now reads the UserItemDataDto the
mark already returns and clears the bookmark itself when the server left one
behind, so the postcondition holds however the item got into that state. The
follow-up write costs a request only when the invariant is actually broken.

The writer putting items there is our own offline queue. insertWatchAction
already drops queued progress for an item when the mark is itself queued, but
the online mark writes straight to the server and queues nothing, so a progress
row recorded earlier survived and replayed afterwards — pending actions go out
oldest first — restoring the very position the mark had cleared. The sync
service now listens for watch-state events and discards queued progress for
that item as the mark lands. Progress recorded after a mark is a rewatch and is
queued later, so it is untouched. Plex never showed this because it forwards the
recorded-at timestamp and lets the server discard a stale replay; the
MediaBrowser stop report has nowhere to put one.

Continue Watching also drops the row locally now instead of waiting a round trip
for the refetch to confirm it, matching what removal events already did, and
marking a season or show takes its on-deck episode with it.

Watched items are deliberately still not filtered out of the shelf: Jellyfin
keeps Played set when new progress arrives, so a rewatch in progress is
indistinguishable from a stuck row, and filtering would hide it.

close #1812
2026-08-06 04:21:24 +02:00
edde746 309a107912 feat(downloads): let Android move the app and its downloads to adoptable storage
Declare android:installLocation="auto" so the app becomes eligible for the
Settings "change storage" flow and pm move-package. Adoptable storage relocates
the private data directory with the APK, so downloads follow the app onto a USB
drive adopted by an Android TV.

Moving the app changes the private data directory, which invalidated any download
task already enqueued: those pinned BaseDirectory.root plus an absolute directory
that background_downloader persists verbatim, so a queued or paused download
resumed writing to a volume the app no longer owns. Enqueue app-storage targets
against the base directory the downloader re-resolves from the live app context
instead, and drop the tasks and records a previous location left behind so the
download restarts under the current one.

That sweep runs before the downloader is wired up, because initialization delivers
statuses accumulated while suspended — which can mark the row failed, and a failed
row is deliberately not restarted — and because rescheduleKilledTasks re-enqueues
every killed record it finds, stale absolute directory included.

Compare paths by containment rather than by string prefix while making a stored
path relative. A custom download root that merely starts with the base directory's
name is a sibling the app does not own, and stripping it re-rooted the download
inside app storage.

close #1794
2026-08-06 03:47:43 +02:00
edde746 21cf1ff8d4 fix(exoplayer): recover a stalled playback session instead of spinning on it
A session that lost its sink sat spinning forever: the watchdog measured media
time, which does not advance while the picture is frozen, so a stall could not be
told from an ordinary rebuffer and the recovery ladder was never climbed.

The stall is now judged in playout time against the load control's own view of
whether the buffer was ever enough. Readiness is the union of the three signals
rather than a precedence chain, because media3 stops asking its load control once
a renderer wedges - the very failure this watchdog exists to catch - and a stale
verdict could otherwise hold it shut. The watchdog is armed on every path that
replaces the source, including the same-state reloads that produce no state change
of their own, and a seek rebaselines it so a backward seek does not inherit the
old clock.

Handing over to MPV keeps the position playback reached rather than restarting the
episode, and a play or pause issued mid-handover is recorded on the queued open,
which is the only thing left to command while one core is being disposed and its
replacement does not yet exist.
2026-08-06 03:45:09 +02:00
edde746 f63a4b039a fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was
left staring at a launcher error with no way to link the account. The QR code and
the linking code are now rendered in the app on a car, so the pairing happens on
a phone while the vehicle shows what to scan.
2026-08-06 03:45:09 +02:00
edde746 961e9c0326 feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
2026-08-06 03:45:09 +02:00
edde746 4607d165fd fix(automotive): keep video from starting while a car is driving
DD-3 gives video no exemption: a restricted vehicle must not play it at all. The
gate is read at the single point where media actually opens, so every path that
can start a picture - an explicit play, a gapless arm, a track or channel switch,
a frame-rate-match resume, a reload, and the queue navigation commands of the OS
media session - is covered by one check rather than by a guard at each call site.
A seek can also start playback with no play call, because mpv resumes when it
seeks off the end of a file, so a restricted seek is followed by a pause.

Watch Together needed the pause to be local. A vehicle stopping one peer is not a
room-wide intent: a guest's forced pause is swallowed by the attachment's ledger
rather than published, while a host's still pauses the room, because a host that
kept broadcasting a frozen anchor would stall or rewind every guest it was meant
to protect. The layer that owns a pause owns the resume for it, and one
acknowledgement is recorded per event, so a surplus cannot eat the user's next
real pause.
2026-08-06 03:45:09 +02:00
edde746 3a56218a12 fix(automotive): keep music playing while a car is parked, and silence it while driving
Music ran under a foreground service whose lifecycle observer was registered for
App TV, so backgrounding the app on a head unit never paused it and driving never
stopped it. Both halves were wrong for a car: parked audio must survive the app
going to the background, and DD-2 requires it to stop when the vehicle starts
moving.

The vehicle now owns exactly the pause it caused. It is claimed when a restriction
arrives and discharged on the event that proves the resume, so a track the user
paused during a drive stays paused when the car parks. A restriction landing while
the next source is still resolving silences the native player as well as the
session, because the previous track is still coming out of it, and a pause that
throws ends the session rather than leaving audio running in a moving car.
2026-08-06 03:45:09 +02:00
edde746 7ce5a443fd feat(automotive): read the vehicle's driver-distraction state
Android Automotive tells an app when the car requires distraction optimization,
and Plezy never asked. A monitor now watches CarUxRestrictions and publishes the
verdict over the existing platform channel, where a single Dart gate answers
whether playback may start.

The car service is reached through the lifecycle-listener overload rather than
Car.createCar(Context). That overload blocks its caller for up to five seconds
polling ServiceManager, and on car-service death it reaches killClient(), which
kills the hosting process for any context that is not an Activity or a Service -
a crash in a system component would take the app down with it. Head units on
Android 9 and 10 predate the listener, so a legacy ServiceConnection is used
there, with the same identity guard on reconnect.

A vehicle that has not answered yet counts as restricted, and one deadline is
spent resolving it rather than one per request, so a wedged car service delays
playback once instead of on every open.
2026-08-06 03:45:09 +02:00
edde746 f93952ba6f fix(android): stop tunneling 24p video on the Fire TV Stick 4K
Tunneled playback on an AFTMM judders continuously through 23.976p direct play.
The #1802 reporter isolated it: turning off Tunneled Playback with every other
setting unchanged makes it smooth, and their log shows tunneling active for the
whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing.

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

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

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

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

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

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

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

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

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

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

close #1781
2026-08-06 03:45:08 +02:00
edde746 9d51a040c3 fix(player): keep the remote on the player surface after a window switch (#1797)
Returning to the desktop window with the chrome still up left arrow keys
navigating the OSD instead of seeking: the first press seeked and silently
moved focus onto Play/Pause, and every press after that walked the buttons.

A window blur drops Flutter's primary focus to the root scope, so the player
screen's reclaim parks it on its own node. The only handoff back down to the
controls was the chrome visible->hidden transition, so with the OSD up nothing
reclaimed it -- hence the reported workarounds of letting the controls hide, or
moving the pointer off the player and back. Pointer exit normally hides the
chrome and masks this, which is why it only shows when the pointer stays over
the player while another window takes focus.

Hand the surface back on window re-activation, next to the existing hide-path
claim, and rename the helper since it is no longer hidden-chrome specific. The
claim runs synchronously because a platform callback is not guaranteed to be
followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the
two no longer compete.

Also gate the screen's self-heal so a directional key no longer pulls focus into
the OSD when "Video Player Navigation" is off -- Tab and select keep their path
in, which the ungated return value would otherwise consume with nowhere to go.
2026-08-05 15:24:57 +02:00
edde746 23b8befe11 test(theme): load the deferred locale off the widget tester's fake clock
`await LocaleSettings.setLocale` inside a `testWidgets` body waits on a
deferred library load that only completes on the real event loop, so the
regex-dialog test hung indefinitely rather than failing. It passed only
because an earlier plain `test` in the same file loads `de` first —
running the file alone, under a name filter, or sharded apart from that
test stalled the run until the ten-minute timeout.
2026-08-05 13:17:26 +02:00
edde746 26dbce0277 fix(profiles): name the Plex user and account in one translated chip
A Plex account connection labels itself with the account owner's name.
Under a profile tile that reads as being signed in as the owner: the
Plex Home tile showed the owner beneath the Home user's own name, and a
local profile that borrowed a Home user out of someone else's account
showed only the lender.

Both halves of the relation now go through a single translated string,
so a locale orders them itself instead of inheriting the English
"user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the
account first. When the Home cache cannot resolve the connection's uuid
the chip names the account alone rather than falling back to a bare
name. ProfilesView carries the Home user cache that resolution needs,
and chip labels ellipsize now that an account label can be an email.
2026-08-05 13:05:15 +02:00
edde746 edaff1fbfc Merge pull request #1789 from JackDanger/fix/plex-home-account-chip
fix(profiles): label a Plex Home parent connection as an account

Conflict resolution: regenerated the Slang outputs against main's
translation set, added the empty locale placeholders the translation
gate requires, and gave the new widget test the StorageService provider
the picker now reads for profile recency.
2026-08-05 12:31:29 +02:00
edde746 eaa1736c4e feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a
Device Code app so no client secret or redirect URI ships in the binary
and TV, mobile and desktop all use the same flow.

MDBList omits `verification_uri_complete`, but its device page seeds the
code field from a `user_code` query parameter and the sign-in redirect
preserves the query string, so the activation link is built locally and
the dialog's open button lands on a filled-in form instead of an empty
one. A server-supplied complete URL still wins if one ever appears.

Poll state is read from the response body rather than the status code:
`authorization_pending` and `slow_down` both arrive as HTTP 400, and a
missing grant answers 404 `device_not_found`.

Writes go out as real-time `/scrobble/*` reports plus `/sync/watched`
for the marks that never pass through the player, with ratings on
`/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block
has no `tvdb` field, so a TVDB-only item is skipped rather than written
under an empty id block.
2026-08-05 12:03:05 +02:00
edde746 541fc2c097 test(player): measure AudioTrack release accounting on real hardware
The #1790 fix turns on an invariant no JVM fake can observe: `DefaultAudioSink`
charges a static, process-wide counter per flush and discharges it only from
`Listener::onReleased`, and any lasting imbalance stops media3 escalating audio
failures at all. The wrapper tests pin the wrapper's side of that contract
against a fake; nothing checked it against a real sink.

`onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased`
once per answered flush, both on the public `AnalyticsListener`, so counting them
across the cycles the reuse cache actually creates measures the counter directly
without reaching into media3 internals. Seeking repeatedly exercises the
park-and-reuse path; switching to a fixture with a different channel count forces
the eviction path.

On a Shield with the pre-fix wrapper this reports initialized=5, released=0 after
four seeks — the counter climbing once per seek in a live session, which is the
state that makes a later AudioTrack failure unrecoverable.

A second case runs the same measurement on a bitstream route, which is the output
that failed on the reporter's device. It probes the live route the way the app
does and skips when there is no encoded surround, so a phone or a TV set to PCM
does not report coverage it never had.
2026-08-05 12:03:05 +02:00
edde746 b97a22c213 fix(player): report every AudioTrack release so a failed one can recover
An episode that opens but never plays, forever, with no error and no way out
except force-quitting the app. The reporter's log has the whole shape: media
opens at 85206ms, the first video frame renders, `AudioTrack init failed 0
Config(48000, 252, 5, 40000)` is logged exactly once, and the position never
moves again. Force-quitting fixes it for a while, which is the tell — the state
that breaks recovery is process-wide and static.

`DefaultAudioSink` releases its `AudioOutput` on every flush — every seek, every
renderer disable, every reconfigure — and increments a private static
`pendingReleaseCount` as it does. It decrements only from `Listener::onReleased`.
`RawPositionAudioOutput.release` never called `delegate.release()` for a
cacheable output, and it forwarded `addListener` straight through, so the sink's
listener sat on the real output while the wrapper was parked and the increment
was never balanced. media3's own delivery is lossy too: it posts `onReleased` to
the playback looper, which `ExoPlayer.release()` has already quit by the time the
20ms-delayed release runs, so even a real release drops its decrement at
teardown.

A counter that never returns to zero silently disables media3's escalation of
both init and write failures: `PendingExceptionHolder` arms its throw deadline
only when nothing is pending, and short-circuits every retry while something is.
So the `InitializationException` is never thrown, the audio renderer never
becomes ready, and the player is pinned in `STATE_BUFFERING`. No
`PlaybackException` means `retryAfterAudioTrackError` never runs, which is why
the same failure recovered onto decoded PCM earlier in the same log and hung
outright later.

The wrapper now owns the listener set and answers every flush exactly once: at
once when it parks the track, because a parked track is never going to release;
on the delegate's confirmation for a real release; and from the provider at
teardown, where nothing else ever will. Bitstream outputs are not parked at all —
a direct route is often single-instance and a parked one would block its own
successor.

An eviction therefore builds its replacement while the old AudioTrack is still
going away, as upstream does. Holding the count open across the park to buy
media3 patience for that window was tried and is worse: it pins the counter above
zero for the whole live track after the first seek, which is the hang above.
Refusing to allocate until the release confirms is worse too — the refusal
reaches media3 as an init failure with no pending release to excuse it, so the
200ms deadline starts immediately and a slow TV teardown turns an ordinary config
change into a playback error. If the overlapping allocation does fail, media3
escalates into the audio recovery ladder and the watchdog below backs it up.

Because no amount of accounting hygiene guarantees media3 will raise the next
failure, add the watchdog that was missing. Nothing covered "buffering, holding
data, not moving": the frame watchdog wants `STATE_READY` and zero frames, the
decoder-hang check is cancelled by the first frame, `ResumeStallPolicy` treats a
frozen clock as explicitly not its business, `EndOfStreamPolicy` wants the
position past the duration, and media3's stuck-buffering detector wants an empty
buffer. `BufferingStallPolicy` covers exactly that hole and escalates through the
existing audio ladder — now shared with the exception path — then to the mpv
backend rather than leaving a spinner up.

The watchdog only indicts a player that could have started. `DefaultLoadControl`
is configured to hold playback until 5s is buffered after a rebuffer, so the
stall threshold is derived from that same constant rather than guessing at one,
and a buffer below it reads as starved — the loader's business, not the
renderer's. Starvation also restarts the stall clock, so a minute of network
rebuffering cannot bank the timeout and have the first poll after recovery
report a stall that never happened.

Also raise the passthrough buffer to a second. media3 defaults it to 250ms, which
the AC3 factor doubles to the 40000 bytes that failed here, and 1.10.1's only
retry is to keep halving; upstream adopted the same 1s floor in #3207.

Recovery now resumes from the furthest position reached rather than `lastPosition`,
which the poller writes down as freely as up — a dead clock reporting 0 is how an
audio recovery restarted a resumed episode from the top. On the Dart side the
episode loading flags are cleared on every exit of the in-place reload, not just
the success and rollback paths; a flag stranded by a superseded reload made the
Next button a no-op for the rest of the session.

close #1790
2026-08-05 12:03:05 +02:00
80d3537975 fix(linux): do not schedule audio recovery after playback resume (#1786)
This was resulting in two audio stutters per playback resume.

Regression originates in 9f2e05079 (release 2.10.0).

Co-authored-by: Torin Cooper-Bennun <torin.cbennun@googlemail.com>
2026-08-05 11:55:28 +02:00
Vincent ValleeandGitHub 8879941d29 fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on
Windows and Linux whenever HTPC-style player navigation was off, so it dropped
the window out of fullscreen regardless of who put it there. For anyone running
with "start in fullscreen" (or who had toggled fullscreen from the browse UI),
backing out of a movie left the app windowed, with "exit fullscreen on player
close" switched off.

Track fullscreen ownership instead: FullscreenStateManager now exposes a scope
that the player opens in initState and closes in dispose, and setFullscreen —
the single funnel every desktop platform reports through (window_manager on
Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) —
records whether the fullscreen currently active was entered inside that scope.
Escape only exits fullscreen the player itself entered; otherwise it is plain
Back. The scope is depth-counted so the next-episode swap, where the incoming
screen's initState runs before the outgoing screen's dispose, carries ownership
across rather than resetting it.

Nothing changes for a user who fullscreens from inside the player: Escape still
exits fullscreen first, then acts as Back. The fullscreen toggle button and its
shortcut are untouched, as is exitFullscreenOnPlayerClose.

Fixes #1624.
2026-08-05 07:14:11 +02:00
edde746 3b0b407cd0 docs: list Emby alongside Plex and Jellyfin
Feature footnotes distinguish the two MediaBrowser backends where they diverge:
favorite and unwatched filters work on both, while Quick Connect stays
Jellyfin-only because Emby exposes no such route. LAN discovery covers both,
since Emby answers only its own datagram.
2026-08-05 06:09:27 +02:00
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00
edde746 f36e20bcad fix(profiles): keep the profile picker highlighted while its list sorts
The picker resolved StorageService asynchronously and rebuilt its profiles
stream once it landed. Storage is what supplies profile recency, so the second
view arrived re-sorted a microtask after first paint. The sliver children
carried no keys, so that reorder handed each tile's Element the next profile's
focus node; detaching the old node dropped primary focus onto the enclosing
scope and took the D-pad highlight with it. The launch picker has no back
route on tvOS, so a user who can no longer see or move the selection has
nothing useful left to press.

Read StorageService from the provider graph, where it is already resolved
before any route exists, so the stream is built once and the first painted
frame is already recency-sorted. Key the tiles and add findChildIndexCallback
so a later re-sort from a refreshed profile source moves a tile instead of
destroying it: without the lookup the sliver re-inflates the tile, which keeps
primary focus but resets FocusableWrapper's chrome to unfocused.

close #1792
2026-08-05 06:09:26 +02:00
github-actions[bot] d7c4ea0a8b chore: update cask to 2.12.1 2026-08-05 03:57:31 +00:00
Jack DangerandClaude Opus 5 dcc06768e0 fix(profiles): label a Plex Home parent connection as an account
A Plex Home profile's chip rendered the parent connection's displayLabel,
which for a Plex account is the account owner's username. The owner's name
appeared directly beneath the Home user's own, reading as the wrong user
being signed in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fdzda9t7kQtq7LoQ2v5nVF
2026-08-04 10:11:38 -07:00
github-actions[bot] ddc3a4d77c chore: bump version to 2.12.1 2026-08-04 14:59:38 +00:00
edde746 58d5d3c4ef fix(plex): read external ids from legacy agent and HAMA AniDB guids
Plex only builds the `Guid` array for the Plex Movie / Plex TV Series
agents. A library still on a legacy agent answers with the scalar `guid`
alone, so `fetchExternalIds` returned nothing for it and every consumer
went quiet: trackers logged "no external IDs" and skipped the write,
manual ratings showed "Not available", the detail screen dropped its
watchlist button, and Continue Watching stopped collapsing duplicate
copies. The reverse lookup already read that scalar; only the forward
path ignored it.

Read both shapes from the one request the method already makes, with the
array winning per field and the scalar filling the rest.

HAMA identifies anime by AniDB id and nothing else, which no id set could
carry. AniDB is the Fribb mapping's own primary key, so it now travels on
`ExternalIds` and indexes those rows directly — 7177 of them expose no
tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain
`anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one
TVDB-numbered show, so the guid names the root entry only.

Two guards keep the new id where it means something. It is trusted for
season 1, because that mode puts the anime there and its specials in
season 0, while a higher season means the library is numbered by TVDB
instead. And it resolves nothing for Trakt and Simkl, which never map
anime and cannot address an AniDB id, so they keep reporting no ids
rather than failing silently further down. `hasCatalogIds` marks the
callers that can only speak IMDb/TMDB/TVDB.

close #1788
2026-08-04 16:56:54 +02:00
edde746 4872adcde3 feat(player): keep the session's explicit track choices across episodes
Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.

Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.

Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.

close #1785
2026-08-04 16:10:02 +02:00
edde746 61ae314c94 fix(player): tell same-language subtitle rows apart across episodes
The committed track kept the server display title, which collapses to the
bare language ("English") and is identical for every same-language row: a
carried signs/songs choice tied with the full dialogue track on the next
episode and latched onto whichever row sorted first. The row's own title
is preferred now, so the carried intent names the exact row again and the
native pass can match the right container track by title instead of
language order.

Reproduced against a live library where both English ASS rows differ only
by Title ("Styled Subtitles" vs "Signs/OP/ED").

close #1785
2026-08-04 15:22:46 +02:00
github-actions[bot] d82a5c5cd7 chore: bump version to 2.12.0 2026-08-04 13:03:59 +00:00
edde746 fdd4c661fe fix(player): carry a picked subtitle language across episodes with sparse tags
The cross-item subtitle intent required declared languages on both sides,
and a null on either side counted as a contradiction. Any untagged track -
common when a title like "Swedish" is the only signal - declined on every
episode advance, fell to the server's per-item priority, and turned the
viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard
gates).

A unique real title match now vouches for a row when language evidence is
missing on either side. Declared languages that disagree still decline no
matter what the title says, forced-class parity is untouched, codec and
external parity only break ties within the title-matched set, and a
residual tie declines rather than guesses, so the wrong-track class of
#1716 stays closed.

A decline is also no longer laundered into a viewer decision: the resolver
keeps the unserved preference on the selection, the open flow hands it to
the track manager instead of a navigation-priority off (late native tracks
may carry the container tags the server rows lack), the next episode
boundary re-carries it instead of hardening it into an explicit off, and
progress reports withhold the -1 subtitle index that would otherwise come
back as the item's server-side default forever.

A pick the screen could not map to a source row (no subtitle catalog, or
an identity-matcher miss) previously never reached the committed session
selection at all, so the next episode carried the stale off while the
picked track was visibly on screen. Such picks now commit the raw native
track without source ids and demote to a semantic intent at the boundary.

close #1785
2026-08-04 13:51:20 +02:00
edde746 439ae1d733 perf(detail): paint a show before its on-deck episode is looked up
Jellyfin has no equivalent of Plex's bundled `?includeOnDeck=1`, so a show
detail open chained `/Shows/NextUp` behind the item fetch and the screen sat
on a spinner for both round trips. The second one is not needed to paint:
everything except the play button's episode label comes from the item.

`fetchItemWithOnDeck` now takes an `onItemReady` callback and invokes it as
soon as the item is known, when that is strictly before on-deck settles.
Plex returns both together and never invokes it.

Phone and desktop only. TV keeps its own reveal gate — `_isTvDetailReadyToReveal`
holds the foreground at opacity 0 until extras, related hubs, seasons and the
first episode page have all loaded, and those still run after the on-deck
lookup settles, so TV sees no change. Both halves are pinned by tests.

Measured on a remote Jellyfin server, 15 interleaved show-detail opens per
version: time to content 1264ms -> 1042ms (-18%), with the rest of the load
unchanged.

Seasons and extras deliberately still start after the whole lookup settles.
Starting them at the early paint measured worse (time to settled +21%)
because they contend with the on-deck request instead of overlapping it — the
same reason `/Shows/NextUp` is not fired in parallel with the item fetch.
That trade-off is also why TV was left alone rather than being unblocked by
moving those loads earlier.

Two ordering hazards the early paint introduces, both covered by
`media_detail_screen_test.dart`:

- The early call must not write on-deck. `_loadFullMetadata` runs again after
  playback, and clearing there would blank the play button for the length of
  the round trip. `onDeckSettled` marks the authoritative write, so a reload
  that finds the series finished still clears it.
- A settled empty on-deck must not drop the episode-derived fallback that
  `_ensureFallbackOnDeckEpisode` supplies.

close #1784
2026-08-04 08:38:19 +02:00
edde746 a759e8b3c6 perf(jellyfin): fetch a detail item once when several callers want it at once
Opening a detail screen issued two identical full-detail GETs for the same
id, concurrently: `_loadFullMetadata` calls `fetchItemWithOnDeck`, and
`_initWatchlistState` calls `fetchExternalIds`, which fetches the same item
purely to read `ProviderIds`. Playback start adds three more for its own id.

Each of those makes the server rebuild the entire dto — `People`, `Chapters`
and `MediaSources` cost a database query apiece and `Trickplay` costs several
plus a filesystem stat — so the duplicate is expensive on both ends.

`fetchItem` now shares an in-flight request per item id. Single-flight only:
once a request settles the next caller re-fetches, so nothing can serve a
stale item.

Measured on a remote Jellyfin server, 12 interleaved show-detail opens per
version: requests 4 -> 3, payload 28.4 KB -> 18.6 KB. Median wall time is
unchanged (1394ms -> 1386ms) because the duplicate ran alongside the first
rather than behind it; this removes duplicated work, not latency.

Two things were tried and rejected because measurement did not support them:
starting `/Shows/NextUp` in parallel with the detail fetch (the requests
contend rather than overlap — NextUp went from 380ms alone to 1395ms beside
it — and it costs a wasted request per movie), and dropping `Trickplay` /
`Chapters` from the detail field set (no measurable effect; both are real
data the playback path reads).

Refs #1784
2026-08-04 06:39:23 +02:00
edde746 74d3af3ae1 perf(home): load the home screen once instead of twice per cold start
The Discover tab fanned out its whole request set twice on every cold
start and replayed slow rows on a shrinking timeout ladder, so a healthy
remote server produced anywhere from 4s to 15s of loading.

Measured against a remote Jellyfin server with four libraries, 24
interleaved cold-start samples per side:

  requests  19 -> 9      payload  219 KB -> 94 KB
  settled   5231ms -> 2502ms median, 13222ms -> 5927ms p95

Four independent causes:

- Retry policy. `Client.send` resolves on response headers, so the
  connect budget covers the server's think time and a slow-but-alive
  query raises `connectionTimeout`. Replaying it made the server re-run
  the query with a shorter budget than the one it just missed; the
  `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after
  23s. Hub surfaces now get one whole-request deadline, retry only
  immediate connection errors, and the deadline bounds the whole call
  including the request still in flight.

- Request shape. `/Items/Latest` groups a TV library by series, so its
  rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost
  a DB count each, per row. Hub rows now ask for `Overview` only; watch
  state survives because Jellyfin derives `UserData.Played` from
  `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp`
  sends `NextUpDateCutoff` to bound the server's series-key scan, and
  `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData`
  and `PremiereDate` leave the browse set: neither is an `ItemFields`
  member, so the server dropped them anyway.

- Fan-out. Per-library hubs ran in batches of three separated by a
  barrier, so one slow library stalled every library behind it. A
  sliding window keeps the same peak concurrency without head-of-line
  blocking. Concurrent `fetchLibraries` calls now share one `/Views`
  instead of racing two identical round trips, Plex's global and music
  hub legs start together, and Jellyfin gets Plex's pool tuning.

- Duplicate pass. `DiscoverScreen.initState` starts a load and the
  online-entry hook asked for a full refresh on top of it, which
  `CoalescedLoadCoordinator` correctly queued as a trailing pass. The
  hook now calls `primeRefresh`, which rides along with a load already
  in flight; profile switches still go through `fullRefresh`.

Refs #1784
2026-08-04 04:35:06 +02:00
edde746 8624c37041 fix(profiles): notice a corrected connection creation time
ActiveProfileProvider diffed connections on toConfigJson alone, but
createdAt is a real column and now decides which connection lends a
profile its picture. A creation-time correction was therefore invisible
to the guard and left a stale avatar until the next launch.

Compare createdAt alongside the config. ConnectionRegistry pins
creation order across re-authentication, so this adds no notifications
in normal operation — it only stops an out-of-band correction, such as
a restore or a backfill, from being swallowed.
2026-08-04 02:22:44 +02:00
edde746 7a19f4149e fix(connections): keep a connection's creation time across re-authentication
ConnectionRegistry.upsert already preserved isDefault on conflict but
rewrote created_at from the in-memory model. Re-signing in rebuilds the
connection with DateTime.now() under the same stable id, so the row's
creation time jumped forward on every reauth.

That was cosmetic while created_at only drove list ordering. It is now
behaviour: it picks which connection lends a profile its picture, so
re-adding the originally-first connection could hand the avatar to a
later one. remove() also promotes the oldest remaining row to default
and was reading the same restamped value.

Preserve the existing row's created_at on conflict, reusing the lookup
upsert already performs for isDefault.
2026-08-04 02:22:44 +02:00
edde746 860ce1e11a feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.

Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.

The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.

close #1667
2026-08-04 02:22:44 +02:00
edde746 2b4875d389 fix(player): keep hidden and cycled subtitles off in the next episode
Episode navigation carries the subtitle choice this screen has committed, so
a way of turning subtitles off that the screen never sees is undone by the
next episode.

ExoPlayer has no renderer-level visibility switch, so the player's hide
toggle is emulated by deselecting the track. That emulation lasted until the
next selection: the automatic pass after an episode change put subtitles
straight back on screen while the toggle still read "hidden", and un-hiding
then restored a track id belonging to the episode that had already ended.
Hiding is now sticky across media opens the way mpv's global sub-visibility
is, selections made while hidden become what un-hiding restores, and the
toggle no longer refuses to restore because the hidden track reads as Off.

Cycling subtitles over the native track list — downloads, and items whose
server exposes no subtitle rows — went straight to the track manager, which
owns the player selection and the server write-back but not the committed
choice. The screen records the cycled track now.
2026-08-03 17:07:14 +02:00
edde746 e7aa1e4782 fix(jellyfin): stop overriding a server subtitle mode of None
Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the
user's SubtitleMode is None: the index is the server's whole answer, and null
means it picked no subtitle. The mapper read null as "the server did not say"
and promoted the container's default/forced flags to a server selection
instead, which outranks the profile subtitle mode in the selection ladder. A
viewer who had turned subtitles off for their Jellyfin user got them switched
back on by every item that carried a default or forced row.

Only the row the server names is selected now. A stream the viewer picks, and
an explicit off, still survive per item because Plezy reports the index
through playback progress and the server hands it back as that index or -1.

close #1779
2026-08-03 17:07:05 +02:00
edde746 bbaf5f0f9e fix(music): replace the Instant Mix faders with a wand icon
The three vertical faders read as an equalizer in a music context and
are the vertical twin of the video player's settings icon. Use
wand_stars, which names what the action produces and collides with no
neighbouring affordance in the action bar or the music context menu.

close #1629
2026-08-03 13:29:27 +02:00
edde746 0f5e5c8b6e feat(music): offer File Info on tracks and other file-backed items
The context menu only offered File Info for movies and episodes, so a
track's path, container, and audio stream detail were unreachable even
though both backends already answer getFileInfo for them.

Gate the entry on the new MediaKind.hasFileInfo instead of a literal
kind list: movies, episodes, tracks, and clips are leaf items with real
files, while shows, seasons, artists, albums, collections, playlists,
and folders carry no Media/MediaSources and would only ever produce the
"not available" snackbar.

Also fix the Plex stream classifier, which mapped streamType 4 to an
embedded image although PlexStreamType.lyrics is 4. Only music tracks
carry that type, so a track's lyric stream rendered under "Embedded
Images" with the video field block. Type 5 was invented outright and is
now unknown.

close #1747
2026-08-03 02:34:37 +02:00
edde746 a9f0532f5f fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is
36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16
disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only
SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on
API 35+ the window is edge-to-edge from the first frame and
MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation.

MainScreen's phone layout hides that. It supplies a bottomNavigationBar and
never sets extendBody, so Flutter's Scaffold strips padding.bottom from the
body MediaQuery and every tab is already safe. Routes pushed on the profile
navigator are full-screen siblings of MainScreen with no bottom bar, so they
receive the untouched inset and nothing consumes it - the last settings card
and the final log lines render under the back, home, and recents buttons.

Three shared hosts own most of those routes, so the inset is consumed there:
FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and
FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing
SliverSystemBottomInset, and the four screens that build their own Scaffold
around a CustomScrollView append it directly.

The new widget codifies the convention this repository had already written
down but open-coded - insets baked into the scroll content rather than a
SafeArea around the scroll view - so content still paints under the bar while
the scroll extent grows enough to bring the last row above it. It reads
padding from its own context and collapses to zero height wherever the inset
is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside
MainScreen's tab bodies. No platform branching, and it stacks additively with
the music detail screens' existing mini-player spacers, which is correct
because the mini-player itself floats above the navigation bar on a pushed
route.

Scroll views that are not sliver lists take the inset in their own padding:
the companion remote's ListView, the auth screen's scroll container, and the
two SliverFillRemaining sign-in forms, whose children size themselves from
the extent remaining before them and so cannot be helped by a trailing
sliver. The logs empty state is left alone for the same reason inverted - it
already fills the viewport, and a trailing inset would only add scroll slack.

Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation:
Settings, Logs, and Video Playback all end clear of the bar.

close #1766
2026-08-03 00:05:21 +02:00
edde746 fb0613e3db feat(player): toggle playback on a two-finger tap without raising the chrome
A touch viewer had to raise the chrome to pause, which dims the picture and
covers the subtitle line they were trying to finish reading. A two-finger tap
now toggles playback with the chrome left down, so the frame that pauses is the
frame that was on screen. It fires the moment the chord resolves, in every
player state.

The two-finger double tap no longer resets the video zoom. Keeping it would mean
holding this toggle back for the double-tap window before acting, and pausing
late is pausing on the wrong frame. Zoom reset stays in the video settings sheet,
its presets and the keyboard shortcut, and pinching back to 100% now snaps
exactly within three percent so touch has a one-gesture path too.

Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert
under screen lock, in PiP and while the content strip is open; the zoom reset
previously fired straight through a locked screen.

close #1505
2026-08-03 00:04:03 +02:00
edde746 9c08c78f6d fix(plex): stop recording a second play when the server already logged one
Plezy reported a completed playback twice: the /:/timeline heartbeats let
the server mark the item played on its own, and the in-player auto-scrobble
then sent an explicit /:/scrobble for the same watch. On PMS 1.30 that adds
a second Play History row; on 1.43 the row is suppressed but viewCount still
lands on 2 for one playback.

Measured against PMS 1.43 to find what the server acts on: a watched-threshold
crossing observed inside one session. Consecutive above-threshold reports mark
nothing, a resume point left by an earlier session does not arm a new one, and
a report at position zero is inert while one at a single second is enough. So
the explicit mark now goes out only for sessions that gave the server no
crossing to observe.

That decision cannot be made while the session is live. A session beginning
past the threshold has no crossing yet, but rewinding and playing forward
creates one, and the server records it — marking eagerly and then hitting that
path leaves viewCount at 2 again. The mark is therefore deferred to the
terminal stop, and rides its future so callers that await the stop before
tearing the player down do not drop it. Deferring also covers a crossing
coalesced away during startup and a seek back below the threshold before
stopping.

Crossing state is tracked from reports the backend actually received rather
than from PlaybackReportSession.report()'s bool, which resolves true for a
same-state snapshot dropped during startup.

The same-file sibling hook (#1500) still runs exactly once, on the transition
to a settled mark rather than at the local crossing, so sibling episodes are
never marked watched while the episode actually played is not.

Local watched state and Continue Watching removal still happen on the observed
crossing, so the only behaviour that moves is the redundant server call.

close #1740
2026-08-02 16:16:19 +02:00
edde746 95b013e155 fix(seerr): show worldwide popular titles in Explore again
Overseerr and Jellyseerr bind the `language` query parameter of
`/discover/movies` and `/discover/tv` to `originalLanguage`, which becomes
TMDB's `with_original_language`. Sending the app locale there collapsed both
shelves to titles originally made in that language, so a Portuguese UI saw
only Portuguese films. Those two routes take their display language from the
instance/user locale, which already wins over the query value, so the
parameter was pure filtering with no localization to show for it.

Drop it from the two paged discover routes. Trending, both upcoming rows,
search, details and recommendations keep it: Seerr treats it as the display
language everywhere else.

close #1763
2026-08-02 15:35:42 +02:00
edde746 d83d0790ba fix(exoplayer): match side-loaded subtitles after media3 rewrites track ids
Plex sidecar subtitles are attached as MediaItem.SubtitleConfiguration and
tagged `external_<n>`, then recovered from the Format id the track selector
reports. Since media3 1.3.0, DefaultMediaSourceFactory always merges
side-loaded subtitles with the primary source and MergingMediaPeriod rewrites
every child format id to "<periodIndex>:<originalId>", so the tag arrives as
"1:external_0" - measured on device - or "0:1:external_0" behind the
container-sidecar merge. The prefix test therefore never matched and every
sidecar reached Dart as an embedded track with no URI.

A Plex sidecar's only identity is its stream key, which the app carries in
that URI, so both matchers failed on it: a server-selected sidecar could
never resolve and left subtitle selection pending, and a manually chosen one
could not be mapped back to a stream id to write to the server. The
already-attached branch of addSubtitleTrack compared the raw id too, so
re-selecting a loaded sidecar silently did nothing.

Route every write and readback of the tag through ExternalSubtitleIds, which
matches the final id segment, and cover it with an instrumentation test that
side-loads a subtitle through the real media3 media-source factory. Also stop
claiming a saved track selection when no server stream was identified - there
is no local store, so that path silently dropped the user's choice.

close #1713
2026-08-02 12:19:28 +02:00
edde746 bbed260169 fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The
chrome controller is born visible, and its auto-hide clock cannot arm until the
first frame lands, so the controls did not merely appear early: they appeared
exactly when the picture did, and then sat over the opening five seconds of
every movie and episode. The timeline is gated behind the first frame, so the
bar materialised on top of the video rather than over the loading spinner,
which is what makes it read as a pop-up rather than as chrome that was already
there.

The route now opens with no chrome on TV. Nothing is lost: the loading spinner
and buffering overlay are their own overlays, the screen focus node owns back,
and the first D-pad press raises the controls the way it already does after
every auto-hide. Pointer and touch platforms keep the chrome, where the
viewer's hand is on the surface and the title and back affordance belong over
the spinner.

Initial presentation now follows initial visibility. They were separate:
seeding only visibility would leave the route claiming its chrome was still
presented, so PlayerNavigationCoordinator would read back as "hide the chrome",
hide() would no-op against chrome that was never up, and the press would be
swallowed instead of leaving the player.

Controls that mount with the chrome already down now claim focus themselves.
Focus normally reaches them through the hide transition, and their own
autofocus cannot win it back because the screen node took it during the loading
phase. Left alone, the screen node kept primary focus and its self-heal raised
the entire OSD on the first D-pad press, which put the chrome straight back
over the picture and bypassed the transient seek and transport indicators.

Both player spinners now carry a label. They were bare progress indicators, so
a screen reader announced nothing at all while the picture was coming up, and
the TV Maestro flows had no way left to tell a loading player from a playing
one once the Pause button stopped appearing on its own.

The two TV flows are repaired to match. They waited on that button, and now
wait for the labelled spinner to clear, which cannot happen before the media is
opened. 05 additionally reaches Search by D-pad rather than a percentage
coordinate, because a tap flips InputModeTracker to pointer mode and collapses
the rail it is aiming at, and it gates on the play-next prompt's own Cancel
action: "Next Episode" is also the credits skip button, so the old assertion
could pass without the prompt ever opening.

close #1765
2026-08-02 11:45:27 +02:00
edde746 35061f9f68 fix(jellyfin): scope global search to visible libraries
Jellyfin search rows do not expose their owning collection, so hidden libraries cannot be filtered after the response. Scope searches to visible libraries, stamp the returned rows, reuse the latest loaded views, propagate cancellation, and fail closed when views cannot be loaded.

Keep full candidate budgets and split music libraries into parallel album, audio, and artist requests. Album requests disable UserData and use the existing album field set to avoid recursive per-album work from #1552; audio requests retain cheap leaf play state.

close #1770
2026-08-02 09:29:59 +02:00
edde746 1d9ffb7427 fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a
hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue
Watching and the library rail still surfaced their contents in the Search
tab. Thread the profile's hidden keys from SearchScreen through to the
aggregation, and drop matching items between the fan-out and the ranking
pass so hidden hits cannot spend the result limit and shrink what is
shown. Items the backend cannot attribute to a library, such as Plex
shared and external media, are kept.

The screen re-runs the visible query when a library is hidden or unhidden
while results are on screen. Its listener is attached only after the
provider has hydrated, so the initial load notification cannot race the
first query into running twice.

Plex search rows now go through the library-aware tagger, so a response
that names its section only via librarySectionKey or
targetLibrarySectionID is still attributable, and therefore filterable.

Jellyfin search results carry no library id at all: the mapper's
ParentLibraryId is not a Jellyfin field, and ParentId resolves to a
season or physical folder rather than a CollectionFolder. Filtering there
needs server-side ParentId scoping and is left for a follow-up.

close #1770
2026-08-02 09:29:59 +02:00
edde746 bac2a0d201 fix(player): keep Delete and Home editing text in player sheets
Bare Backspace and Home are player navigation keys, but they are also
caret editing keys. The player screen's Focus wraps its OverlaySheetHost,
so it saw them before the subtitle-search field could act: the press was
consumed on key-down, DefaultTextEditingShortcuts never turned it into a
deletion, and the back pipeline hid the chrome and then left the player.

A focused text editor now takes both keys back, but only for physical
keyboard presses — a synthesized dpad/gamepad press has no caret, and
browserHome has no editing role at all.

The screen also resolved its overlay-sheet controller from the State's
own context, which sits above the host it was querying, so the lookup
always returned null and Back skipped the sheet stage entirely. Resolve
it from a context below the host instead, matching NowPlayingScreen.

close #1741
2026-08-02 07:37:12 +02:00
edde746 2a7e5f4f9c fix(jellyfin): ask the server who may delete before offering it
Jellyfin never consults IsAdministrator when authorizing a library
delete: BaseItem.IsAuthorizedToDelete looks at EnableContentDeletion and
the per-library grant, and only the first user a server creates gets the
former for free. Gating the "Delete from server" entry on the admin bit
therefore offered a destructive action that answers 401 to later
administrators, and hid it from plain users who do hold the grant.

Ask the server per item instead, through the new
MediaDeletionPermissionClient capability: BaseItemDto.CanDelete already
folds the global grant, the per-library grant, and item state such as
missing files or an in-progress recording. The probe runs when a menu
opens on a deletable kind, costs ~0.5 KB, carries a whole-request
deadline because the client's own budget covers connect and receive
separately, and fails closed on anything unknown. Plex keeps its
account-level owner/admin gate; it has no per-item permission on the
wire.

close #1749
2026-08-02 07:08:14 +02:00
edde746 bc0d14a749 fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore
"In these libraries" chooser could never show more than one copy per server.
A movie held by both a 4K library and an HD library on one Plex server
therefore resolved to whichever copy came back first, with no way to reach
the other.

Return every id-verified match instead. `/library/all` is already
server-wide and each `Metadata` entry carries its own `librarySectionID`,
so both copies come back labelled with no extra request; Plex was simply
taking `Metadata[0]` and the title ladder was returning on its first hit.
An exact-guid hit no longer short-circuits the title search either — a
library still on a legacy agent has a different primary guid and is
invisible to the `guid=` filter.

Copies are deduped by global key and ordered best-first, and each row now
states its resolution, since library names need not mention it.

Resolution passes merge rather than replace: the cross-server fan-out logs
and skips per-server failures, so a later pass can come back short a server
that answered an earlier one, and a failed pass no longer claims the title
left the library. Duplicate keys fold field by field, because Jellyfin's
library stamp is a best-effort ancestors lookup that returns the item bare
when it fails and an unstamped row is indistinguishable from its sibling.
Focus nodes are keyed by copy and reclaimed after a merge re-sorts the
rows, so a dpad user is not thrown to a different copy.

close #1754
2026-08-02 06:40:04 +02:00
edde746 f78f65faf5 chore(player): report marker counts when loading playback extras
The extras loader logged only the chapter count, so a user report of
"auto skip never fires" could not be told apart from "the server has no
intro marker for this item" — the two need opposite fixes. Log the
marker count and types on all three load paths, including the cache-only
one that previously logged nothing at all.

Drop PlexVideoPlaybackData.markers while here: the playback-start parse
filled it on every item and no caller ever read it, because the player
controls fetch their own PlaybackExtras.

Document why getPlaybackExtras may serve the shared metadata cache row
without a freshness check: getPlaybackInitialization refreshes that row
network-first before the controls mount. That ordering is what makes
cache-first correct, and nothing said so.
2026-08-02 04:55:54 +02:00
edde746 957711a650 fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of
10336 bytes, every one of them zero, and reported each as "still failing". The
gate classified it correctly every time and the consented repair would have
cleared it in-process, but nothing on the failure screen said so: Retry was
first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside
it as a tonal afterthought. Retry re-reads the same document, so for a
corrupt-store failure it is an action that cannot succeed however many times it
is pressed — and it was the one the screen recommended.

Repair now takes the primary styling, the focus node and first position whenever
it is offered, and the body text says plainly that retrying will not help.
Retry keeps its place for every other failure, where a locked database or a
denied directory really can change between attempts.

The consent dialog was also promising an outcome it could not always deliver.
Servers and profiles survive a repair only because their tokens are ciphertext
in the database and the key that decrypts them lives in the store, so a store
the key cannot be read out of signs the user out of everything — exactly the
all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without
touching it, and the dialog now names the real cost from that. The retained copy is
labelled as holding credentials unless the bytes prove otherwise: what the
salvage recovered says nothing about what the file still contains, because a
store truncated mid-value keeps most of a vault key in plaintext while the
salvage pattern — which needs the value's closing quote — matches nothing at
all. Only an all-zero file drops the warning, so the one case that cries wolf
is the one that provably holds no secret.

`describe()` finally carries whether a repair was on offer. That line is the
difference between a report a maintainer can act on and two days of guessing
whether the button was even on screen.

close #1732
2026-08-02 04:37:29 +02:00
edde746 2cb2c3eb95 feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor
took `rating` and fell back to `audienceRating` only when it was null, so a
Plex movie carrying four attributed scores surfaced one, and which one was
whatever the server happened to put in the scalar slot. #1755 asked for a
setting to choose the source; showing all of them answers it without one.

The data was already on the wire and being thrown away. `/library/metadata/
{id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels,
TMDB — with no extra query parameter, but PlexMetadataDto declared no field
for it, so json_serializable dropped the key. The identical parse already
existed in plex_catalog_source for the Explore tab and had simply never been
wired to library items.

Model the scores as a list rather than widening the scalar pair. The neutral
MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage
and audienceRatingImage, which the list subsumes — Plex sends those images
on listings too, so the same field covers both response shapes and no caller
narrows to a backend type to read a score any more. CatalogRatingSource is
promoted to lib/media as MediaRatingSource instead of growing a second
near-identical type beside it, and plex_catalog_source's _ratingsFor becomes
the shared plexRatingSources so one implementation serves both paths. There
is no persistence to migrate: MediaItem.toJson has no production caller, the
offline path re-parses raw Plex JSON through the same mapper, and Plex's
audienceRating sort is server-supplied data, not a model read.

Cards and the dashboard still show fewer scores than detail screens, and
that part is a real Plex limit rather than a shortcut. Section listings send
only the scalar pair; includeRatings, includeElements=Rating,
includeFields=Rating, includeChildren and includeExtras were each probed
against a live server and none surfaced the array, while includeGuids=1
demonstrably does add Guid[] — the probe works, the parameter does not
exist. Hydrating every card would be one request per row, so listings render
whatever their own response carried, which is one or two attributed scores
rather than the single one they showed before.

Jellyfin has no per-source array at all: the server collapses whatever its
fetchers found into CommunityRating and CriticRating. CommunityRating's
provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or
a local NFO, last writer wins — so it stays the generic `audience` source
with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a
0-100 percent and is divided by ten explicitly rather than folded by
magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have
promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin
reuses CommunityRating for the EXIF 0-5 star.

The badges share one slot on every surface. On the phone hero the scores go
in a single pill because that chip row is a height-clipped Wrap and a chip
per source would push year, certification and runtime out of the visible
band on short heroes; on the TV detail line and the dashboard spotlight the
group occupies the one metadata slot so bullet separators do not multiply.
The group announces itself as a single semantics node naming each source,
because a bare row of four percentages tells a screen reader nothing about
which score is which. rating_utils drops parseRatingImage and
isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper —
and the source-key resolver and label map, previously private to the Explore
detail screen, become the shared pair both screens use. The label strings
move from explore.ratingSource to common.ratingSource accordingly, which
costs no translations because every non-English value was empty; running
clean_translations also scaffolds startup.quitPlezy and
startup.restartRequiredBody, which were already drifted.

Verified against the live server the probes came from: a detail response now
yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the
production mapper and badge resolver, and the listing response for the same
title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures.
Coverage adds mapper ordering, dedupe against the array's repeat of the
scalar, out-of-range rejection, the Jellyfin scale and photo guard, the
CatalogItem conversion that feeds Explore's dashboard hubs, and the three
render surfaces including the semantics announcement.

close #1755
2026-08-02 03:59:56 +02:00
611 changed files with 80886 additions and 16403 deletions
+47 -18
View File
@@ -765,14 +765,17 @@ jobs:
fi
echo "BUNDLE_DIR=$bundle_dir" >> "$GITHUB_ENV"
- name: Build Linux Packages
shell: bash
run: |
BUILD_DIR="$BUNDLE_DIR" \
ARCH_SUFFIX=${{ matrix.arch }} \
OUTPUT_DIR="$GITHUB_WORKSPACE" \
python3 linux/packaging/build-packages.py
# Everything below resolves the bundle *before* packaging, so the packages
# and the tarball are cut from one identical tree.
#
# The plane needs the pinned Wayland-enabled libmpv: a distro libmpv still
# plays and still does HDR, but silently drops hwdec to vaapi-copy - which
# was measured, not assumed. So libmpv travels with us and no artifact
# depends on a host one. That removed the host `mpv` dependency, and with it
# the transitive pull of everything libmpv itself needs - libass, pulse,
# pipewire, fontconfig and the rest. bundle-libs.sh is what supplies those,
# so it has to run before packaging too, or the packages would carry libmpv
# and nothing it links.
- name: Copy libmpv into bundle
shell: bash
run: |
@@ -781,7 +784,7 @@ jobs:
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
- name: Bundle shared libraries for portable tarball
- name: Bundle shared libraries
shell: bash
run: bash linux/packaging/bundle-libs.sh "$BUNDLE_DIR"
@@ -789,17 +792,43 @@ jobs:
shell: bash
run: cp linux/packaging/plezy.sh "$BUNDLE_DIR/plezy.sh"
- name: Verify no missing dependencies
# Same check the package smoke build runs, against the artifact that
# actually ships: a library nobody declares is a broken install, and it is
# invisible until a user on a clean machine tries to launch.
#
# This also stands in for the `ldd ./plezy | grep "not found"` step that
# used to run after packaging. That one folded ldd's stderr into grep's
# input and dropped its exit status, so ldd failing outright - a missing
# loader, an exec-format mismatch, no ldd at all - left the match empty and
# printed "All dependencies resolved." This guard runs ldd over every
# object under lib/ as well as the executable, fails on an unresolved
# soname, fails when ldd cannot read an object, and refuses to pass when
# the walk found no host libraries at all. Packaging below only reads the
# bundle, so a second shell ldd afterwards could only restate a weaker
# subset of what this already proved about the very same tree.
- name: Verify every unbundled library the bundle needs is declared
shell: bash
run: python3 linux/packaging/check-bundle-host-deps.py "$BUNDLE_DIR"
# Last, from the fully resolved tree above. The host-dependency guard is
# skipped because the named step above just ran it against this same
# bundle; the internal run exists for by-hand packaging outside CI.
- name: Build Linux Packages
shell: bash
run: |
cd "$BUNDLE_DIR"
MISSING=$(LD_LIBRARY_PATH=lib ldd ./plezy 2>&1 | grep "not found" || true)
if [[ -n "$MISSING" ]]; then
echo "ERROR: Unresolved dependencies found:" >&2
echo "$MISSING" >&2
exit 1
fi
echo "All dependencies resolved."
BUILD_DIR="$BUNDLE_DIR" \
ARCH_SUFFIX=${{ matrix.arch }} \
OUTPUT_DIR="$GITHUB_WORKSPACE" \
PLEZY_SKIP_HOST_DEP_CHECK=1 \
python3 linux/packaging/build-packages.py
# The depends lists reached fpm above; only the packages it wrote can show
# they arrived. Same script the smoke build runs, so the two jobs cannot
# drift on what counts as declared - and unlike the smoke build, this one
# covers arm64 and the artifacts users actually install.
- name: Verify the declared dependencies reached the package metadata
shell: bash
run: python3 linux/packaging/check-package-deps.py "$GITHUB_WORKSPACE" --arch ${{ matrix.arch }}
- name: Create tarball
shell: bash
+171 -3
View File
@@ -9,6 +9,14 @@ on:
branches:
- main
workflow_dispatch:
inputs:
build_linux_packages:
description: >
Smoke-build the Linux deb/rpm/pacman packages. Off by default: it needs
a release build plus fpm, and build.yml only packages from main, so this
is the only way to exercise linux/packaging from a branch.
default: false
type: boolean
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
@@ -244,7 +252,7 @@ jobs:
- name: Verify native formatting
run: scripts/format_native.sh --check
- name: Verify Linux native acquisition integrity
- name: Verify Linux native acquisition and build plan
run: bash linux/packaging/build-libmpv_test.sh
linux-native-test:
@@ -279,7 +287,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
libwayland-dev libegl-dev
- name: Prepare Flutter Linux configuration
run: |
@@ -295,12 +304,20 @@ jobs:
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
# `plezy` is the runner itself. Without it nothing in CI ever compiles
# my_application.cc, mpv_plugin.cc or the Wayland video plane — the
# reliability test targets each pull in only a couple of translation units,
# so a break in the rest of linux/runner reached a release build unseen.
- name: Build Linux native reliability tests
run: |
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
plezy \
mpv_player_lifecycle_test \
mpv_player_hdr_output_test \
mpv_property_result_contract_test \
mpv_gpu_bootstrap_test
hdr_metadata_test \
plane_geometry_test \
video_params_test
- name: Run Linux native reliability tests
run: |
@@ -592,3 +609,154 @@ jobs:
- name: Run website checks
run: scripts/ci_website_checks.sh
# The only job that checks packaging against a real artifact. build.yml also
# packages Linux, but refuses any ref but refs/heads/main, and
# check_linux_package_deps.py can only compare a hand-written list against
# CMake - it cannot prove the list reaches the artifact, nor see the
# transitive libraries the bundled libmpv drags in. This can, by reading the
# built bundle back with ldd.
#
# Runs on every push to main and on request, but not on pull requests: it
# needs a release build plus fpm, which is minutes of runner time that most
# changes here have no reason to pay. That buys post-merge detection rather
# than pre-merge, which is the deliberate trade. Dispatch it from a branch
# with build_linux_packages when touching linux/packaging - which is the only
# way to exercise it before merging.
linux-packages:
name: Linux package smoke build
if: ${{ inputs.build_linux_packages || github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
# The packaging deps, minus libmpv: this job builds it from source below,
# because the distro's is a different version with different windowing
# backends, and a package smoke-built against it cannot show that
# build-libmpv.sh still works or that the bundle it produces is coherent.
- name: Install packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev liblzma-dev \
libstdc++-12-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev \
libharfbuzz-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev \
libva-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev \
libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev \
liblua5.2-dev rpm libarchive-tools imagemagick ruby-dev build-essential
sudo gem install fpm --version 1.17.0 --no-document
# Keyed the same way build.yml keys it, so editing the script or its pinned
# inputs is what invalidates the cache - and this branch's whole point is
# that those edits get exercised somewhere.
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
run: bash linux/packaging/build-libmpv.sh
# The windowing backends the runner depends on, read off the library the
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
# in software; X11 and VDPAU are gone with the texture path.
- name: Check the built libmpv's backends
run: |
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
echo "== $LIB =="
ldd "$LIB" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
ldd "$LIB" | grep -q libwayland-client || {
echo "::error::the built libmpv does not link libwayland-client, so the video plane cannot work"
exit 1
}
- name: Build the release bundle
run: |
flutter pub get --enforce-lockfile --no-example
flutter build linux --release
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
# Mirrors build.yml: resolve the bundle completely, then package from it.
# libmpv travels with us, so nothing depends on a host one - which also
# removes the transitive pull of everything libmpv links, hence bundle-libs
# before packaging rather than after.
- name: Copy libmpv into the bundle
run: |
BUNDLE_LIB=build/linux/x64/release/bundle/lib
LIBMPV_DIR=$(dirname "$(find libmpv-prefix -name 'libmpv.so' | head -1)")
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
- name: Bundle shared libraries
run: bash linux/packaging/bundle-libs.sh build/linux/x64/release/bundle
- name: Copy wrapper script into the bundle
run: cp linux/packaging/plezy.sh build/linux/x64/release/bundle/plezy.sh
# Derives what the resolved bundle still needs from the host and proves
# every one of those libraries is declared. This is the check that would
# have caught bundling libmpv without also declaring what libmpv links.
- name: Verify every unbundled library the bundle needs is declared
run: python3 linux/packaging/check-bundle-host-deps.py build/linux/x64/release/bundle
# OUTPUT_DIR defaults to the repo root; name it so the paths below are not
# a guess about where fpm dropped things. The host-dependency guard is
# skipped because the named step above just ran it against this same
# bundle; the internal run exists for by-hand packaging outside CI.
- name: Build the packages
run: |
mkdir -p "$OUTPUT_DIR"
python3 linux/packaging/build-packages.py
env:
OUTPUT_DIR: ${{ github.workspace }}/packages
PLEZY_SKIP_HOST_DEP_CHECK: "1"
# The guard above reconciles the depends lists with the staged bundle; only
# the packages themselves can show that list survived fpm. Every name comes
# from build-packages.py, so adding a library there is verified here without
# a second edit - and this job is off by default, so a hand-copied list
# would rot unseen. The release job in build.yml runs the same script
# against the artifacts users install, so the assertions cannot drift.
- name: Verify the declared dependencies reached the package metadata
run: python3 linux/packaging/check-package-deps.py "${{ github.workspace }}/packages"
# Same resolved bundle the packages were cut from, so the tarball is not a
# second, differently-assembled artifact. It is the one to put on a USB for
# a foreign machine, because it needs nothing installed.
- name: Create the tarball
run: |
BUNDLE_DIR=build/linux/x64/release/bundle
mkdir -p "$OUTPUT_DIR"
tar -czf "$OUTPUT_DIR/plezy-linux-x64.tar.gz" -C "$BUNDLE_DIR" .
echo "=== libmpv travelling in every artifact ==="
ldd "$BUNDLE_DIR/lib/libmpv.so" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
env:
OUTPUT_DIR: ${{ github.workspace }}/packages
- name: Upload the packages
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-packages-smoke
path: ${{ github.workspace }}/packages/plezy-linux-x64.*
if-no-files-found: error
retention-days: 7
-324
View File
@@ -1,324 +0,0 @@
name: E2E - Maestro
on:
push:
branches:
- main
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
pull_request:
branches:
- main
workflow_dispatch:
concurrency:
group: maestro-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
android-maestro:
name: Android Maestro suites
runs-on: ubuntu-latest
timeout-minutes: 150
permissions:
contents: read
env:
FLUTTER_VERSION: "3.44.0"
MAESTRO_VERSION: "2.6.1"
MAESTRO_SHA256: "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b"
MAESTRO_CLI_NO_ANALYTICS: "1"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: "21"
- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
- name: Cache Pub dependencies
id: pub-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Cache Gradle
id: gradle-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-e2e-
${{ runner.os }}-gradle-
- name: Install dependencies
run: flutter pub get
- name: Build debug APK
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
- name: Cache Maestro CLI
id: maestro-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.maestro/bin
~/.maestro/lib
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
- name: Install verified Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
archive="$RUNNER_TEMP/maestro.zip"
staging="$RUNNER_TEMP/maestro-install"
curl --fail --location --retry 3 \
--output "$archive" \
"https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
echo "${MAESTRO_SHA256} ${archive}" | sha256sum --check -
rm -rf "$staging" "$HOME/.maestro/bin" "$HOME/.maestro/lib"
mkdir -p "$staging" "$HOME/.maestro"
unzip -q "$archive" -d "$staging"
mv "$staging/maestro/bin" "$HOME/.maestro/bin"
mv "$staging/maestro/lib" "$HOME/.maestro/lib"
- name: Add Maestro to PATH
run: |
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
"$HOME/.maestro/bin/maestro" --version
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build deterministic Jellyfin image for pull request
if: github.event_name == 'pull_request'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: .maestro/jellyfin-demo/Dockerfile
load: true
tags: plezy-jellyfin-demo:local
cache-from: type=gha,scope=maestro-jellyfin
- name: Build and cache deterministic Jellyfin image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: .maestro/jellyfin-demo/Dockerfile
load: true
tags: plezy-jellyfin-demo:local
cache-from: type=gha,scope=maestro-jellyfin
cache-to: type=gha,mode=max,scope=maestro-jellyfin
- name: Cache Android 15 AVD
id: api35-avd-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api35.avd
~/.android/avd/maestro-api35.ini
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
- name: Generate Android 15 AVD snapshot
if: steps.api35-avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35
force-avd-creation: false
disable-animations: false
emulator-options: >-
-no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: echo "Generated Android 15 AVD snapshot."
- name: Cache Android 9 AVD
id: api28-avd-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api28.avd
~/.android/avd/maestro-api28.ini
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
- name: Generate Android 9 AVD snapshot
if: steps.api28-avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 28
target: google_apis_playstore
arch: x86
profile: pixel_2
avd-name: maestro-api28
force-avd-creation: false
disable-animations: false
emulator-options: >-
-no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: echo "Generated Android 9 AVD snapshot."
- name: Run Android 15 instrumentation
id: api35-instrumentation
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35-instrumentation
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
- name: Run Android R8 reachability
id: api35-r8
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35-instrumentation
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-r8-reachability
- name: Run Android 15 suites
id: api35
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
MAESTRO_SKIP_BUILD: "1"
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
MAESTRO_DEVICE_ID: emulator-5554
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-15
- name: Run Android 9 legacy playback
id: api28
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
MAESTRO_SKIP_BUILD: "1"
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
MAESTRO_DEVICE_ID: emulator-5554
with:
api-level: 28
target: google_apis_playstore
arch: x86
profile: pixel_2
avd-name: maestro-api28
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-9
# Fork PRs may restore trusted caches, but only trusted events publish cache entries.
- name: Save Pub dependency cache
if: github.event_name != 'pull_request' && steps.pub-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.pub-cache
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
- name: Save Gradle cache
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
- name: Save Maestro CLI cache
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.maestro/bin
~/.maestro/lib
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
- name: Save Android 15 AVD cache
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api35.avd
~/.android/avd/maestro-api35.ini
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
- name: Save Android 9 AVD cache
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api28.avd
~/.android/avd/maestro-api28.ini
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
- name: Upload Maestro diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: maestro-android-diagnostics
path: |
build/maestro
build/maestro-offline
build/maestro-real-jellyfin
build/maestro-media
build/maestro-tv
build/maestro-recovery
build/maestro-legacy
~/.maestro/tests
if-no-files-found: ignore
retention-days: 7
- name: Report suite failures
if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35-r8.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }}
run: |
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
echo "Android R8 reachability outcome: ${{ steps.api35-r8.outcome }}"
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
exit 1
@@ -16,13 +16,20 @@ tags:
visible: "(?s)^(Play|Resume).*$"
commands:
- pressKey: "Remote Dpad Center"
# The TV player opens with its chrome down so the OSD and timebar never sit
# over the opening seconds of the picture (#1765), so the Pause button can no
# longer stand in for "the player has finished loading". Wait for the detail
# screen to go first: the labelled spinner is necessarily up the moment the
# player route owns the frame, and it only clears once playback reports its
# first frame or gives up, so this can never advance before the media opens.
# A give-up lands on the transport assertions below, which is where it belongs.
- extendedWaitUntil:
visible: "Pause"
notVisible: "Overview"
timeout: 30000
- pressKey: "back"
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
notVisible: "Loading video"
timeout: 30000
- assertNotVisible: "(?s)^(Play|Pause)$"
# Hardware transport keys drive playback without raising the chrome (#1676):
# the player answers with a centred disc (pause) or a side readout (seek), never the
# full chrome, so subtitles stay readable.
@@ -7,9 +7,23 @@ tags:
- playback
---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
- tapOn:
point: "6%,22%"
- tapOn: "Search"
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A
# tap here flips InputModeTracker into pointer mode, which collapses the rail
# to icons and leaves no "Search" label to hit — and a percentage coordinate
# does not survive the jump from a phone in forced TV mode to a 4K television.
- pressKey: "Remote Dpad Left"
- extendedWaitUntil:
visible: "Search"
timeout: 15000
- repeat:
times: 12
while:
notVisible:
text: "Search"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
@@ -24,13 +38,24 @@ tags:
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "(?s)^Play S1E1$"
# The TV player opens with its chrome down (#1765), so the Pause button can no
# longer stand in for "the player has finished loading". Wait for the detail
# screen to go first: the labelled spinner is necessarily up the moment the
# player route owns the frame, and it only clears once playback reports its
# first frame or gives up, so this can never advance before the media opens.
- extendedWaitUntil:
visible: "Pause"
notVisible: "(?s)^Play S1E1$"
timeout: 30000
- extendedWaitUntil:
notVisible: "Loading video"
timeout: 30000
- pressKey: "Remote Media Fast Forward"
- pressKey: "Remote Media Fast Forward"
# Gate on the prompt's own Cancel action, the label the iOS branch below taps.
# "Next Episode" is the credits skip button at this point in the episode, so
# waiting on it would let Back fire before the prompt ever opened.
- extendedWaitUntil:
visible: "Next Episode"
visible: "(?s)^Cancel$"
timeout: 20000
- runFlow:
when:
@@ -42,7 +67,10 @@ tags:
platform: Android
commands:
- pressKey: "back"
- assertNotVisible: "Next Episode"
# Dismissal is animated, so give it a bounded wait rather than one instant look.
- extendedWaitUntil:
notVisible: "(?s)^Cancel$"
timeout: 10000
- assertNotVisible: "Overview"
- pressKey: "Remote Dpad Up"
- extendedWaitUntil:
+3 -4
View File
@@ -66,13 +66,12 @@ Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse t
`--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 PR groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/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 and must not be
described as CI coverage. `.github/workflows/e2e.yml` runs only automatic targets and uploads diagnostics on failure.
independent tests. A manual-only classification must state why the flow cannot run automatically.
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
manual target rather than automatic PR coverage. Run them only against the pre-seeded Jellyfin fixture and a disposable
manual target rather than an automatic group. Run them only against the pre-seeded Jellyfin fixture and a disposable
emulator, using the required opt-in:
```bash
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.11.0"
sha256 "72a6a74d2f8f4ac76d83d005ff28957b3abcc9d80d7d575710df031d12c1c8cf"
version "2.12.1"
sha256 "7a5e30d125d5a379108bf691ae8ce5e2af0a1acf98ee40897909a9576ebe3ef9"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+9 -8
View File
@@ -3,7 +3,7 @@
Plezy
</h1>
A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
A modern client for Plex, Jellyfin, and Emby on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
<p>
<a href="https://plezy.app">Website</a> ·
@@ -52,11 +52,11 @@ Package managers:
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
- Libraries, collections, and playlists — video and audio
- Discover hub — Continue Watching, Next Up, trending, and recommendations
- Cross-server search across every connected Plex and Jellyfin server
- Cross-server search across every connected Plex, Jellyfin, and Emby server
- Filtering, sorting, and alphabetical jump navigation
- Folder browsing and folder playback — home-video libraries open in folder view
- Resolution, HDR/Dolby Vision, and audio-format badges on cards and detail pages
- Favorites and unwatched library filters[^jf]
- Favorites and unwatched library filters[^mb]
- Extras — trailers, deleted scenes, behind-the-scenes
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
@@ -119,9 +119,9 @@ Package managers:
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
- Multiple servers at once — Plex and Jellyfin side by side
- Multiple servers at once — Plex, Jellyfin, and Emby side by side
- Profiles with per-profile downloads, watch state, and settings; Plex Home switching with PIN
- Jellyfin local-server discovery, multiple URLs per server, and Quick Connect sign-in
- Jellyfin and Emby local-server discovery and multiple URLs per server; Quick Connect sign-in[^jf]
- TV layout options — corner spotlight backdrop, full-card artwork, and Force TV mode on desktop
- Customizable keyboard shortcuts[^desktop]
- Metadata and artwork editing
@@ -129,9 +129,10 @@ Package managers:
- Localized in English plus 21 translations
[^jf]: Jellyfin only.
[^mb]: Jellyfin and Emby only.
[^plex]: Plex only.
[^connect]: Requires connecting the service under Settings > Services.
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV.
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, tvOS, and Linux — Linux needs a colour-managed Wayland compositor. Dolby Vision on Android and Apple TV.
[^pass]: Desktop, Android TV, and Apple TV.
[^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer.
[^pip]: Android, iOS, and macOS — not on Android TV or Apple TV.
@@ -148,7 +149,7 @@ Package managers:
### Prerequisites
- Flutter SDK 3.44.0+
- A Plex account or Jellyfin server with user credentials
- A Plex account, or a Jellyfin or Emby server with user credentials
### Setup
@@ -203,5 +204,5 @@ Plezy is licensed under [GPL-3.0](LICENSE).
## Acknowledgments
- Built with [Flutter](https://flutter.dev)
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
+10
View File
@@ -324,6 +324,11 @@ android {
buildToolsVersion = "36.1.0"
ndkVersion = "29.0.14206865"
// Android Automotive OS driver-distraction state (CarUxRestrictionsManager). This is a platform
// stub, not a shipped dependency: the classes exist only on AAOS images, so every use is guarded
// by FEATURE_AUTOMOTIVE and the manifest declares `uses-library android.car required=false`.
useLibrary("android.car")
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -513,6 +518,10 @@ dependencies {
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.1.0")
// Periodic Watch Next background refresh (ShelfRefreshWorker). Same version
// background_downloader pins, so the merged classpath stays coherent.
implementation("androidx.work:work-runtime-ktx:2.11.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-decoder:$media3Version")
implementation("androidx.media3:media3-exoplayer:$media3Version")
@@ -532,6 +541,7 @@ dependencies {
// Real android.util.* implementations for tests exercising media3 classes
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("androidx.work:work-testing:2.11.0")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
}
@@ -0,0 +1,92 @@
package com.edde746.plezy.car
import android.content.pm.PackageManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Verifies the platform half of the driver-distraction gate on a real device.
*
* Everything here is invisible to the Dart unit tests: `android.car` is a compile-time stub, so a
* missing `useLibrary`, a wrong `uses-library` declaration, a car service that refuses to connect,
* or an unresolvable default display all compile fine and merely make [CarRestrictionsMonitor.start]
* return false — at which point Plezy silently falls back to lifecycle gating and parked background
* audio never works, with no crash to notice.
*
* On a non-automotive device the monitor must stay inert instead of throwing, which is the other
* half of the contract.
*/
@RunWith(AndroidJUnit4::class)
class CarRestrictionsMonitorTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val isCar = context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
@Test
fun reportsSupportOnlyOnACarAndNeverThrows() {
val monitor = CarRestrictionsMonitor(context)
val answered = CountDownLatch(1)
try {
val started = monitor.start { answered.countDown() }
if (isCar) {
assertTrue("android.car is present but the UX-restriction signal did not connect", started)
// The connect never blocks, so readiness arrives on the main thread while this test thread
// waits. A timeout here means the car service never handed over a verdict.
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
assertTrue("a verdict arrived without marking the monitor supported", monitor.supported)
} else {
assertFalse("a non-automotive device must not claim car restrictions", started)
assertFalse(monitor.supported)
}
} finally {
monitor.release()
}
assertFalse("release() must drop support so a stale verdict cannot leak", monitor.supported)
}
@Test
fun aParkedEmulatorReportsNoDistractionOptimization() {
if (!isCar) return
val monitor = CarRestrictionsMonitor(context)
val answered = CountDownLatch(1)
try {
assertTrue(monitor.start { answered.countDown() })
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
// The suite runs on a parked vehicle (no VHAL driving injection), which is the state that
// must permit background audio. A restricted verdict here means the gate would keep music
// tied to the foreground exactly as before.
assertFalse(
"parked car reported that distraction optimization is required",
monitor.requiresDistractionOptimization
)
} finally {
monitor.release()
}
}
@Test
fun theServiceConnectionRouteUsedBelowAndroidElevenAlsoReportsTheVehicle() {
if (!isCar) return
// No Android 9 or 10 Automotive image is published, so the only way to exercise the path those
// head units take is to force it here: the deprecated API is present on every version.
val monitor = CarRestrictionsMonitor(context, forceLegacyConnect = true)
val answered = CountDownLatch(1)
try {
assertTrue("the ServiceConnection route failed to start", monitor.start { answered.countDown() })
assertTrue("car service never reported through the legacy route", answered.await(20, TimeUnit.SECONDS))
assertTrue(monitor.supported)
assertFalse(
"parked car reported that distraction optimization is required",
monitor.requiresDistractionOptimization
)
} finally {
monitor.release()
}
}
}
@@ -0,0 +1,299 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* The hardware half of the #1790 contract, and the half a JVM fake cannot reach.
*
* `DefaultAudioSink` charges a static, process-wide counter on every flush and only discharges it
* from `Listener::onReleased` — the same callback the player surfaces as
* [AnalyticsListener.onAudioTrackReleased]. Any lasting imbalance stops media3 escalating audio
* failures at all, which is how a failed `AudioTrack` turned into a permanent buffering hang.
*
* `onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased` once per answered
* flush, so counting both against a real sink, across the reuse and eviction cycles the wrapper's
* cache actually creates, measures the invariant directly.
*/
@RunWith(AndroidJUnit4::class)
class AudioOutputReleaseAccountingTest {
private companion object {
const val SEEK_COUNT = 4
const val STATE_TIMEOUT_SECONDS = 20L
}
@Test
fun everyFlushIsAnsweredAcrossSeeksAndAConfigChange() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
// Different channel counts give the sink two different output configurations, so the second
// item cannot reuse the first one's parked AudioTrack and has to evict it.
val surround = copyFixture(context, "ffmpeg/surround_5_1.flac")
val stereo = copyFixture(context, "ffmpeg/stereo.flac")
val playbackThread = HandlerThread("plezy-release-accounting-test").apply { start() }
val handler = Handler(playbackThread.looper)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val initialized = AtomicInteger()
val released = AtomicInteger()
try {
handler.runAndWait {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
// Decoded PCM is the only output the cache parks, so it is the path whose reuse and
// eviction the accounting has to survive.
shouldBlockDirectAudioOutput = { format ->
format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
}
}
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
playerReference.set(player)
player.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
initialized.incrementAndGet()
}
override fun onAudioTrackReleased(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
released.incrementAndGet()
}
override fun onPlayerErrorChanged(
eventTime: AnalyticsListener.EventTime,
error: androidx.media3.common.PlaybackException?
) {
if (error != null) errorReference.set(error)
}
})
}
val player = playerReference.get()
play(handler, player, surround)
awaitReady(handler, player)
// Each seek flushes the sink: the output is parked and handed straight back, so every one of
// them has to settle its own flush or the counter drifts up for the rest of the process.
repeat(SEEK_COUNT) {
handler.runAndWait { player.seekTo(0) }
awaitReady(handler, player)
}
val afterSeeks = initialized.get() - released.get()
assertNull("playback failed before the config change", errorReference.get())
assertTrue("expected the seeks to rebuild the audio output", initialized.get() > 1)
assertEquals(
"after $SEEK_COUNT seeks only the live acquisition may be outstanding " +
"(initialized=${initialized.get()}, released=${released.get()})",
1,
afterSeeks
)
// Config change: the parked 5.1 track cannot serve stereo, so it is evicted for real.
play(handler, player, stereo)
awaitReady(handler, player)
assertNull("playback failed after the config change", errorReference.get())
assertEquals(
"an eviction must not leave a second acquisition outstanding " +
"(initialized=${initialized.get()}, released=${released.get()})",
1,
initialized.get() - released.get()
)
} finally {
handler.runAndWait { playerReference.get()?.release() }
playbackThread.quitSafely()
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
surround.delete()
stereo.delete()
}
}
/**
* The same invariant on a real bitstream route, which is the output the reporter's device was
* failing to build. Needs a live HDMI sink that advertises encoded surround, so it skips on a
* phone or a TV set to PCM rather than passing without having exercised anything.
*/
@Test
fun everyFlushIsAnsweredOnABitstreamRoute() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val movieAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val capabilities = AudioCapabilities.getCapabilities(context, movieAttributes, null)
val fixture = when {
capabilities.supportsEncoding(C.ENCODING_E_AC3) -> "ffmpeg/surround_5_1_eac3.mka"
capabilities.supportsEncoding(C.ENCODING_DTS) -> "ffmpeg/surround_5_1_dts.mka"
else -> null
}
assumeTrue("the current audio route does not advertise E-AC3 or DTS bitstream", fixture != null)
val media = copyFixture(context, fixture!!)
val playbackThread = HandlerThread("plezy-bitstream-accounting-test").apply { start() }
val handler = Handler(playbackThread.looper)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val initialized = AtomicInteger()
val released = AtomicInteger()
val sawEncodedOutput = AtomicReference(false)
try {
handler.runAndWait {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
shouldBlockDirectAudioOutput = { false }
}
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
playerReference.set(player)
player.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
initialized.incrementAndGet()
if (!isPcmEncoding(audioTrackConfig.encoding)) sawEncodedOutput.set(true)
}
override fun onAudioTrackReleased(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
released.incrementAndGet()
}
override fun onPlayerErrorChanged(
eventTime: AnalyticsListener.EventTime,
error: androidx.media3.common.PlaybackException?
) {
if (error != null) errorReference.set(error)
}
})
}
val player = playerReference.get()
play(handler, player, media)
awaitReady(handler, player)
repeat(SEEK_COUNT) {
handler.runAndWait { player.seekTo(0) }
awaitReady(handler, player)
}
assertNull("bitstream playback failed", errorReference.get())
assumeTrue(
"the route advertised encoded surround but the sink still decoded to PCM",
sawEncodedOutput.get()
)
assertEquals(
"a bitstream output must answer every flush too " +
"(initialized=${initialized.get()}, released=${released.get()})",
1,
initialized.get() - released.get()
)
} finally {
handler.runAndWait { playerReference.get()?.release() }
playbackThread.quitSafely()
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
media.delete()
}
}
private fun play(handler: Handler, player: ExoPlayer, file: File) {
val context = InstrumentationRegistry.getInstrumentation().targetContext
handler.runAndWait {
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(file)))
player.setMediaSource(source)
player.prepare()
player.play()
}
}
/** Waits for the player to reach READY, which is the point an AudioTrack has been acquired. */
private fun awaitReady(handler: Handler, player: ExoPlayer) {
val ready = CountDownLatch(1)
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY || playbackState == Player.STATE_ENDED) ready.countDown()
}
}
handler.runAndWait {
if (player.playbackState == Player.STATE_READY || player.playbackState == Player.STATE_ENDED) {
ready.countDown()
} else {
player.addListener(listener)
}
}
val reached = ready.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
handler.runAndWait { player.removeListener(listener) }
assertTrue("timed out waiting for the player to become ready", reached)
// The AudioTrack is acquired on the first buffer handed to the sink, which lands just after
// READY; give the playback thread a beat to get there before counting.
handler.runAndWait { }
Thread.sleep(250)
handler.runAndWait { }
}
private fun Handler.runAndWait(block: () -> Unit) {
val done = CountDownLatch(1)
val failure = AtomicReference<Throwable>()
post {
try {
block()
} catch (error: Throwable) {
failure.set(error)
} finally {
done.countDown()
}
}
assertTrue("timed out running on the playback thread", done.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
failure.get()?.let { throw it }
}
private fun copyFixture(targetContext: Context, fixture: String): File {
val output = File.createTempFile("release-accounting-", null, targetContext.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets.open(fixture).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
@@ -0,0 +1,152 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Pins the side-loaded subtitle identity contract against the real media3 the
* app links, on a real device.
*
* `ExoPlayerCore` tags each `MediaItem.SubtitleConfiguration` with
* [ExternalSubtitleIds.idFor] and recovers it from the `Format` the track
* selector reports. media3 does not hand that id back verbatim:
* `DefaultMediaSourceFactory` merges side-loaded subtitles with the primary
* source, and `MergingMediaPeriod` prefixes every child format id with its
* period index. Matching the raw id therefore classifies every sidecar as an
* embedded track and drops its URI, which is what broke Plex sidecar
* subtitles (#1713).
*
* A JVM test can only assert the parsing rule. This asserts that the rule
* still matches what media3 actually emits.
*/
@RunWith(AndroidJUnit4::class)
class ExternalSubtitleIdentityTest {
private companion object {
const val TAG = "ExternalSubtitleIdentityTest"
const val SRT = "1\n00:00:00,500 --> 00:00:05,000\nplezy sidecar identity\n\n"
}
@Test
fun sideLoadedSubtitleIdSurvivesMedia3Merging() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val media = copyAsset(instrumentation.context, context, "ffmpeg/planar_5_1.m4a")
val subtitle = File.createTempFile("sidecar-", ".srt", context.cacheDir).apply {
writeText(SRT)
}
val thread = HandlerThread("plezy-sidecar-identity-test").apply { start() }
val handler = Handler(thread.looper)
val settled = CountDownLatch(1)
val playerRef = AtomicReference<ExoPlayer>()
val errorRef = AtomicReference<Throwable>()
val textFormatId = AtomicReference<String>()
val textGroupCount = AtomicReference(0)
handler.post {
try {
val player = ExoPlayer.Builder(context).setLooper(thread.looper).build()
playerRef.set(player)
player.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
errorRef.set(error)
settled.countDown()
}
override fun onTracksChanged(tracks: Tracks) {
val text = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
if (text.isEmpty()) return
textGroupCount.set(text.size)
textFormatId.set(text.first().mediaTrackGroup.getFormat(0).id)
settled.countDown()
}
})
// setMediaItem (not setMediaSource) so DefaultMediaSourceFactory owns
// the subtitle configuration exactly as ExoPlayerCore.open does.
player.setMediaItem(
MediaItem.Builder()
.setUri(Uri.fromFile(media))
.setSubtitleConfigurations(
listOf(
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subtitle))
.setId(ExternalSubtitleIds.idFor(0))
.setLabel("Plezy sidecar")
.setLanguage("en")
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
)
)
.build()
)
player.prepare()
} catch (error: Throwable) {
errorRef.set(error)
settled.countDown()
}
}
val finished = settled.await(30, TimeUnit.SECONDS)
val released = CountDownLatch(1)
handler.post {
playerRef.get()?.release()
thread.quitSafely()
released.countDown()
}
val teardownFinished = released.await(5, TimeUnit.SECONDS)
thread.join(5_000)
media.delete()
subtitle.delete()
assertTrue("Player teardown timed out", teardownFinished)
assertNull("Playback failed", errorRef.get())
assertTrue("Timed out before any text track group was reported", finished)
assertEquals("Expected exactly one side-loaded text group", 1, textGroupCount.get())
val reportedId = textFormatId.get()
Log.i(TAG, "media3 reported side-loaded subtitle Format.id=$reportedId")
assertNotNull("Side-loaded subtitle reported a null Format.id", reportedId)
// The contract ExoPlayerCore depends on.
assertTrue(
"Side-loaded subtitle was not recognised as external (Format.id=$reportedId)",
ExternalSubtitleIds.isExternal(reportedId)
)
assertEquals(
"Side-loaded subtitle did not resolve to its configuration index (Format.id=$reportedId)",
0,
ExternalSubtitleIds.indexOf(reportedId)
)
}
private fun copyAsset(instrumentationContext: Context, targetContext: Context, asset: String): File {
val output = File.createTempFile("sidecar-primary-", null, targetContext.cacheDir)
instrumentationContext.assets.open(asset).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
@@ -0,0 +1,311 @@
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.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Speed changes arrive while the carrier is already live (#1804). Nothing re-asks the sink unless
* capabilities are invalidated, so this drives the real path: play on the carrier, change speed,
* and require the renderer to actually move TrueHD onto a decoder and keep the clock advancing.
*
* Skips itself on hardware that never takes the carrier; there is no transition to observe there.
*/
class TrueHdSpeedTransitionTest {
private companion object {
const val TAG = "TrueHdSpeed"
const val SETTLE_MS = 4_000L
}
@Test
fun leavingUnitSpeedMovesTrueHdOffTheCarrierAndKeepsPlaying() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixture = copyFixture(context)
val thread = HandlerThread("truehd-speed-test").apply { start() }
val handler = Handler(thread.looper)
val playing = CountDownLatch(1)
val audioDecoder = AtomicReference<String?>(null)
val trackEncoding = AtomicReference<Int>(-1)
val player = AtomicReference<ExoPlayer?>(null)
handler.post {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
// Mirrors ExoPlayerCore: without this flag DefaultTrackSelector silently drops every
// renderer-capability invalidation, so the carrier could never be re-evaluated.
val selector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
)
}
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
player.set(exo)
exo.addAnalyticsListener(
object : AnalyticsListener {
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long
) {
audioDecoder.set(decoderName)
Log.i(TAG, "audio decoder: $decoderName")
}
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
config: AudioSink.AudioTrackConfig
) {
trackEncoding.set(config.sampleRate)
Log.i(TAG, "AudioTrack rate=${config.sampleRate} buffer=${config.bufferSize}")
}
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
if (isPlaying) playing.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.prepare()
exo.playWhenReady = true
}
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
Thread.sleep(SETTLE_MS)
val carrierEncoding = trackEncoding.get()
val decoderBefore = audioDecoder.get()
Log.i(TAG, "==== BEFORE: rate=$carrierEncoding decoder=$decoderBefore ====")
// 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) {
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
teardown(handler, player, thread, fixture)
return
}
val positionBefore = positionOf(handler, player)
handler.post { player.get()?.setPlaybackSpeed(1.5f) }
Thread.sleep(SETTLE_MS)
val encodingAfter = trackEncoding.get()
val decoderAfter = audioDecoder.get()
val positionAfter = positionOf(handler, player)
Log.i(
TAG,
"==== AFTER: rate=$encodingAfter decoder=$decoderAfter " +
"position=${positionBefore}ms -> ${positionAfter}ms ===="
)
// Returning to 1x has to re-offer the carrier, or one speed nudge costs Atmos for the session.
trackEncoding.set(-1)
handler.post { player.get()?.setPlaybackSpeed(1f) }
Thread.sleep(SETTLE_MS)
val rateRestored = trackEncoding.get()
Log.i(TAG, "==== RESTORED: rate=$rateRestored ====")
teardown(handler, player, thread, fixture)
assertEquals(
"returning to 1x must put TrueHD back on the carrier",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
rateRestored
)
assertNotEquals(
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
encodingAfter
)
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
assertTrue("the clock must keep advancing after the switch", positionAfter > positionBefore)
// The whole point of the switch: the speed the user asked for has to actually apply. At 1.5x
// the media clock must outrun the 4s of wall time spent waiting.
assertTrue(
"playback must run faster than real time after the switch, advanced " +
"${positionAfter - positionBefore}ms in ${SETTLE_MS}ms",
positionAfter - positionBefore > SETTLE_MS * 6 / 5
)
}
private fun positionOf(handler: Handler, player: AtomicReference<ExoPlayer?>): Long {
val value = AtomicReference(0L)
val done = CountDownLatch(1)
handler.post {
value.set(player.get()?.currentPosition ?: 0L)
done.countDown()
}
done.await(5, TimeUnit.SECONDS)
return value.get()
}
private fun teardown(
handler: Handler,
player: AtomicReference<ExoPlayer?>,
thread: HandlerThread,
fixture: File
) {
val done = CountDownLatch(1)
handler.post {
player.get()?.release()
done.countDown()
}
done.await(10, TimeUnit.SECONDS)
thread.quitSafely()
thread.join(5_000)
fixture.delete()
}
private fun copyFixture(
context: android.content.Context,
asset: String = "ffmpeg/truehd_speed_repro.mka"
): File {
val output = File.createTempFile("truehd-speed-", null, context.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets
.open(asset)
.use { input -> output.outputStream().use { input.copyTo(it) } }
return output
}
/**
* The container announces 48kHz, which selection trusts, but the bitstream is genuinely 44.1kHz
* TrueHD, which the carrier does not cover (#1804). The packer emits nothing in that state, so
* the sink has to hand the stream to the decoder rather than play silence.
*/
@Test
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
if (!supportsTrueHdMatCarrier(context)) {
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
return
}
val fixture = copyFixture(context, "ffmpeg/truehd_mismatch_repro.mka")
val thread = HandlerThread("truehd-mismatch-test").apply { start() }
val handler = Handler(thread.looper)
val playing = CountDownLatch(1)
val audioDecoder = AtomicReference<String?>(null)
val trackRate = AtomicReference(-1)
val rateSequence = java.util.Collections.synchronizedList(mutableListOf<Int>())
val diagnostics = java.util.Collections.synchronizedList(mutableListOf<String>())
val player = AtomicReference<ExoPlayer?>(null)
handler.post {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
audioDiagnosticsLogger = { _, _, message ->
diagnostics.add(message)
Log.i(TAG, "mismatch sink: $message")
}
}
val selector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
)
}
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
player.set(exo)
exo.addAnalyticsListener(
object : AnalyticsListener {
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long
) {
audioDecoder.set(decoderName)
Log.i(TAG, "mismatch audio decoder: $decoderName")
}
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
config: AudioSink.AudioTrackConfig
) {
trackRate.set(config.sampleRate)
rateSequence.add(config.sampleRate)
Log.i(TAG, "mismatch AudioTrack rate=${config.sampleRate}")
}
override fun onAudioInputFormatChanged(
eventTime: AnalyticsListener.EventTime,
format: androidx.media3.common.Format,
decoderReuseEvaluation: androidx.media3.exoplayer.DecoderReuseEvaluation?
) {
Log.i(TAG, "mismatch INPUT format: mime=${format.sampleMimeType} rate=${format.sampleRate} ch=${format.channelCount}")
}
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
if (isPlaying) playing.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.prepare()
exo.playWhenReady = true
}
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
Thread.sleep(SETTLE_MS)
val positionFirst = positionOf(handler, player)
Thread.sleep(SETTLE_MS)
val positionSecond = positionOf(handler, player)
val decoder = audioDecoder.get()
val rate = trackRate.get()
Log.i(
TAG,
"==== MISMATCH RESULT: rates=$rateSequence decoder=$decoder rate=$rate " +
"position=${positionFirst}ms -> ${positionSecond}ms ===="
)
teardown(handler, player, thread, fixture)
// Without this the test would also pass on a build where the carrier was never selected at all:
// the mismatch fires on the first access unit, before the carrier writes a burst, so no 192kHz
// 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("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,
rate
)
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
}
}
+10 -1
View File
@@ -1,5 +1,11 @@
<!-- android:installLocation="auto" is what makes the app eligible to be moved to adoptable
storage (a USB drive adopted by an Android TV, an adopted SD card), which relocates the
private data directory — and therefore downloads — along with the APK. Plezy declares none
of the components this is unsafe for: no app widget, IME, live wallpaper, device admin,
accessibility service, sync adapter or account authenticator. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
@@ -44,6 +50,9 @@
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<!-- Driver-distraction state on Android Automotive OS. Optional: the platform library is
absent on every other form factor, and CarRestrictionsMonitor falls back silently. -->
<uses-library android:name="android.car" android:required="false" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -10,18 +10,24 @@ import android.content.res.Configuration
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import com.edde746.plezy.car.CarRestrictionsMonitor
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
@@ -45,6 +51,20 @@ class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
// Flutter's TextInputPlugin issues showSoftInput before the FlutterView is
// the IMM's served view (the InputConnection restart is deferred to the
// next channel message), so on TV the D-pad-driven first open is dropped
// with "Ignoring showSoftInput() as view ... is not served" and never
// retried (flutter/flutter#177360). These bounded retries re-issue the
// show once the view is served; the restart budget repairs the sibling
// failure mode where the keyboard shows but its key session never bound
// ("Ignoring onBind: cur seq=-1"), leaving Gboard blind to D-pad
// (#1051, #1079).
private const val IME_SHOW_RETRY_LIMIT = 4
private const val IME_SHOW_RETRY_INTERVAL_MS = 300L
private const val IME_LEAK_RESTART_BUDGET = 2
private const val IME_LEAK_RESTART_MIN_INTERVAL_MS = 1000L
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
@@ -74,8 +94,18 @@ class MainActivity : FlutterActivity() {
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
private var watchNextPlugin: WatchNextPlugin? = null
private var carRestrictions: CarRestrictionsMonitor? = null
private var carRestrictionsChannel: MethodChannel? = null
private var nativeTextInputFocused = false
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
private var imeShowAttempts = 0
private var imeLeakRestartBudget = 0
private var imeRestartedOnShow = false
private var imeWasVisible = false
private var lastImeLeakRestartUptime = 0L
private var imeVisibilityListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false
@@ -159,6 +189,82 @@ class MainActivity : FlutterActivity() {
return forward
}
private fun inputMethodManager(): InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
private fun flutterView(): View? = findViewById(FLUTTER_VIEW_ID)
// Re-issues a soft-input show that the engine dropped because the
// FlutterView was not yet the IMM's served view when TextInput.show ran
// (flutter/flutter#177360). Flutter never retries on its own — its Dart
// side believes the keyboard is already up — so without this the first
// D-pad-driven open on TV can silently do nothing.
private val imeShowRetry = object : Runnable {
override fun run() {
if (!nativeTextInputFocused) return
if (isImeVisible()) return
val view = flutterView()
val imm = inputMethodManager()
if (view != null && imm.isActive(view)) {
logTextInputDiag { "imeShowRetry re-showing attempt=$imeShowAttempts ${describeImeState()}" }
imm.showSoftInput(view, 0)
} else {
logTextInputDiag { "imeShowRetry waiting attempt=$imeShowAttempts served=${view != null && imm.isActive(view)}" }
}
imeShowAttempts++
if (imeShowAttempts < IME_SHOW_RETRY_LIMIT) {
imeRecoveryHandler.postDelayed(this, IME_SHOW_RETRY_INTERVAL_MS)
}
}
}
private fun startNativeTextInputSession() {
imeShowAttempts = 0
imeLeakRestartBudget = IME_LEAK_RESTART_BUDGET
imeRestartedOnShow = false
imeRecoveryHandler.removeCallbacks(imeShowRetry)
imeRecoveryHandler.postDelayed(imeShowRetry, IME_SHOW_RETRY_INTERVAL_MS)
}
private fun endNativeTextInputSession() {
imeRecoveryHandler.removeCallbacks(imeShowRetry)
}
private fun restartNativeTextInput(reason: String) {
val view = flutterView() ?: return
logTextInputDiag { "restartInput reason=$reason ${describeImeState()}" }
inputMethodManager().restartInput(view)
}
// A visible IME owns D-pad navigation: a healthy Gboard consumes these keys
// at the ImeInputStage, before the app. One arriving here therefore means
// the IME's key session never bound ("Ignoring onBind: cur seq=-1") — the
// Chromecast/Google TV failure of #1051/#1079. Repair by rebinding, and eat
// the press so Flutter focus cannot wander behind the stuck keyboard. The
// bounded budget guarantees keys flow again (and Flutter can close the
// session) if rebinding cannot heal the device.
private fun consumeLeakedImeNavigationKey(event: KeyEvent): Boolean {
if (!nativeTextInputFocused || imeLeakRestartBudget <= 0) return false
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> Unit
else -> return false
}
if (!isImeVisible()) return false
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val now = SystemClock.uptimeMillis()
if (now - lastImeLeakRestartUptime >= IME_LEAK_RESTART_MIN_INTERVAL_MS) {
lastImeLeakRestartUptime = now
imeLeakRestartBudget--
restartNativeTextInput("leaked-dpad-while-ime-visible")
}
}
logTextInputDiag { "consuming leaked IME key ${describeKeyEvent(event)} budget=$imeLeakRestartBudget" }
return true
}
private fun getAndroidTvDetection(): Map<String, Any> {
val pm = packageManager
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
@@ -429,6 +535,24 @@ class MainActivity : FlutterActivity() {
)
)
// Watch IME visibility so a fresh session can be rebound the moment the
// keyboard first shows: on Chromecast-class devices the initial bind can
// land against a stale sequence, leaving the IME without a key session
// (D-pad dead, #1051/#1079). One restartInput at first-show — before the
// user has typed or moved the key highlight — repairs it invisibly.
val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
val visible = isImeVisible()
if (visible == imeWasVisible) return@OnGlobalLayoutListener
imeWasVisible = visible
logTextInputDiag { "ime visibility changed visible=$visible ${describeImeState()}" }
if (visible && nativeTextInputFocused && !imeRestartedOnShow) {
imeRestartedOnShow = true
restartNativeTextInput("first-show-rebind")
}
}
window.decorView.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
imeVisibilityListener = visibilityListener
// Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent)
}
@@ -443,6 +567,9 @@ class MainActivity : FlutterActivity() {
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
}
// Reaching the activity means the ImeInputStage already declined this
// key, so consumption below cannot starve a healthy IME.
if (consumeLeakedImeNavigationKey(event)) return true
val handled = super.dispatchKeyEvent(event)
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag {
@@ -460,6 +587,12 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
externalPlayerChannel.dispose()
endNativeTextInputSession()
imeVisibilityListener?.let { window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(it) }
imeVisibilityListener = null
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -474,6 +607,29 @@ class MainActivity : FlutterActivity() {
}
}
// Connects the car UX-restriction monitor on first use, retrying while the platform signal is
// unavailable: a car service that was not ready during startup can still answer later, and on a
// phone every attempt fails cheaply on the FEATURE_AUTOMOTIVE check. The connect itself never
// blocks, so this is safe on the main thread; readiness arrives through the callback below.
private fun startCarRestrictionsIfNeeded() {
val existing = carRestrictions
if (existing?.supported == true) return
val monitor = existing ?: CarRestrictionsMonitor(applicationContext).also { carRestrictions = it }
monitor.start { restricted ->
runOnUiThread {
// `supported` rides along because it can go false again when the car service dies, and Dart
// must then fall back to lifecycle gating rather than read a stale verdict.
carRestrictionsChannel?.invokeMethod(
"onChanged",
mapOf(
"supported" to monitor.supported,
"requiresDistractionOptimization" to restricted
)
)
}
}
}
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
selectedFlutterRenderer = selectFlutterRenderer()
@@ -602,6 +758,28 @@ class MainActivity : FlutterActivity() {
}
}
val carChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CAR_RESTRICTIONS_CHANNEL)
carRestrictionsChannel = carChannel
carChannel.setMethodCallHandler { call, result ->
when (call.method) {
"getState" -> {
startCarRestrictionsIfNeeded()
val monitor = carRestrictions
val supported = monitor?.supported == true
result.success(
mapOf(
"supported" to supported,
// Tells Dart the difference between "this device has no car service" and "the verdict
// is coming": only the latter is worth waiting for.
"pending" to (monitor?.pending == true),
"requiresDistractionOptimization" to (supported && monitor.requiresDistractionOptimization)
)
)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
handleDeviceAdjustmentCall(call.method, call.arguments, result)
}
@@ -614,6 +792,11 @@ class MainActivity : FlutterActivity() {
logTextInputDiag {
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
}
if (nativeTextInputFocused && !oldValue) {
startNativeTextInputSession()
} else if (!nativeTextInputFocused && oldValue) {
endNativeTextInputSession()
}
result.success(null)
}
else -> result.notImplemented()
@@ -0,0 +1,263 @@
package com.edde746.plezy.car
import android.car.Car
import android.car.drivingstate.CarUxRestrictions
import android.car.drivingstate.CarUxRestrictionsManager
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.view.Display
/**
* Reads Android Automotive OS driver-distraction state straight from the platform.
*
* Plezy previously derived "may audio play?" from the Flutter activity lifecycle, which conflates
* two different things: a car that is driving, and a car that is parked with Plezy simply not in
* the foreground. Only the first must silence playback (car app quality `DD-2`/`DD-3`); the second
* is ordinary background audio. [CarUxRestrictionsManager] is the signal the platform documents for
* apps where lifecycle reaction is not sufficient.
*
* Bound explicitly to [Display.DEFAULT_DISPLAY] — the driver's screen — by creating a display
* context for it. An application context would NOT do: it has no associated display, so
* `CarUxRestrictionsManager` falls back to the current user's assigned main display, which under
* multi-user Android Automotive OS can be a passenger screen. UX restrictions legitimately differ
* per display, but audio is not per-display, and `DD-2` requires audio to stop when the user starts
* driving unless the device declares `com.android.car.background_audio_while_driving`. Reading the
* driver display therefore keeps a passenger-display session stricter than it may need to be, which
* is the safe direction; do not rebind this without pairing it with that feature check. If the
* default display cannot be resolved, [start] fails closed and the caller keeps lifecycle gating.
*
* Connection uses the [Car.CarServiceLifecycleListener] overload with
* [Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT], which is not a preference:
*
* - `Car.createCar(Context)` blocks the calling thread polling for the car service (AOSP polls
* every 50 ms up to 100 times, so up to five seconds) and is reached from the main thread here.
* - Worse, a client created without a lifecycle listener is terminated when the car service dies:
* the library calls `finishClient()`, which for anything that is not literally an `Activity` or
* `Service` — a display context included — runs `Process.killProcess(myPid())`. A routine car
* service update would take the whole app down.
*
* With the listener, `createCar` never blocks and a service restart arrives as `ready = false`
* instead. Every `ready = true` re-fetches the manager and re-registers the listener, because the
* old manager holds a dead binder: its calls then return null and are swallowed, which would look
* like a permanently parked car rather than a failure.
*
* `android.car` is a platform library that only exists on Android Automotive OS images, hence the
* `useLibrary` compile-time stub, the `uses-library required=false` manifest entry, and the
* defensive [Throwable] catches: on a phone the classes are simply absent.
*/
class CarRestrictionsMonitor(
private val context: Context,
/**
* Forces the pre-Android-11 [ServiceConnection] route on a device that also has the newer one, so
* instrumentation can exercise it: no Android 9 or 10 Automotive system image is published.
*/
private val forceLegacyConnect: Boolean = false
) {
private var car: Car? = null
private var manager: CarUxRestrictionsManager? = null
private var onChanged: ((Boolean) -> Unit)? = null
private var connecting = false
private var legacyConnection = false
/**
* True while a live car connection can answer; until then callers must not trust
* [requiresDistractionOptimization]. Goes back to false if the car service dies.
*/
var supported: Boolean = false
private set
/**
* Latest platform verdict. Defaults to restricted so a half-connected car can never be read as
* "free to play"; [supported] is what gates whether this value is used at all.
*/
var requiresDistractionOptimization: Boolean = true
private set
/**
* A connection exists but has not produced a verdict yet — the car service is starting, or died
* and is expected back. Callers that gate behaviour on the verdict should wait rather than treat
* this like a phone, where no answer is ever coming.
*/
val pending: Boolean
get() = connecting && !supported
/**
* Begins connecting to the car service. Returns whether this device can have the signal at all,
* NOT whether it is ready: readiness arrives through [onChanged], and [supported] reports it.
* A false return leaves the caller on its previous (lifecycle-derived) behaviour for good.
*/
fun start(onChanged: (Boolean) -> Unit): Boolean {
if (supported) return true
val existing = car
if (existing != null) {
// A connection exists but is not observing the vehicle: the manager lookup or the listener
// registration failed. Retry it here, because the car service is already ready and will not
// announce itself again — without this the signal would stay dark for the whole process.
this.onChanged = onChanged
if (existing.isConnected) bind(existing) else reconnectLegacyIfNeeded()
return true
}
if (connecting) return true
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
return try {
// Fail closed when the driver display cannot be resolved: an unbound context would silently
// report some other display's restrictions (see the class doc).
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return false
val driverDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY) ?: return false
val displayContext = context.createDisplayContext(driverDisplay)
this.onChanged = onChanged
connecting = true
if (!forceLegacyConnect && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
connectWithLifecycleListener(displayContext)
} else {
connectWithServiceConnection(displayContext)
}
} catch (error: Throwable) {
Log.w(TAG, "Car UX restrictions unavailable; falling back to lifecycle gating", error)
release()
false
}
}
/** Android 11 and newer: the car service announces readiness and death through one callback. */
private fun connectWithLifecycleListener(displayContext: Context): Boolean {
val created = Car.createCar(
displayContext,
null,
Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT
) { connectedCar: Car, ready: Boolean ->
// Use the Car handed to the listener: on the synchronous path — the car service is already
// up and this runs on the main thread — it fires inside createCar, before it returns.
if (ready) bind(connectedCar) else unbind()
}
// Cold service: the listener has not run, so this is the only reference to the pending
// connection and release() would otherwise leave it bound for the process's lifetime.
if (car == null) car = created
return true
}
/**
* Android 9 and 10 head units, which have no [Car.CarServiceLifecycleListener].
*
* A [ServiceConnection] is the safe alternative, not merely an older one: the process kill in
* `finishClient()` is reached only by a client that supplies neither callback, and binding here is
* asynchronous, so nothing blocks. Kept behind a version check because naming the newer overload
* on such a device raises `NoSuchMethodError`.
*/
@Suppress("DEPRECATION")
private fun connectWithServiceConnection(displayContext: Context): Boolean {
legacyConnection = true
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
car?.let { bind(it) }
}
override fun onServiceDisconnected(name: ComponentName?) {
unbind()
// Android 10 unbinds inside its own disconnect handling, so the binding that would have
// reconnected is gone; newer platforms keep it and race us harmlessly.
reconnectLegacyIfNeeded()
}
}
val created = Car.createCar(displayContext, connection)
if (created == null) {
// The platform returns null when the car service loader is unavailable. Clearing the
// connection state matters: leaving it set would report a pending verdict for the rest of the
// process, and every later start would short-circuit instead of trying again.
Log.w(TAG, "Car service loader unavailable; falling back to lifecycle gating for now")
release()
return false
}
car = created
created.connect()
return true
}
private fun reconnectLegacyIfNeeded() {
if (!legacyConnection) return
val existing = car ?: return
if (existing.isConnected || existing.isConnecting) return
try {
existing.connect()
} catch (error: IllegalStateException) {
// The platform's own reconnect won the race, which is the outcome we wanted anyway.
Log.w(TAG, "Car service reconnect already in progress", error)
}
}
/** Adopts a ready car service: fresh manager, fresh listener, fresh verdict. */
private fun bind(connectedCar: Car) {
car = connectedCar
try {
val uxManager = connectedCar.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager
if (uxManager == null) {
Log.w(TAG, "Car service is ready but has no UX restrictions manager")
return
}
manager = uxManager
// Register before reading, per the platform's own guidance: registration subscribes to future
// changes only, it does not replay the current one. Reading first would drop a transition that
// lands between the two binder calls, and publishing the stale "parked" afterwards would then
// override lifecycle gating and permit audio for the whole drive.
uxManager.registerListener { restrictions: CarUxRestrictions -> publish(restrictions.isRequiresDistractionOptimization) }
supported = true
publish(uxManager.currentCarUxRestrictions?.isRequiresDistractionOptimization ?: true)
} catch (error: Throwable) {
// Registration is the failure the caller's retry is meant to survive, so keep the connection
// owned (it is in `car`) and report nothing rather than half-observing the vehicle.
Log.w(TAG, "Failed to observe car UX restrictions", error)
manager = null
supported = false
}
}
/**
* Drops a dead car service. The [Car] itself is kept: its `BIND_AUTO_CREATE` binding is what
* reconnects, and disconnecting would end that for good.
*/
private fun unbind() {
Log.w(TAG, "Car service went away; falling back to lifecycle gating until it returns")
manager = null
supported = false
requiresDistractionOptimization = true
onChanged?.invoke(true)
}
private fun publish(restricted: Boolean) {
requiresDistractionOptimization = restricted
onChanged?.invoke(restricted)
}
/** Unregisters and disconnects. Safe to call when never started. */
fun release() {
try {
manager?.unregisterListener()
} catch (error: Throwable) {
Log.w(TAG, "Failed to unregister car UX restrictions listener", error)
}
try {
car?.disconnect()
} catch (error: Throwable) {
Log.w(TAG, "Failed to disconnect from the car service", error)
}
manager = null
car = null
onChanged = null
connecting = false
legacyConnection = false
supported = false
requiresDistractionOptimization = true
}
private companion object {
const val TAG = "CarRestrictions"
}
}
@@ -0,0 +1,36 @@
package com.edde746.plezy.exoplayer
/**
* Decides which audio outputs `RawPositionOutputProvider` may park in its reuse cache instead of
* releasing on a sink flush.
*
* The cache exists so a seek does not tear down and rebuild the hardware audio pipeline, which
* costs 7-10s of silence on Android TV boxes that reinitialize tunneled output slowly (Sony
* Bravia class). That win is real for decoded PCM, but parking a *bitstream* output is a
* different trade: a direct/passthrough `AudioTrack` occupies the platform's direct output for as
* long as it is held, and several HDMI HALs expose only one. A parked AC3 track therefore blocks
* the next AC3 track from being created at all, and the failure surfaces as
* `UnsupportedOperationException: Cannot create AudioTrack` rather than as a slow seek (#1790).
*
* Parking also has to stay honest about media3's process-wide pending-release accounting: a
* cached output is *not* releasing, so `RawPositionAudioOutput` signals `onReleased` immediately
* for it. That is only true because nothing scarce is being held, which is exactly what this
* policy guarantees.
*
* Offload outputs are excluded for the same reason plus their separate gapless/end-of-stream
* state machine, which the cache does not model.
*/
internal object AudioOutputCachePolicy {
/**
* `AudioTrack.flush()` only reliably resets a stopped track from N MR1 onwards; below that the
* cache would hand back a track carrying the previous playback's state.
*/
const val MIN_SDK_INT = 25
fun mayCache(encoding: Int, isOffload: Boolean, sdkInt: Int): Boolean {
if (sdkInt < MIN_SDK_INT) return false
if (isOffload) return false
return isPcmEncoding(encoding)
}
}
@@ -1,6 +1,10 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
@@ -25,6 +29,19 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
/**
* Linear PCM output encodings, i.e. the sink decoded the bitstream instead of
* passing it through. Mirrors the platform's `AudioFormat.ENCODING_PCM_*` set.
*/
internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_FLOAT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT -> true
else -> false
}
/**
* mpv `audio-spdif` codec names and the exact platform encoding a route must
* advertise to carry that bitstream.
@@ -72,3 +89,51 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
}
return mpvSpdifCodecs(capabilities::supportsEncoding)
}
/**
* Whether this route can carry TrueHD as MAT inside IEC 61937 (#1804).
*
* 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
* carrier on the 192kHz/7.1 IEC combination specifically. media3 instead asks the audio policy
* 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, where the API exists,
* `getDirectPlaybackSupport` proves the route will actually bitstream it rather than silently
* decode or wedge.
*/
internal fun supportsTrueHdMatCarrier(context: Context): Boolean {
// getMinBufferSize alone is not sufficient. On a Shield it answers yes for the 192kHz/7.1 IEC
// tuple and the AudioTrack then fails to initialise; it reports that a buffer can be sized, not
// that the route will carry the format. Without getDirectPlaybackSupport there is no way to tell
// the two apart, so below API 33 the carrier is not offered and TrueHD decodes as before.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
val rate = TrueHdMatPacker.CARRIER_SAMPLE_RATE
val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
val sizedOk = try {
AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_IEC61937) > 0
} catch (error: Exception) {
false
}
if (!sizedOk) return false
return try {
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
.getPlatformAudioAttributes()
val probe = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
.setChannelMask(mask)
.setSampleRate(rate)
.build()
val support = AudioManager.getDirectPlaybackSupport(probe, audioAttributes)
(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)
false
}
}
@@ -0,0 +1,112 @@
package com.edde746.plezy.exoplayer
/**
* Decision logic for the buffering stall watchdog (#1790).
*
* A renderer that can never become ready pins ExoPlayer in `STATE_BUFFERING` with a full buffer
* and a frozen clock. The reported case is an `AudioTrack` that fails to initialize: media3 logs a
* sink error, keeps retrying, and — when its process-wide pending-release accounting has been
* knocked out of balance — never converts the failure into a `PlaybackException`. Nothing else
* notices. The frame watchdog wants `STATE_READY` and zero rendered frames, the decoder-hang check
* is cancelled by the first frame, [ResumeStallPolicy] needs `isPlaying` and deliberately treats a
* frozen clock as "not a decoder stall", [EndOfStreamPolicy] needs the position past the duration,
* and media3's own stuck-buffering detector needs an *empty* buffer. So the user sits on a spinner
* with no error and no way out.
*
* This watchdog covers exactly that hole: buffering, intending to play, holding data, and not
* moving. A starved buffer is left alone — that is an ordinary network stall and the loader is the
* right thing to wait for.
*/
internal object BufferingStallPolicy {
/** Poll interval while buffering. */
const val CHECK_INTERVAL_MS = 2_000L
/**
* How long a buffering player may hold enough data to start and still not move before it is
* called stalled. Long enough to sit out a slow decoder handover or a track reselection, short
* enough that a user has not yet given up and force-quit.
*
* Only time spent above [MIN_BUFFER_AHEAD_MS] counts: a rebuffer that takes a minute to refill
* is the loader doing its job, and the clock restarts once it is done.
*/
const val STALL_TIMEOUT_MS = 12_000L
/** Position movement that counts as progress rather than clock jitter. */
const val PROGRESS_EPSILON_MS = 250L
/**
* Media buffered ahead of the position before a motionless player is anomalous at all.
*
* A fallback bar, used only until media3 has answered for itself (see [evaluate]). It has to
* clear `DefaultLoadControl`'s own play-start bar with room to spare, or the watchdog would
* indict the player for obeying it: below [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS]
* the load control deliberately keeps playback in `STATE_BUFFERING`, and the two evaluate on
* independent schedules.
*
* Measured in playout time, not media time, because `shouldStartPlayback` divides buffered media
* duration by the playback speed: at 2x the same bar needs twice the media, and comparing raw
* media duration would call a legitimately rebuffering fast-forward stalled.
*/
const val MIN_BUFFER_AHEAD_MS = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + 2_000L
enum class Verdict {
/** The position moved; re-baseline and keep watching. */
HEALTHY,
/** Enough data to start, not moving, but not for long enough yet. */
WAITING,
/** Not moving because there is not yet enough to play. An ordinary rebuffer; not ours. */
STARVED,
/** Enough data to start, playback is wanted, and nothing has moved. */
STALLED
}
/**
* Every signal here can only ever *permit* an indictment; none of them vetoes one, because a
* signal that can go stale would otherwise wedge this watchdog shut in exactly the failure it
* exists to catch.
*
* [loadControlReady] is media3's own `shouldStartPlayback` verdict when it has given one, which is
* the most faithful answer available — but it is only asked while the renderers report ready, and
* a renderer that never becomes ready is that failure. A `false` recorded before the renderer got
* stuck would never be revised, so it is treated as no answer rather than as a denial.
*
* [loading] false means the load control stopped asking for data, whether because its duration
* thresholds are met or because its byte target is full. On a high bitrate stream that byte cap is
* reached well below [MIN_BUFFER_AHEAD_MS] (see [LoadControlPolicy]), and without this signal a
* genuinely stuck player there would look like an ordinary rebuffer forever. A network stall is
* the opposite: the loader keeps wanting data it cannot get, so [loading] stays true and none of
* the three signals fires.
*/
fun evaluate(
elapsedMs: Long,
baselinePositionMs: Long,
currentPositionMs: Long,
bufferedPositionMs: Long,
playbackSpeed: Float = 1f,
loadControlReady: Boolean? = null,
loading: Boolean = true
): Verdict {
val speed = if (playbackSpeed > 0f) playbackSpeed else 1f
val bufferedPlayoutMs = ((bufferedPositionMs - currentPositionMs) / speed).toLong()
val readyToPlay = loadControlReady == true || !loading || bufferedPlayoutMs >= MIN_BUFFER_AHEAD_MS
return when {
currentPositionMs - baselinePositionMs >= PROGRESS_EPSILON_MS -> Verdict.HEALTHY
!readyToPlay -> Verdict.STARVED
elapsedMs < STALL_TIMEOUT_MS -> Verdict.WAITING
else -> Verdict.STALLED
}
}
/**
* Whether [verdict] means the elapsed stall clock was measuring something the watchdog is not
* responsible for, and must restart.
*
* Without this a long rebuffer accrues the whole timeout while starved, and the first poll after
* the buffer refills reports a stall that never happened.
*/
fun resetsStallClock(verdict: Verdict): Boolean = verdict == Verdict.HEALTHY || verdict == Verdict.STARVED
}
@@ -215,6 +215,7 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var assVideoLatencyFrames = 0
private var subtitlePositionPercent: Int = 100
private var subtitleFontSize: Float = 55f
private var subtitleAnchorToScreen: Boolean = false
private var lastSubtitleCues: List<Cue> = emptyList()
// Tracks whether a text track was selected on the previous onTracksChanged so we
@@ -275,6 +276,29 @@ class ExoPlayerCore(private val activity: Activity) :
private var resumeStallRecoveryCount = 0
private var loggedResumeStallCap = false
// Buffering stall watchdog (#1790): a renderer that never becomes ready pins the player in
// STATE_BUFFERING with a full buffer and a frozen clock, and — when media3 absorbs the
// underlying failure instead of raising it — nothing else ever notices. Armed on every
// transition into buffering; see BufferingStallPolicy.
private var bufferingStallRunnable: Runnable? = null
private var bufferingStallSinceMs = 0L
private var bufferingStallBaselinePositionMs = 0L
/**
* The live load control, so the buffering watchdog can ask whether media3 itself considers the
* buffer sufficient to start rather than re-deriving that from durations.
*/
private var observingLoadControl: ObservingLoadControl? = null
/**
* The read-ahead limits this session actually resolved to, kept so [getStats] can report them.
* Both ceilings matter and the smaller one binds: with `prioritizeTimeOverSizeThresholds`
* disabled the byte target stops the loader even below `minBufferMs`, so a raised Maximum
* Buffer that changes nothing on a UHD remux is explained by these two numbers side by side.
*/
private var resolvedTargetBufferBytes: Int? = null
private var resolvedBufferDurations: LoadControlPolicy.BufferDurations? = null
// Decoder hang detection: tracks gap between decoder init and first rendered frame
private var decoderHangRunnable: Runnable? = null
private var decoderInitName: String? = null
@@ -314,6 +338,12 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var detectedFrameRate: Float = -1f
private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT)
// Frame rate the media server reported for the open item, or -1 when unknown.
// Supplied per open because the extractors media3 uses for direct play (Matroska,
// MP4) never populate Format.frameRate, and the tunneled path renders no frames
// back to the app for detectedFrameRate to derive one from.
@Volatile private var contentFrameRate: Float = -1f
@Volatile private var fpsTimestampCount = 0
private var assSyncFrameCount = 0L
@@ -323,8 +353,17 @@ class ExoPlayerCore(private val activity: Activity) :
// Track state for event emission
private var lastPosition: Long = 0
/** Position to use for fallback: max of current position and pending start position. */
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs)
/**
* Highest position actually reached in the current media generation, or the last explicit seek
* target. [lastPosition] tracks the emitted timeline and follows the player down as well as up,
* so a renderer that reports 0 while its clock is dead would otherwise hand recovery a 0ms
* resume point — which is how an audio recovery restarted a resumed episode from the top
* (#1790). Recovery reads this instead.
*/
private var lastKnownGoodPositionMs: Long = 0L
/** Position to use for fallback: the furthest of the tracked positions. */
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs, lastKnownGoodPositionMs)
private var lastDuration: Long = 0
private var lastBufferedPosition: Long = 0
private var positionUpdateRunnable: Runnable? = null
@@ -525,7 +564,11 @@ class ExoPlayerCore(private val activity: Activity) :
bufferSizeBytes: Int? = null,
bufferSizeAuto: Boolean = false,
tunnelingEnabled: Boolean = true,
audioPassthroughEnabled: Boolean = false
audioPassthroughEnabled: Boolean = false,
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
// is internal and this function is not; unrecognised names resolve to Auto, which is also
// the default (#1816).
bufferTier: String = "auto"
): Boolean {
if (isInitialized) {
Log.d(TAG, "Already initialized")
@@ -748,20 +791,32 @@ class ExoPlayerCore(private val activity: Activity) :
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
}
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
resolvedTargetBufferBytes = targetBufferBytes
resolvedBufferDurations = bufferDurations
val loadControl = DefaultLoadControl.Builder().apply {
setTargetBufferBytes(targetBufferBytes)
setPrioritizeTimeOverSizeThresholds(false)
if (availableMB <= 2048) {
setBufferDurationsMs(15_000, 50_000, 1_000, 5_000)
} else {
setBufferDurationsMs(30_000, 60_000, 1_000, 5_000)
}
}.build()
// Generic setter only. media3 1.9 added ...ForStreaming/...ForLocalPlayback variants and a
// latch that stops mirroring these into the local-playback fields the moment either is
// called, which would silently give file:// playback its own defaults.
setBufferDurationsMs(
bufferDurations.minBufferMs,
bufferDurations.maxBufferMs,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}.build().let { ObservingLoadControl(it).also { observing -> observingLoadControl = observing } }
emitLog(
"info",
"init",
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
"heap=${largeHeapMB}MB, available=${availableMB}MB), tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
"(${resolvedTier.name.lowercase()}), " +
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
)
exoPlayer = ExoPlayer.Builder(activity)
@@ -963,6 +1018,7 @@ class ExoPlayerCore(private val activity: Activity) :
// classifies an EOF by position against duration.
if (currentPosition != lastPosition && terminalErrorGeneration != currentMediaGeneration) {
lastPosition = currentPosition
if (currentPosition > lastKnownGoodPositionMs) lastKnownGoodPositionMs = currentPosition
delegate?.onPropertyChange("time-pos", currentPosition / 1000.0)
}
@@ -1004,6 +1060,7 @@ class ExoPlayerCore(private val activity: Activity) :
private fun resetPlaybackProgress(startPositionMs: Long) {
lastPosition = startPositionMs
lastKnownGoodPositionMs = startPositionMs
lastDuration = 0L
lastBufferedPosition = 0L
// Dart already seeds the visible timeline before open. Emitting native
@@ -1083,8 +1140,10 @@ class ExoPlayerCore(private val activity: Activity) :
when (state) {
Player.STATE_BUFFERING -> {
delegate?.onPropertyChange("paused-for-cache", true)
armBufferingStallWatchdog()
}
Player.STATE_READY -> {
cancelBufferingStallWatchdog()
// Restore start position if it was lost during track reselection
// (e.g. tunneling state change in onTracksChanged triggers renderer teardown)
if (pendingStartPositionMs > 0L) {
@@ -1120,6 +1179,7 @@ class ExoPlayerCore(private val activity: Activity) :
}
Player.STATE_ENDED -> {
stopFrameWatchdog()
cancelBufferingStallWatchdog()
delegate?.onPropertyChange("eof-reached", true)
delegate?.onEvent("end-file", mapOf("reason" to "eof"))
}
@@ -1206,6 +1266,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
emitSeekable(false, force = true)
// If native DV7 failed, retry with conversion before falling to MPV
@@ -1346,7 +1407,7 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
val previousDecoder = decoderInitName
pendingTrackRestore = pendingTrackRestore ?: captureTrackRestore()?.also { restore ->
@@ -1368,6 +1429,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(
reason = "video decoder recovery",
forceSelector = true,
@@ -1387,6 +1449,8 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
armBufferingStallWatchdog()
return true
}
@@ -1413,33 +1477,51 @@ class ExoPlayerCore(private val activity: Activity) :
private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean {
if (!isAudioTrackError(error.errorCode)) return false
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
format.sampleMimeType?.startsWith("audio/") == true
}
return recoverAudioOutputInPlace(
label = PlaybackException.getErrorCodeName(error.errorCode),
reason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}",
fallbackFormat = errorFormat,
blockDirectOutput = true
)
}
/**
* Re-prepares the current media with the audio output forced onto a path that has not just
* failed. Shared by the [PlaybackException] route and by the buffering stall watchdog, which
* reaches the same failure without an exception ever being raised (#1790).
*/
private fun recoverAudioOutputInPlace(
label: String,
reason: String,
fallbackFormat: Format?,
blockDirectOutput: Boolean
): Boolean {
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
if (audioRecoveryAttempts >= MAX_AUDIO_RECOVERY_ATTEMPTS) {
emitLog(
"warn",
"audio-recovery",
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for ${PlaybackException.getErrorCodeName(error.errorCode)}"
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for $label"
)
return false
}
val selectedFormat = selectedAudioFormat()
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
format.sampleMimeType?.startsWith("audio/") == true
}
val recoveryFormat = selectedFormat ?: errorFormat
val recoveryFormat = selectedAudioFormat() ?: fallbackFormat
val actions = mutableListOf<String>()
recoveryFormat?.sampleMimeType
?.takeIf { isEncodedAudioMimeType(it) }
?.let { mimeType ->
if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
actions.add("force-decoded-pcm($mimeType)")
if (blockDirectOutput) {
recoveryFormat?.sampleMimeType
?.takeIf { isEncodedAudioMimeType(it) }
?.let { mimeType ->
if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
actions.add("force-decoded-pcm($mimeType)")
}
}
}
}
if (!tunnelingDisabledForAudioRecovery) {
tunnelingDisabledForAudioRecovery = true
actions.add("disable-tunneling")
@@ -1447,7 +1529,7 @@ class ExoPlayerCore(private val activity: Activity) :
if (actions.isEmpty()) actions.add("reload")
audioRecoveryAttempts++
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
val previousAudioTrackConfig = lastAudioTrackConfig
pendingStartPositionMs = savedPosition
@@ -1455,11 +1537,12 @@ class ExoPlayerCore(private val activity: Activity) :
audioDecoderInitName = null
lastAudioTrackConfig = null
lastAudioRecoveryAction = actions.joinToString(",")
lastAudioRecoveryReason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}"
lastAudioRecoveryReason = reason
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(reason = "audio recovery", forceSelector = true)
emitLog(
@@ -1473,6 +1556,8 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
armBufferingStallWatchdog()
return true
}
@@ -1673,7 +1758,8 @@ class ExoPlayerCore(private val activity: Activity) :
videoHeight,
pixelRatio,
resizeMode,
videoZoomScale
videoZoomScale,
subtitleAnchorToScreen
)
val bitmapDimensions = SubtitleViewLayout.bitmapDimensions(
containerWidth,
@@ -1877,14 +1963,15 @@ class ExoPlayerCore(private val activity: Activity) :
subtitleTrackGroupMap[trackId] = trackGroup
val isSelected = group.isSelected
// Detect external (side-loaded) subtitle by the ID prefix set in open()
val isExternal = format.id?.startsWith("external_") == true
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
// Detect external (side-loaded) subtitle by the ID set in open(). media3
// rewrites merged child ids, so the tag is not the whole id.
val isExternal = ExternalSubtitleIds.isExternal(format.id)
val externalIndex = ExternalSubtitleIds.indexOf(format.id)
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null
Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
Log.d(TAG, "Subtitle track $groupIndex: formatId=${format.id}, codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
val track = mutableMapOf<String, Any?>(
"type" to "sub",
@@ -2536,6 +2623,7 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return null
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
return tunnelingUserEnabled &&
!DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate) &&
(player.playbackParameters.speed == 1f) &&
!tunnelingDisabledForCodec &&
!tunnelingDisabledForAssSubtitles &&
@@ -2631,15 +2719,6 @@ class ExoPlayerCore(private val activity: Activity) :
return selectedAudioGroup.mediaTrackGroup.getFormat(0)
}
private fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_FLOAT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT -> true
else -> false
}
private fun formatAudioSummary(format: Format): String {
val parts = mutableListOf<String>()
parts.add("mime=${format.sampleMimeType ?: "unknown"}")
@@ -3123,6 +3202,114 @@ class ExoPlayerCore(private val activity: Activity) :
}
}
// Buffering stall watchdog (#1790) — see BufferingStallPolicy for the coverage argument.
// Main-thread only, like the watchdogs above.
private fun armBufferingStallWatchdog() {
cancelBufferingStallWatchdog()
val player = exoPlayer ?: return
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = player.currentPosition
// Fresh window: do not judge it on a verdict media3 gave for the previous one.
observingLoadControl?.reset()
val mediaGeneration = currentMediaGeneration
bufferingStallRunnable = object : Runnable {
override fun run() {
if (mediaGeneration != currentMediaGeneration) return
if (disposing || !isInitialized) return
val current = exoPlayer ?: return
if (current.playbackState != Player.STATE_BUFFERING) {
cancelBufferingStallWatchdog()
return
}
val now = System.currentTimeMillis()
// Paused mid-buffer: nothing is meant to progress, so the clock does not run.
if (!current.playWhenReady) {
bufferingStallSinceMs = now
bufferingStallBaselinePositionMs = current.currentPosition
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
return
}
val elapsedMs = now - bufferingStallSinceMs
val verdict = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = bufferingStallBaselinePositionMs,
currentPositionMs = current.currentPosition,
bufferedPositionMs = current.bufferedPosition,
// The load control's bar is in playout time, so a fast-forward legitimately needs
// proportionally more media. Only used when media3 has not answered for itself yet.
playbackSpeed = current.playbackParameters.speed,
// media3's own verdict, which also covers the byte-target release no duration comparison
// can express — but it is only asked once the renderers are ready.
loadControlReady = observingLoadControl?.startPlaybackVerdict,
// A loader that has stopped asking for data has all it wants, whatever the duration says.
// This is the signal that survives a renderer which never becomes ready.
loading = current.isLoading
)
if (verdict == BufferingStallPolicy.Verdict.STALLED) {
cancelBufferingStallWatchdog()
recoverFromBufferingStall(current, mediaGeneration, elapsedMs)
return
}
// Moving again, or still short of the load control's play-start threshold: either way the
// clock was not measuring a stall this watchdog owns, so it restarts.
if (BufferingStallPolicy.resetsStallClock(verdict)) {
bufferingStallSinceMs = now
bufferingStallBaselinePositionMs = current.currentPosition
}
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
}
}
handler.postDelayed(bufferingStallRunnable!!, BufferingStallPolicy.CHECK_INTERVAL_MS)
}
private fun cancelBufferingStallWatchdog() {
bufferingStallRunnable?.let { handler.removeCallbacks(it) }
bufferingStallRunnable = null
}
private fun recoverFromBufferingStall(player: ExoPlayer, mediaGeneration: Int, stalledMs: Long) {
val positionMs = player.currentPosition
val playWhenReady = player.playWhenReady
val sinkError = lastAudioSinkError
emitLog(
"warn",
"buffering-stall",
"No progress for ${stalledMs}ms at ${positionMs}ms while buffering " +
"(buffered=${player.bufferedPosition}ms, audio=${selectedAudioFormat()?.let { formatAudioSummary(it) } ?: "unknown"}, " +
"tunneling=$currentTunneledPlayback, lastSinkError=${sinkError ?: "none"})"
)
// A sink error with no exception behind it is the shape media3 absorbs, so take the audio
// path off bitstream. Without one, the reload alone is the unstick.
if (recoverAudioOutputInPlace(
label = "buffering stall",
reason = "buffering stall after ${stalledMs}ms: ${sinkError ?: "no sink error reported"}",
fallbackFormat = null,
blockDirectOutput = sinkError != null
)
) {
// recoverAudioOutputInPlace re-arms the watchdog itself, so the retry is watched and can
// escalate to the handover below.
return
}
// In-place recovery is spent. Hand over to the MPV backend, which has its own audio path,
// rather than leaving the user on a spinner.
val uri = currentMediaUri ?: return
requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri,
// Not the raw position: a failed sink can report 0, which would restart a resumed episode from
// the beginning. Every other fallback path hands over the tracked position for the same reason.
positionMs = maxOf(positionMs, effectivePosition),
playWhenReady = playWhenReady,
errorMessage = "Playback stalled while buffering for ${stalledMs}ms"
)
}
// Public API
fun open(
@@ -3132,18 +3319,21 @@ class ExoPlayerCore(private val activity: Activity) :
autoPlay: Boolean,
mediaGeneration: Int,
isLive: Boolean = false,
externalSubtitleList: List<Map<String, Any?>>? = null
externalSubtitleList: List<Map<String, Any?>>? = null,
contentFrameRate: Float = -1f
) {
if (!isInitialized) return
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
resumeStallRecoveryCount = 0
loggedResumeStallCap = false
// Reset FPS detection for new content
detectedFrameRate = -1f
this.contentFrameRate = contentFrameRate
fpsTimestampCount = 0
assSyncFrameCount = 0
@@ -3222,7 +3412,7 @@ class ExoPlayerCore(private val activity: Activity) :
(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
(if (isForced) C.SELECTION_FLAG_FORCED else 0)
val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
.setId("external_$index")
.setId(ExternalSubtitleIds.idFor(index))
.setLabel(title ?: "External")
.setLanguage(language)
.setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
@@ -3254,14 +3444,30 @@ class ExoPlayerCore(private val activity: Activity) :
)
emitSeekable(false, force = true)
// Only here: this is the one caller that is a genuinely new item. The recovery, DV-mode and
// subtitle reloads all reuse setCurrentMediaSource for the *same* stream, and clearing
// per-stream audio decisions there would undo them and loop.
renderersFactory?.beginMediaItem()
exoPlayer?.apply {
setCurrentMediaSource(this, uri, startPositionMs)
prepare()
playWhenReady = autoPlay
// Same reason as the recovery reloads: replacing the source while the previous item was
// already buffering produces no state change, so nothing would arm the watchdog for this
// generation. The runnable cancels itself as soon as the player is not buffering.
armBufferingStallWatchdog()
}
val sourceLabel = if (isLive) "live HLS" else "media"
emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
emitLog(
"info",
"media",
"Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, " +
"contentFps=${if (contentFrameRate > 0f) contentFrameRate.toString() else "unknown"}, " +
"sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled, " +
"tunnelingStatus=${exoPlayer?.let(::getTunnelingStatus) ?: "n/a"}"
)
}
fun setAudioDelay(seconds: Double) {
@@ -3450,7 +3656,7 @@ class ExoPlayerCore(private val activity: Activity) :
val uri = currentMediaUri ?: return false
if (currentMediaIsLive) return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
pendingTrackRestore = captureTrackRestore()?.also { restore ->
emitLog(
@@ -3478,6 +3684,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(
reason = "DV reload",
@@ -3489,6 +3696,10 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// Replacing the source while the player is already buffering produces no state change, and that
// change is the only thing that arms the stall watchdog — so arm it here or a reload that never
// becomes ready has nothing left watching it.
armBufferingStallWatchdog()
emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
return true
}
@@ -3507,6 +3718,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
exoPlayer?.stop()
emitSeekable(false, force = true)
setVisible(false)
@@ -3526,6 +3738,18 @@ class ExoPlayerCore(private val activity: Activity) :
pendingStartPositionMs = 0L
player.seekTo(clampedPositionMs)
lastPosition = clampedPositionMs
lastKnownGoodPositionMs = clampedPositionMs
// A seek discards whatever the stall watchdog was measuring: it compares against a baseline
// position, and the policy reads a lower position as stalled, so a backward seek during a
// legitimate buffered wait would inherit the pre-seek stall time and trip recovery on the next
// poll. Re-baseline here so the new position gets the full timeout.
if (bufferingStallRunnable != null) {
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = clampedPositionMs
// The load control's recorded verdict belongs to the buffer this seek just discarded, and the
// policy prefers it over the live loader state, so a stale answer would decide the new window.
observingLoadControl?.reset()
}
delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
}
@@ -3585,7 +3809,7 @@ class ExoPlayerCore(private val activity: Activity) :
val existingIndex = externalSubtitleUris.indexOf(uri)
val isNew = existingIndex < 0
val index = if (isNew) externalSubtitles.size else existingIndex
val formatId = "external_$index"
val formatId = ExternalSubtitleIds.idFor(index)
if (isNew) {
// SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
@@ -3631,10 +3855,14 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, mediaUri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// Same-state source replacement: media3 reports no change when the player was already
// buffering, so arm here or this window keeps the previous one's watchdog and verdict.
armBufferingStallWatchdog()
} else {
// Already attached — select the existing track via override.
// Already attached — select the existing track via override. The
// reported id carries media3's merge prefixes, so compare the tag.
val trackId = subtitleTrackGroupMap.entries
.firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
.firstOrNull { (_, group) -> ExternalSubtitleIds.indexOf(group.getFormat(0).id) == index }
?.key
if (trackId != null) {
selectSubtitleTrack(trackId)
@@ -3685,7 +3913,8 @@ class ExoPlayerCore(private val activity: Activity) :
bgOpacity: Int,
subtitlePosition: Int = 100,
bold: Boolean = false,
italic: Boolean = false
italic: Boolean = false,
anchorToScreen: Boolean = false
) {
activity.runOnUiThread {
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
@@ -3733,6 +3962,18 @@ class ExoPlayerCore(private val activity: Activity) :
subtitlePositionPercent = clampedPosition
subtitleFontSize = fontSize
// Anchor-to-screen (#1730): resize the text SubtitleView to the full
// container so default-placed cues land in the letterbox bars.
val anchorChanged = subtitleAnchorToScreen != anchorToScreen
subtitleAnchorToScreen = anchorToScreen
if (anchorChanged) {
lastVideoSize?.let { vs ->
if (vs.width > 0 && vs.height > 0) {
updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
}
}
}
// Cue-level positioning handles default VTT/SRT placement, whose line
// numbers bypass SubtitleView bottom padding. Authored VTT line positions
// are preserved in applySubtitlePosition().
@@ -3754,7 +3995,7 @@ class ExoPlayerCore(private val activity: Activity) :
Log.w(TAG, "Failed to set ASS font scale: ${e.message}")
}
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale")
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, anchorToScreen=$anchorToScreen, assScale=$scale")
}
}
@@ -3926,6 +4167,10 @@ class ExoPlayerCore(private val activity: Activity) :
// Buffer metrics
"bufferedPositionMs" to player.bufferedPosition,
"currentPositionMs" to player.currentPosition,
// Both read-ahead ceilings. The smaller binds, so a Maximum Buffer that appears to do
// nothing on a high bitrate file is explained by the byte target sitting beside it.
"bufferTargetBytes" to resolvedTargetBufferBytes,
"bufferMaxMs" to resolvedBufferDurations?.maxBufferMs,
"totalBufferedDurationMs" to player.totalBufferedDuration,
// Playback state
"playbackSpeed" to player.playbackParameters.speed,
@@ -3971,6 +4216,7 @@ class ExoPlayerCore(private val activity: Activity) :
private fun getTunnelingStatus(player: ExoPlayer): String {
if (currentTunneledPlayback) return "Active"
if (!tunnelingUserEnabled) return "Disabled by user"
if (DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate)) return "Off (unreliable on this device)"
if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)"
if (audioNormalizationEnabled) return "Off (loudness normalization)"
if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)"
@@ -4010,6 +4256,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
stopPositionUpdates()
handler.removeCallbacksAndMessages(null)
// releasePending (not clearVideoFrameRate): on the ExoPlayer→MPV fallback
@@ -61,9 +61,16 @@ class ExoPlayerPlugin :
val headers: Map<String, String>?,
val startPositionMs: Long,
val hasStartPosition: Boolean,
val autoPlay: Boolean,
/**
* Whether to start playing once open. Mutable because a play or pause arriving while this
* request is queued behind a backend handover has no player to reach — the outgoing core is
* being disposed and the replacement does not exist yet — so the latest intent is recorded here
* instead. Without that, a pause the caller was told succeeded is silently undone by the open.
*/
var autoPlay: Boolean,
val isLive: Boolean,
val externalSubtitles: List<Map<String, Any?>>?,
val contentFrameRate: Float,
private val result: MethodChannel.Result?
) {
private val completed = AtomicBoolean(false)
@@ -294,6 +301,9 @@ class ExoPlayerPlugin :
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.
@@ -319,6 +329,14 @@ class ExoPlayerPlugin :
fallbackInProgress = false
}
// An initialize without an intervening dispose would otherwise orphan the previous core
// along with its ExoPlayer, audio sink, codecs and surface views — nothing else holds a
// reference, so they would never be released.
playerCore?.let { stale ->
playerCore = null
stale.dispose()
}
try {
val core = ExoPlayerCore(currentActivity).apply {
delegate = this@ExoPlayerPlugin
@@ -329,7 +347,8 @@ class ExoPlayerPlugin :
bufferSizeBytes = bufferSizeBytes,
bufferSizeAuto = bufferSizeAuto,
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled
audioPassthroughEnabled = audioPassthroughEnabled,
bufferTier = bufferTier
)
if (!success) {
if (playerCore === core) playerCore = null
@@ -371,6 +390,8 @@ class ExoPlayerPlugin :
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
// Server-reported frame rate for this item; -1 when the metadata did not carry one.
val contentFrameRate = call.argument<Number>("contentFrameRate")?.toFloat() ?: -1f
if (uri == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
@@ -391,6 +412,7 @@ class ExoPlayerPlugin :
autoPlay = autoPlay,
isLive = isLive,
externalSubtitles = externalSubtitles?.map { it.toMap() },
contentFrameRate = contentFrameRate,
result = result
)
terminalEventGeneration = null
@@ -450,7 +472,8 @@ class ExoPlayerPlugin :
autoPlay = autoPlay,
mediaGeneration = request.mediaGeneration,
isLive = isLive,
externalSubtitleList = request.externalSubtitles
externalSubtitleList = request.externalSubtitles,
contentFrameRate = request.contentFrameRate
)
request.success()
}
@@ -811,6 +834,9 @@ class ExoPlayerPlugin :
}
private fun handlePlay(result: MethodChannel.Result) {
// A backend handover leaves nothing to command: record the intent on the queued open instead,
// so the replacement starts the way the caller last asked.
pendingOpen?.autoPlay = true
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "no", result)
return
@@ -822,6 +848,10 @@ class ExoPlayerPlugin :
}
private fun handlePause(result: MethodChannel.Result) {
// See handlePlay. This direction matters more: the caller is told the pause succeeded, and a
// queued open that still carried autoPlay would start playing anyway — on a car, that is
// playback after the vehicle said no.
pendingOpen?.autoPlay = false
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "yes", result)
return
@@ -1039,6 +1069,7 @@ class ExoPlayerPlugin :
val subtitlePosition = call.argument<Number>("subtitlePosition")?.toInt() ?: 100
val bold = call.argument<Boolean>("bold") ?: false
val italic = call.argument<Boolean>("italic") ?: false
val anchorToScreen = call.argument<Boolean>("anchorToScreen") ?: false
if (usingMpvFallback) {
// MPV fallback handles styling via setProperty, no-op here
@@ -1046,7 +1077,7 @@ class ExoPlayerPlugin :
return
}
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic)
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic, anchorToScreen)
result.success(null)
}
@@ -1386,6 +1417,8 @@ class ExoPlayerPlugin :
autoPlay = playWhenReady,
isLive = false,
externalSubtitles = currentExternalSubtitles?.map { it.toMap() },
// The mpv fallback core paces frames itself; the rate only gates ExoPlayer tunneling.
contentFrameRate = -1f,
result = null
)
fallbackInProgress = true
@@ -0,0 +1,40 @@
package com.edde746.plezy.exoplayer
/**
* Identity of a side-loaded subtitle across media3's media-source merging.
*
* Each `MediaItem.SubtitleConfiguration` is tagged with [idFor], and the tag is
* read back off the `Format` the track selector reports. The tag does not
* survive verbatim: `DefaultMediaSourceFactory.createMediaSource` always wraps
* the primary source plus one source per subtitle configuration in a
* `MergingMediaSource`, and since media3 1.3.0 `MergingMediaPeriod.onPrepared`
* rewrites every child format id to `"<periodIndex>:<originalId>"`. A second
* merge — the one this player builds for container sidecars — prefixes it
* again. So the same configuration can surface as `external_0`,
* `1:external_0`, or `0:1:external_0`.
*
* Only the final `:`-separated segment carries the tag, which is why matching
* on the whole id silently classifies every sidecar as an embedded track.
*/
internal object ExternalSubtitleIds {
private const val PREFIX = "external_"
/** Tag written onto the [index]th side-loaded subtitle configuration. */
fun idFor(index: Int): String = "$PREFIX$index"
/** Whether [formatId] identifies a side-loaded subtitle. */
fun isExternal(formatId: String?): Boolean = tagOf(formatId) != null
/**
* Index passed to [idFor], or null when [formatId] is not a side-loaded
* subtitle or carries a tag this build did not write.
*/
fun indexOf(formatId: String?): Int? = tagOf(formatId)?.toIntOrNull()
private fun tagOf(formatId: String?): String? {
// substringAfterLast returns the whole string when no ':' is present, which
// is the unmerged case.
val segment = formatId?.substringAfterLast(':') ?: return null
return if (segment.startsWith(PREFIX)) segment.removePrefix(PREFIX) else null
}
}
@@ -31,12 +31,100 @@ internal object LoadControlPolicy {
*/
const val MIN_TARGET_BYTES = 32 * MIB
/** `DefaultLoadControl` play-start threshold on a first buffer. */
const val BUFFER_FOR_PLAYBACK_MS = 1_000
/**
* `DefaultLoadControl` play-start threshold after a rebuffer. Below this the player is
* *supposed* to stay in `STATE_BUFFERING`, so anything judging a buffering player has to clear
* this bar before it can call the wait anomalous — see [BufferingStallPolicy].
*/
const val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5_000
/**
* Fraction of a memory budget the allocator may claim. Matches the threshold the Buffer
* Size setting already warns at (`value > heapMB / 4`).
*/
private const val BUDGET_DIVISOR = 4
/**
* Memory tier below which the shipped Auto durations stay conservative, in
* `ActivityManager.MemoryInfo.availMem` MiB.
*/
private const val LOW_MEMORY_MB = 2048
/** Auto durations, by memory tier. These are the values that shipped before #1816. */
private const val AUTO_MIN_BUFFER_LOW_MEMORY_MS = 15_000
private const val AUTO_MAX_BUFFER_LOW_MEMORY_MS = 50_000
private const val AUTO_MIN_BUFFER_MS = 30_000
private const val AUTO_MAX_BUFFER_MS = 60_000
/**
* How deep the loader may read ahead.
*
* Named tiers rather than a duration, because a duration would be a promise this cannot keep:
* `prioritizeTimeOverSizeThresholds` is disabled, so `targetBufferBytes` stops the loader even
* below `minBufferMs`, and effective read-ahead is `min(maxBufferMs, 8 * bytes / bitrate)`. On a
* high bitrate stream the byte term wins whatever is chosen here. The tiers therefore describe
* an intent — how much network variance to absorb — and the Buffer Size setting bounds the
* memory it may cost.
*
* Values match jellyfin-androidtv and jellyfin-android so a user moving between Jellyfin clients
* gets the same behaviour from the same words.
*/
enum class BufferTier(val minBufferMs: Int, val maxBufferMs: Int) {
/** Memory-tiered defaults, resolved per device in [bufferDurations]. */
AUTO(0, 0),
/** For moderate or variable connections. */
LARGE(50_000, 120_000),
/** For slow or high-latency links, where a stall costs more than the memory does. */
EXTRA_LARGE(80_000, 240_000);
companion object {
/**
* Wire values are the Dart enum's `nativeValue`. An unknown string means a Dart/Kotlin
* skew, and [AUTO] is the only safe reading of "I do not know what this user asked for".
*/
fun fromWire(value: String?): BufferTier = when (value) {
"large" -> LARGE
"extra_large" -> EXTRA_LARGE
else -> AUTO
}
}
}
/** The min/max pair handed to `DefaultLoadControl.Builder.setBufferDurationsMs`. */
data class BufferDurations(val minBufferMs: Int, val maxBufferMs: Int)
/**
* Read-ahead duration bounds for this session.
*
* The returned pair always satisfies the ordering media3 asserts on
* (`bufferForPlayback* <= minBufferMs <= maxBufferMs`). That is enforced here rather than
* trusted, because media3 validates with Guava `Preconditions` — an unconditional throw that
* R8 does not elide — so a bad pair is an `IllegalArgumentException` out of
* `DefaultLoadControl.Builder.build()` on a user's device, not a degraded buffer.
*
* @param availableMB `ActivityManager.MemoryInfo.availMem`. Non-positive when unknown, which
* is treated as the low-memory tier.
*/
fun bufferDurations(tier: BufferTier, availableMB: Int): BufferDurations {
val playStartFloor = maxOf(BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
val (rawMin, rawMax) = if (tier == BufferTier.AUTO) {
if (availableMB <= LOW_MEMORY_MB) {
AUTO_MIN_BUFFER_LOW_MEMORY_MS to AUTO_MAX_BUFFER_LOW_MEMORY_MS
} else {
AUTO_MIN_BUFFER_MS to AUTO_MAX_BUFFER_MS
}
} else {
tier.minBufferMs to tier.maxBufferMs
}
val minBufferMs = rawMin.coerceAtLeast(playStartFloor)
return BufferDurations(minBufferMs, rawMax.coerceAtLeast(minBufferMs))
}
/**
* @param largeHeapMB `ActivityManager.largeMemoryClass` — the hard Java-heap ceiling for
* this process, which is what bounds `DefaultAllocator` (it hands out `byte[]`).
@@ -0,0 +1,91 @@
package com.edde746.plezy.exoplayer
import androidx.annotation.OptIn
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.exoplayer.upstream.Allocator
/**
* A [LoadControl] that remembers whether it last said playback may start.
*
* The buffering stall watchdog ([BufferingStallPolicy]) needs to tell apart a player that is
* buffering because the load control is holding it back — an ordinary rebuffer, not its business —
* from one the load control has released that still refuses to move, which is the failure it exists
* to catch.
*
* Re-deriving that condition cannot be done faithfully: `DefaultLoadControl.shouldStartPlayback`
* releases playback on buffered *playout* duration (media duration divided by playback speed) or,
* with `prioritizeTimeOverSizeThresholds` disabled, as soon as its byte target is full — which on a
* high bitrate stream happens well below any duration bar, and depends on allocator accounting that
* no public API exposes. So ask the object ExoPlayer asks, and record its answer.
*
* Note what this does *not* reach: `ExoPlayerImplInternal.shouldTransitionToReadyState` returns
* early when the renderers are not ready, so it never asks at all in the case the watchdog was
* written for — a renderer stuck forever. [BufferingStallPolicy] therefore also takes
* `Player.isLoading`, which stays observable throughout.
*
* Every member is forwarded explicitly, and that is not a style choice: Kotlin's `by` delegation
* only generates forwarders for *abstract* interface members, so a Java interface like this one —
* where all but `getAllocator` carry default implementations that throw
* `IllegalStateException("... not implemented")` — would inherit those throwing defaults and blow up
* inside `ExoPlayer.Builder.build()`. [ObservingLoadControlTest] builds a real player through this
* wrapper so a media3 upgrade that adds another such method fails a test rather than a device.
*/
@OptIn(UnstableApi::class)
class ObservingLoadControl(private val delegate: LoadControl) : LoadControl {
/**
* The delegate's most recent verdict, or null if it has not been asked since [reset].
*
* ExoPlayer asks on every playback-loop iteration while buffering, so a reader running on the
* watchdog's schedule sees a current answer; null only means "not yet asked".
*/
@Volatile
var startPlaybackVerdict: Boolean? = null
private set
/** Forgets the recorded verdict, for a watchdog arming a fresh buffering window. */
fun reset() {
startPlaybackVerdict = null
}
// The Parameters overloads are the ones ExoPlayer 1.10 calls; the older signatures are deprecated
// shims that route through them.
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean {
val verdict = delegate.shouldStartPlayback(parameters)
startPlaybackVerdict = verdict
return verdict
}
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = delegate.shouldContinueLoading(parameters)
override fun shouldContinuePreloading(
playerId: PlayerId,
timeline: Timeline,
mediaPeriodId: MediaSource.MediaPeriodId,
bufferedDurationUs: Long
): Boolean = delegate.shouldContinuePreloading(playerId, timeline, mediaPeriodId, bufferedDurationUs)
override fun onPrepared(playerId: PlayerId) = delegate.onPrepared(playerId)
override fun onTracksSelected(
parameters: LoadControl.Parameters,
trackGroups: TrackGroupArray,
trackSelections: Array<out ExoTrackSelection>
) = delegate.onTracksSelected(parameters, trackGroups, trackSelections)
override fun onStopped(playerId: PlayerId) = delegate.onStopped(playerId)
override fun onReleased(playerId: PlayerId) = delegate.onReleased(playerId)
override fun getAllocator(playerId: PlayerId): Allocator = delegate.getAllocator(playerId)
override fun getBackBufferDurationUs(playerId: PlayerId): Long = delegate.getBackBufferDurationUs(playerId)
override fun retainBackBufferFromKeyframe(playerId: PlayerId): Boolean = delegate.retainBackBufferFromKeyframe(playerId)
}
@@ -8,6 +8,7 @@ import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.ChannelMixingAudioProcessor
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.util.Clock
@@ -150,6 +151,16 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
)
}
private var trueHdCarrierSink: TrueHdCarrierSink? = 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].
*/
fun beginMediaItem() {
trueHdCarrierSink?.beginMediaItem()
}
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
@@ -161,6 +172,11 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
// media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms — 40000
// bytes at 640 kbps. Some HDMI routes reject a buffer that short outright and the only
// retry media3 1.10.1 has is to keep halving it (#1790). Ask for a second up front;
// upstream adopted the same 1s floor as its last-resort retry in #3207.
.setPassthroughBufferDurationUs(500_000)
.build()
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
@@ -180,7 +196,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
.build()
return PositionFixAudioSink(
val processedSink = PositionFixAudioSink(
defaultSink,
rawPositionUs,
audioDelayUs,
@@ -188,7 +204,89 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
onAudioCapabilitiesChanged,
audioDiagnosticsLogger
)
return TrueHdCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsTrueHdMatCarrier(context) },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
).also { trueHdCarrierSink = it }
}
/**
* The delegate that carries packed TrueHD (#1804).
*
* Deliberately separate from the processed sink, and deliberately barren: an empty
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
* float conversion can ever touch the bytes. The carrier only looks like PCM; a single mutated
* sample reaches the receiver as full-scale noise rather than as a glitch.
*
* The output provider keeps `OutputConfig.encoding` at PCM 16-bit so every position, pending-data
* and release accounting in media3 stays in its mature PCM path — which is correct here, because
* after packing the stream genuinely is a fixed-rate 192kHz 8-channel carrier. Only the
* `AudioTrack` itself is switched to `ENCODING_IEC61937`, via the builder modifier that upstream
* applies immediately before `AudioTrack.Builder.build()`.
*/
private fun buildCarrierSink(
context: Context,
bufferSizeProvider: DefaultAudioTrackBufferSizeProvider
): AudioSink {
val provider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setAudioTrackBuilderModifier { builder, config ->
builder.setAudioFormat(
android.media.AudioFormat.Builder()
.setEncoding(android.media.AudioFormat.ENCODING_IEC61937)
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
.setSampleRate(config.sampleRate)
.build()
)
// The AudioTrackConfig media3 reports upstream is built from OutputConfig, which stays
// PCM so the accounting stays in the PCM domain — so it cannot show the real encoding.
// This is the only place that can confirm what the AudioTrack was actually built as.
audioDiagnosticsLogger?.invoke(
"info",
"audio",
"Carrier AudioTrack: encoding=IEC61937 rate=${config.sampleRate} " +
"mask=0x${android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND.toString(16)} " +
"buffer=${config.bufferSize}"
)
}
}
}
.build()
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(false)
// Not DefaultAudioProcessorChain: that one appends silence-skipping and Sonic whatever it is
// constructed with, and either would rewrite carrier bytes. This chain has nothing in it, so
// AudioProcessingPipeline is a straight passthrough. Speed is reported as applied without
// resampling because the carrier delegate routes playback parameters to the AudioTrack, and
// the sink refuses the carrier outright at any speed other than 1.0x.
.setAudioProcessorChain(EmptyAudioProcessorChain())
.setAudioOutputProvider(provider)
.build()
}
}
/**
* An [AudioProcessorChain] that owns no processors, for the MAT carrier delegate (#1804).
*
* `DefaultAudioProcessorChain` always contributes silence-skipping and Sonic; both rewrite samples,
* which is fatal to a bit-exact IEC 61937 carrier. Speed is reported back unchanged because the
* carrier delegate applies playback parameters at the AudioTrack, and the routing sink declines the
* carrier entirely at any speed other than 1.0x.
*/
@OptIn(UnstableApi::class)
private class EmptyAudioProcessorChain : DefaultAudioSink.AudioProcessorChain {
override fun getAudioProcessors(): Array<AudioProcessor> = emptyArray()
override fun applyPlaybackParameters(playbackParameters: PlaybackParameters): PlaybackParameters = playbackParameters
override fun applySkipSilenceEnabled(skipSilenceEnabled: Boolean): Boolean = false
override fun getMediaDuration(playoutDuration: Long): Long = playoutDuration
override fun getSkippedOutputFrameCount(): Long = 0L
}
/**
@@ -514,17 +612,46 @@ internal class DvSanitizingVideoRenderer(
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
//
// Two invariants keep that safe (#1790):
//
// 1. Only outputs [AudioOutputCachePolicy] admits are parked, so the cache never holds a
// scarce direct/passthrough route hostage while the next one is being created.
// 2. Every flush is answered by exactly one onReleased, promptly.
//
// DefaultAudioSink increments a *static, process-wide* counter on every flush and decrements it
// only from `Listener::onReleased`, which media3 delivers by posting to the playback looper — a
// looper that is already dead by the time a player-release actually completes. A dropped callback
// pins the counter above zero, and any nonzero value disables media3's escalation of *both* init
// and write failures: `PendingExceptionHolder` refuses to arm its throw deadline and every retry
// short-circuits, so a sink error never becomes a PlaybackException and playback hangs in
// STATE_BUFFERING with no recovery. The wrapper therefore owns the listener set and answers the
// flush itself when it parks the track, because a parked track is never going to release.
//
// Deferring that answer until the parked track is really evicted is tempting — it would let
// media3 stay patient through the eviction, whose replacement is built while the old AudioTrack
// is still going away — but it holds the counter above zero for the whole live track after the
// first seek, which is the very hang above. So the eviction overlap is tolerated instead, as
// upstream tolerates it: if the replacement does fail to allocate, media3 escalates on its own
// 200ms deadline into the audio recovery ladder, and the buffering stall watchdog backs that up.
//
// Everything below is confined to the ExoPlayer playback thread: sink flush/release, provider
// lookups, and media3's own onReleased delivery all run there.
@OptIn(UnstableApi::class)
private class RawPositionOutputProvider(
internal class RawPositionOutputProvider(
private val delegate: AudioOutputProvider,
private val rawPositionUs: AtomicLong,
private val log: ((String, String, String) -> Unit)?
private val log: ((String, String, String) -> Unit)?,
private val sdkInt: Int = Build.VERSION.SDK_INT
) : AudioOutputProvider {
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
/** Outputs whose real release was started but whose completion has not been observed yet. */
private val unsettledReleases = LinkedHashSet<RawPositionAudioOutput>()
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config)
@@ -533,14 +660,24 @@ private class RawPositionOutputProvider(
val cached = cachedOutput
if (cached != null && cachedConfig == config) {
cachedOutput = null
cached.markReacquired()
return cached
}
cached?.forceRelease()
cachedOutput = null
cachedConfig = null
// The replacement is built while the evicted track is still going away. Upstream does the
// same; the alternatives are worse (see the note above this class).
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
return RawPositionAudioOutput(
delegate = realOutput,
rawPositionUs = rawPositionUs,
provider = this,
mayCache = AudioOutputCachePolicy.mayCache(config.encoding, config.isOffload, sdkInt),
log = log
)
}
fun returnToCache(output: RawPositionAudioOutput) {
@@ -551,6 +688,14 @@ private class RawPositionOutputProvider(
cachedOutput = output
}
fun onRealReleaseStarted(output: RawPositionAudioOutput) {
unsettledReleases.add(output)
}
fun onReleaseSettled(output: RawPositionAudioOutput) {
unsettledReleases.remove(output)
}
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
@@ -561,15 +706,21 @@ private class RawPositionOutputProvider(
cachedOutput?.forceRelease()
cachedOutput = null
cachedConfig = null
// Player teardown: media3 schedules the real AudioTrack release with a delay and posts the
// completion back to the playback looper this call is in the middle of quitting, so nothing
// will ever deliver it. Settle here rather than leak the accounting for the whole process.
for (output in unsettledReleases.toList()) output.settleReleaseNow()
unsettledReleases.clear()
delegate.release()
}
}
@OptIn(UnstableApi::class)
private class RawPositionAudioOutput(
internal class RawPositionAudioOutput(
private val delegate: AudioOutput,
private val rawPositionUs: AtomicLong,
private val provider: RawPositionOutputProvider,
private val mayCache: Boolean,
private val log: ((String, String, String) -> Unit)?
) : AudioOutput {
@@ -578,6 +729,46 @@ private class RawPositionAudioOutput(
private var writtenBytes = 0L
private var failed = false
/**
* DefaultAudioSink registers here instead of on the real output, so the wrapper can report the
* releases media3 will not: a parked output never really releases, and a released output's
* completion callback is posted to a playback looper that may already be gone. The set is
* cleared on every release report and repopulated by the sink on the next acquisition, which
* also stops one stale listener per reuse from piling up on the real output.
*/
private val listeners = mutableListOf<AudioOutput.Listener>()
private var releaseSignalled = false
private val currentListener: AudioOutput.Listener?
get() = listeners.lastOrNull()
private val forwarder = object : AudioOutput.Listener {
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) {
currentListener?.onPositionAdvancing(playoutStartSystemTimeMs)
}
override fun onOffloadDataRequest() {
currentListener?.onOffloadDataRequest()
}
override fun onOffloadPresentationEnded() {
currentListener?.onOffloadPresentationEnded()
}
override fun onUnderrun() {
currentListener?.onUnderrun()
}
override fun onReleased() {
provider.onReleaseSettled(this@RawPositionAudioOutput)
signalReleased()
}
}
init {
delegate.addListener(forwarder)
}
override fun getPositionUs(): Long {
val pos = delegate.getPositionUs()
rawPositionUs.set(pos)
@@ -626,24 +817,50 @@ private class RawPositionAudioOutput(
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
if (failed) {
delegate.release()
return
}
if (Build.VERSION.SDK_INT >= 25) {
if (!failed && mayCache) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
// A parked track is never going to release, so answer the flush now. Holding the sink's
// pending-release count open for it would disable media3's escalation of every later sink
// error, for as long as the track stays parked or live — the #1790 hang, re-armed by an
// ordinary seek.
signalReleased()
return
}
startRealRelease()
}
/** Releases for real even when the output would otherwise be cacheable. */
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
startRealRelease()
}
/** Reports a release whose completion callback can no longer be delivered. */
fun settleReleaseNow() {
provider.onReleaseSettled(this)
signalReleased()
}
/** Re-arms the wrapper for a fresh acquisition out of the provider's cache. */
fun markReacquired() {
releaseSignalled = false
}
private fun startRealRelease() {
provider.onRealReleaseStarted(this)
delegate.release()
}
private fun signalReleased() {
if (releaseSignalled) return
releaseSignalled = true
val notified = listeners.toList()
listeners.clear()
for (listener in notified) listener.onReleased()
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
@@ -651,8 +868,12 @@ private class RawPositionAudioOutput(
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun addListener(listener: AudioOutput.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: AudioOutput.Listener) {
listeners.remove(listener)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
@@ -13,12 +13,18 @@ internal object SubtitleViewLayout {
videoHeight: Int,
pixelRatio: Float,
resizeMode: Int,
zoomScale: Float
zoomScale: Float,
anchorToScreen: Boolean = false
): SubtitleViewDimensions? {
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
if (containerWidth <= 0 || containerHeight <= 0) return null
if (resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
// Anchor-to-screen (#1730): size the text view to the full container so
// media3's fractional text size and bottom-anchored cue placement compute
// against the physical screen, letting subtitles render in the letterbox
// bars instead of inside the video rect. Same geometry the non-FIT modes
// below already use.
if (anchorToScreen || resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
return SubtitleViewDimensions(containerWidth, containerHeight)
}
@@ -0,0 +1,420 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.AuxEffectInfo
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioSink
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).
*
* 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.
*
* **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,
* silence skipping, float conversion — turns it into full-scale noise at the receiver. Keeping the
* carrier on a delegate built with an empty [AudioProcessorChain] makes that impossible by
* construction. The alternative, asserting the normal chain stays inactive, puts the guarantee in a
* different class from the thing it protects, so adding a processor later would silently break
* playback in the loudest possible way.
*
* [carrierSink] must therefore be built with no audio processors and with an output provider whose
* `AudioTrack` is forced to `ENCODING_IEC61937`; see [PlezyRenderersFactory].
*
* Selection happens only in [configure]. Persistent controls and the listener are mirrored to both
* delegates so either can be activated later; per-stream calls go to the active one alone.
*/
@OptIn(UnstableApi::class)
internal class TrueHdCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
private val carrierRouteAvailable: () -> Boolean,
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
private val directOutputBlocked: (Format) -> Boolean,
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 packer = TrueHdMatPacker()
private var active: AudioSink = defaultSink
private var carrierActive = 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.
*
* 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.
*/
private var carrierAnchorUs: Long = C.TIME_UNSET
private var burstsSinceAnchor: 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.
*
* Deliberately outlives [flush] and [reset]: media3 resets every renderer disabled by a new
* selection before enabling the replacement (ExoPlayerImplInternal.enableRenderers), and both
* audio renderers share this sink, so the outgoing renderer's reset lands here in the middle of
* the very handover this latch exists to cause. Clearing it there would re-offer the carrier and
* loop straight back into the mismatch.
*
* The real boundary is a new media item, which only the caller knows and which happens before
* selection asks anything. [beginMediaItem] is that hook; without it one malformed item would
* cost bitstreaming for every later item sharing the player.
*
* It is held as a generation rather than a flag because that hook is called on the app thread
* while the mismatch is discovered on the playback thread. A late buffer from the outgoing stream
* can land after the new item began; latching the generation the carrier was configured for makes
* that write name the stream it belongs to instead of poisoning its successor.
*/
private val mediaGeneration = AtomicInteger(0)
/** Generation [mediaGeneration] held when the carrier was last configured; playback thread. */
private var configuredGeneration = 0
@Volatile
private var mismatchGeneration = -1
// --- Selection ---
/**
* True when this format should ride the carrier.
*
* Speed changes are excluded deliberately: a bitstream cannot be resampled, so anything other
* than 1.0x has to decode. The same is true of the existing downmix/normalization blocks, which
* [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.
*/
private fun shouldUseCarrier(format: Format): Boolean {
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false
if (mismatchGeneration == mediaGeneration.get()) return false
if (!isCarrierRateFamily(format.sampleRate)) return false
if (playbackParameters.speed != 1f) return false
if (directOutputBlocked(format)) return false
return carrierRouteAvailable()
}
/**
* The 48kHz family rides the 192kHz carrier this path builds. The 44.1kHz family needs a 176.4kHz
* carrier, which would be a different AudioTrack tuple throughout, so it decodes instead.
*/
private fun isCarrierRateFamily(sampleRate: Int): Boolean = sampleRate == 48_000 || sampleRate == 96_000 || sampleRate == 192_000
/** The PCM-shaped format the carrier delegate is configured with. */
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)
.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
* 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
* change, downmix, normalization, or a 44.1kHz-family stream — report unsupported and let the
* bundled FFmpeg decoder take it, exactly as the existing decoded-PCM fallback does.
*/
private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD
override fun supportsFormat(format: Format): Boolean = when {
shouldUseCarrier(format) -> true
isTrueHd(format) -> 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
else -> defaultSink.getFormatSupport(format)
}
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
val useCarrier = shouldUseCarrier(inputFormat)
if (useCarrier != carrierActive) {
log?.invoke(
"info",
"audio",
if (useCarrier) {
"TrueHD via MAT/IEC 61937 carrier at ${TrueHdMatPacker.CARRIER_SAMPLE_RATE}Hz/" +
"${TrueHdMatPacker.CARRIER_CHANNEL_COUNT}ch"
} else {
"Leaving the MAT carrier; audio returns to the normal sink"
}
)
}
carrierActive = useCarrier
configuredGeneration = mediaGeneration.get()
active = if (useCarrier) carrierSink else defaultSink
discardCarrierState()
if (useCarrier) {
carrierSink.configure(carrierFormat(), specifiedBufferSize, null)
} else {
defaultSink.configure(inputFormat, specifiedBufferSize, outputChannels)
}
}
// --- Stream path: active delegate only ---
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
// A refused burst blocks everything: placing it must come before consuming more input, or the
// carrier would lose a frame.
pendingBurst?.let { burst ->
if (!carrierSink.handleBuffer(burst, pendingBurstTimeUs, 1)) return false
pendingBurst = null
}
if (!buffer.hasRemaining()) return true
if (carrierAnchorUs == C.TIME_UNSET) carrierAnchorUs = presentationTimeUs
// One media3 sample holds many access units. The buffer position advances per unit, so a burst
// the delegate refuses leaves the units behind it in the buffer for the retry rather than
// dropping them — media3 re-delivers the same buffer whenever this returns false.
val remaining = ByteArray(buffer.remaining())
buffer.duplicate().get(remaining)
var offset = 0
while (offset < remaining.size) {
val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size)
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
}
offset += length
buffer.position(buffer.position() + length)
if (burst == null) continue
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
burstsSinceAnchor++
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
pendingBurst = burst
pendingBurstTimeUs = burstTimeUs
return false
}
}
return true
}
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.
active.playToEndOfStream()
}
override fun isEnded(): Boolean = active.isEnded()
override fun hasPendingData(): Boolean = pendingBurst != null || active.hasPendingData()
override fun handleDiscontinuity() {
discardCarrierState()
active.handleDiscontinuity()
}
override fun play() = active.play()
override fun pause() = active.pause()
override fun flush() {
discardCarrierState()
active.flush()
}
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
private fun discardCarrierState() {
packer.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
}
// --- Persistent state: mirrored, so either delegate can be activated later ---
override fun setListener(listener: AudioSink.Listener) {
sinkListener = listener
defaultSink.setListener(listener)
carrierSink.setListener(listener)
}
override fun setPlayerId(playerId: PlayerId?) {
defaultSink.setPlayerId(playerId)
carrierSink.setPlayerId(playerId)
}
override fun setClock(clock: Clock) {
defaultSink.setClock(clock)
carrierSink.setClock(clock)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
val wasUnitSpeed = this.playbackParameters.speed == 1f
val isUnitSpeed = playbackParameters.speed == 1f
this.playbackParameters = playbackParameters
defaultSink.setPlaybackParameters(playbackParameters)
// A bitstream cannot be resampled, and the carrier delegate has no processor chain to do it
// with, so it is never handed a speed nothing can apply.
carrierSink.setPlaybackParameters(
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.
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
log?.invoke(
"info",
"audio",
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the TrueHD carrier"
)
sinkListener?.onAudioCapabilitiesChanged()
}
}
/** Whether TrueHD is currently riding the carrier. Read by the core when speed changes. */
val isCarrierActive: Boolean
get() = carrierActive
/**
* 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
* really can apply them.
*/
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
// Only the normal sink: the carrier delegate is fed a fixed-rate bitstream,
// and dropping "silent" carrier bytes would break the frame cadence.
defaultSink.setSkipSilenceEnabled(skipSilenceEnabled)
}
override fun getSkipSilenceEnabled(): Boolean = defaultSink.getSkipSilenceEnabled()
override fun setAudioAttributes(audioAttributes: AudioAttributes) {
defaultSink.setAudioAttributes(audioAttributes)
carrierSink.setAudioAttributes(audioAttributes)
}
override fun getAudioAttributes(): AudioAttributes? = active.audioAttributes
override fun setAudioSessionId(audioSessionId: Int) {
defaultSink.setAudioSessionId(audioSessionId)
carrierSink.setAudioSessionId(audioSessionId)
}
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {
defaultSink.setAuxEffectInfo(auxEffectInfo)
carrierSink.setAuxEffectInfo(auxEffectInfo)
}
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) {
defaultSink.setPreferredDevice(audioDeviceInfo)
carrierSink.setPreferredDevice(audioDeviceInfo)
}
override fun setOutputStreamOffsetUs(outputStreamOffsetUs: Long) {
defaultSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
carrierSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
}
override fun setVolume(volume: Float) {
defaultSink.setVolume(volume)
carrierSink.setVolume(volume)
}
override fun enableTunnelingV21() {
// Tunneling is a decoded-PCM/video-sync arrangement; the carrier never uses it.
defaultSink.enableTunnelingV21()
}
override fun disableTunneling() {
defaultSink.disableTunneling()
}
override fun reset() {
discardCarrierState()
defaultSink.reset()
carrierSink.reset()
}
/**
* 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.
*/
fun beginMediaItem() {
mediaGeneration.incrementAndGet()
}
override fun release() {
mediaGeneration.incrementAndGet()
discardCarrierState()
defaultSink.release()
carrierSink.release()
}
}
@@ -0,0 +1,261 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* Packs Dolby TrueHD access units into MAT frames carried in IEC 61937 bursts (#1804).
*
* Android will not bitstream raw TrueHD on the TV routes we have measured — the platform reports
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
* bitstream-capable. That is also the split Kodi models: it offers an "AudioTrack (IEC)" sink where
* it packs the carrier itself, and treats handing raw TrueHD to Android as the fallback. Media3 only
* ever does the latter, and at the stream rate rather than the carrier rate, which is why playback
* wedges where other players work.
*
* The algorithm is a port of FFmpeg's `spdif_header_truehd` (libavformat/spdifenc.c, n8.0). Kodi's
* `CAEBitstreamPacker` is *not* an equivalent reference: it wraps an already-assembled buffer,
* whereas the timing-driven padding and MAT code placement live in FFmpeg's stateful packer.
*
* Output is a complete IEC 61937 burst per MAT frame:
*
* - 8 byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x0016 Pd=61424`
* - 61424 bytes of MAT frame, 16-bit byte-swapped
* - 8 bytes of zero padding
* - 61440 bytes total, which is 20ms at 192kHz/8ch/16-bit
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class TrueHdMatPacker {
internal companion object {
/** Payload bytes in one MAT frame. */
const val MAT_FRAME_SIZE = 61424
/** 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
private const val IEC61937_TRUEHD = 0x16
/** Minimum bytes needed to read an access unit header plus its major-sync probe. */
private const val MIN_ACCESS_UNIT_LENGTH = 10
private val MAT_START_CODE = byteArrayOf(
0x07, 0x9E.toByte(), 0x00, 0x03, 0x84.toByte(), 0x01, 0x01, 0x01, 0x80.toByte(), 0x00,
0x56, 0xA5.toByte(), 0x3B, 0xF4.toByte(), 0x81.toByte(), 0x83.toByte(), 0x49, 0x80.toByte(),
0x77, 0xE0.toByte()
)
private val MAT_MIDDLE_CODE = byteArrayOf(
0xC3.toByte(), 0xC1.toByte(), 0x42, 0x49, 0x3B, 0xFA.toByte(), 0x82.toByte(), 0x83.toByte(),
0x49, 0x80.toByte(), 0x77, 0xE0.toByte()
)
private val MAT_END_CODE = byteArrayOf(
0xC3.toByte(), 0xC2.toByte(), 0xC0.toByte(), 0xC4.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x97.toByte(), 0x11
)
private val MAT_CODES = arrayOf(MAT_START_CODE, MAT_MIDDLE_CODE, MAT_END_CODE)
private val MAT_CODE_POSITIONS = intArrayOf(0, 30708, MAT_FRAME_SIZE - MAT_END_CODE.size)
/**
* Length of the access unit starting at [offset], in bytes.
*
* The first 16-bit big-endian word carries the length in units of 16-bit words in its low 12
* bits. Returns 0 when the buffer is too short or the length is nonsensical, which the caller
* treats as "stop walking".
*/
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 2 > limit) return 0
val words = ((data[offset].toInt() and 0x0F) shl 8) or (data[offset + 1].toInt() and 0xFF)
val length = words * 2
return if (length < 4 || offset + length > limit) 0 else length
}
/** True when the access unit at [offset] carries a major sync, which announces the rate. */
private fun hasMajorSync(data: ByteArray, offset: Int, limit: Int): Boolean {
if (offset + 8 > limit) return false
return (data[offset + 4].toInt() and 0xFF) == 0xF8 &&
(data[offset + 5].toInt() and 0xFF) == 0x72 &&
(data[offset + 6].toInt() and 0xFF) == 0x6F
}
}
private val matBuffers = Array(2) { ByteArray(MAT_FRAME_SIZE) }
private val burstBacking = Array(2) { ByteArray(MAT_PKT_OFFSET) }
private val burstBuffers = Array(2) {
ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN)
}
private var burstIndex = 0
private var matBufferIndex = 0
private var matBufferFilled = 0
private var previousTiming = 0
private var previousSize = 0
private var samplesPerFrame = 0
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
fun reset() {
matBufferIndex = 0
matBufferFilled = 0
previousTiming = 0
previousSize = 0
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
java.util.Arrays.fill(matBuffers[0], 0)
java.util.Arrays.fill(matBuffers[1], 0)
}
/**
* True when the stream announced a 44.1kHz-family rate.
*
* That family rides a 176.4kHz carrier rather than 192kHz, which changes the AudioTrack tuple the
* 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
private set
/**
* Packs one access unit, returning a completed burst when this unit finished a MAT frame.
*
* The returned buffer is owned by the packer and reused, alternating between two so a burst
* handed downstream stays valid while the next frame fills. Callers must submit or copy it before
* the second following call. This is the audio path on low-power TV hardware — a fresh 61,440
* byte array every 20ms would be roughly 3MB/s of garbage.
*
* Faithful to `spdif_header_truehd`: padding between units is derived from the delta of their
* `input_timing` fields against the previous unit's on-carrier size, so the carrier keeps a fixed
* rate even though the source frames vary in length.
*
* 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? {
if (length < MIN_ACCESS_UNIT_LENGTH) return null
if (hasMajorSync(data, offset, offset + length)) {
// Rate is announced in the major sync; 40 samples per frame at 48kHz, doubling with the rate.
val rateBits = when (data[offset + 7].toInt() and 0xFF) {
0xBA -> (data[offset + 8].toInt() and 0xFF) shr 4
0xBB -> (data[offset + 9].toInt() and 0xFF) shr 4
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
samplesPerFrame = 40 shl (rateBits and 3)
}
if (unsupportedRateFamily || samplesPerFrame == 0) return null
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
var paddingRemaining = 0
if (previousSize != 0) {
val deltaSamples = (inputTiming - previousTiming) and 0xFFFF
// One 48kHz-family frame is 1/1200s; the carrier runs at 768000*4 bytes/s, so the nominal
// space per frame is 2560 bytes, which divides evenly by samplesPerFrame.
val deltaBytes = deltaSamples * 2560 / samplesPerFrame
paddingRemaining = deltaBytes - previousSize
if (paddingRemaining < 0 || paddingRemaining >= MAT_FRAME_SIZE / 2) {
// Timing we do not model; better a momentary rate wobble than a corrupt frame.
paddingRemaining = 0
}
}
var totalFrameSize = length
var dataCursor = offset
var dataRemaining = length
var completed: ByteBuffer? = null
var nextCodeIndex = MAT_CODE_POSITIONS.indexOfFirst { matBufferFilled <= it }
if (nextCodeIndex < 0) return null
while (paddingRemaining > 0 || dataRemaining > 0 || MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) {
if (MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) {
val code = MAT_CODES[nextCodeIndex]
var codeLengthRemaining = code.size
code.copyInto(matBuffers[matBufferIndex], matBufferFilled)
matBufferFilled += code.size
nextCodeIndex++
if (nextCodeIndex == MAT_CODES.size) {
nextCodeIndex = 0
// Last code of the frame: this buffer is complete, continue filling the other one.
completed = toIec61937Burst(matBuffers[matBufferIndex])
matBufferIndex = matBufferIndex xor 1
matBufferFilled = 0
// The inter-frame gap occupies carrier space too.
codeLengthRemaining += MAT_PKT_OFFSET - MAT_FRAME_SIZE
}
if (paddingRemaining > 0) {
val countedAsPadding = minOf(paddingRemaining, codeLengthRemaining)
paddingRemaining -= countedAsPadding
codeLengthRemaining -= countedAsPadding
}
if (codeLengthRemaining > 0) totalFrameSize += codeLengthRemaining
}
if (paddingRemaining > 0) {
val padding = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, paddingRemaining)
java.util.Arrays.fill(matBuffers[matBufferIndex], matBufferFilled, matBufferFilled + padding, 0)
matBufferFilled += padding
paddingRemaining -= padding
if (paddingRemaining > 0) continue
}
if (dataRemaining > 0) {
val toCopy = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, dataRemaining)
data.copyInto(matBuffers[matBufferIndex], matBufferFilled, dataCursor, dataCursor + toCopy)
matBufferFilled += toCopy
dataCursor += toCopy
dataRemaining -= toCopy
}
}
previousSize = totalFrameSize
previousTiming = inputTiming
return completed
}
/**
* Wraps a finished MAT frame in its IEC 61937 burst, into the next reusable buffer.
*
* Alternating between two keeps a burst already handed downstream intact while the following
* frame is assembled.
*/
private fun toIec61937Burst(matFrame: ByteArray): ByteBuffer {
burstIndex = burstIndex xor 1
val burst = burstBuffers[burstIndex]
val backing = burstBacking[burstIndex]
putLittleEndianShort(backing, 0, SYNCWORD1)
putLittleEndianShort(backing, 2, SYNCWORD2)
putLittleEndianShort(backing, 4, IEC61937_TRUEHD)
putLittleEndianShort(backing, 6, MAT_FRAME_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
while (source < MAT_FRAME_SIZE) {
backing[destination] = matFrame[source + 1]
backing[destination + 1] = matFrame[source]
source += 2
destination += 2
}
// Trailing bytes stay zero for the inter-frame gap; the backing array is never re-dirtied
// beyond MAT_FRAME_SIZE, so they remain zero for the life of the packer.
burst.limit(MAT_PKT_OFFSET)
burst.position(0)
return burst
}
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
}
}
@@ -6,6 +6,43 @@ object DeviceQuirks {
val isEWaste: Boolean
get() = isGooglePixelDevice || isGoogleTensorDevice
/**
* Highest content frame rate for which [hasUnreliableTunneledPlayback] applies.
*
* Tunneling exists to meet the scheduling deadlines of high-frame-rate 4K, which Amazon
* documents and recommends for exactly that workload, so the cut-off keeps 4K50/60
* tunneled. Withdrawing tunneling is not free even below it — the device pipeline saves
* CPU and power at any rate — but ~33ms per frame is well inside what media3's own
* release path handles, and a juddering picture is the worse trade.
*/
const val MAX_UNRELIABLE_TUNNELED_FPS = 30f
/**
* Fire TV Stick 4K (AFTMM) judders continuously on 23.976p direct play while tunneled.
* The #1802 reporter A/B-tested it: tunneling off, every other setting unchanged, and
* the judder goes away. Untunneled, media3's VideoFrameReleaseHelper owns frame release
* and vsync-aligns it; tunneled, the device owns it and media3 notes it "may do frame
* rate conversion".
*
* The mechanism is unconfirmed. Tunneling fires no VideoFrameMetadataListener and stops
* media3 counting frames in the codec, so nothing app-side can measure the cadence or
* drops. Only the trigger is established.
*
* Deliberately scoped to the one model and the one rate class that were tested: AFTMM
* keeps tunneling for high-frame-rate content, and an unknown rate changes nothing.
* Widen only on further reports.
*/
fun hasUnreliableTunneledPlayback(contentFrameRate: Float): Boolean = hasUnreliableTunneledPlayback(Build.MANUFACTURER, Build.MODEL, contentFrameRate)
internal fun hasUnreliableTunneledPlayback(
manufacturer: String,
model: String,
contentFrameRate: Float
): Boolean = manufacturer.equals("Amazon", ignoreCase = true) &&
model.equals("AFTMM", ignoreCase = true) &&
contentFrameRate > 0f &&
contentFrameRate <= MAX_UNRELIABLE_TUNNELED_FPS
private val isGooglePixelDevice: Boolean
get() = (
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
@@ -0,0 +1,140 @@
package com.edde746.plezy.watchnext
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
* Owns the WorkManager registration for the periodic launcher-shelf refresh.
*
* Registration is best-effort by design: it runs inside the plugin's sync/clear
* result path, and a WorkManager failure must not turn a committed shelf write
* into a channel error.
*/
internal object ShelfRefreshScheduler {
private const val TAG = "ShelfRefreshScheduler"
internal const val WORK_NAME = "plezy_shelf_refresh"
private const val REFRESH_INTERVAL_HOURS = 6L
fun schedule(context: Context) {
try {
val request = PeriodicWorkRequestBuilder<ShelfRefreshWorker>(REFRESH_INTERVAL_HOURS, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule shelf refresh work", e)
}
}
fun cancel(context: Context) {
try {
WorkManager.getInstance(context.applicationContext).cancelUniqueWork(WORK_NAME)
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel shelf refresh work", e)
}
}
}
/**
* Refreshes the Android TV Watch Next row while the app is not running.
*
* Boots a headless [FlutterEngine], runs the Dart `systemShelfBackgroundMain`
* entrypoint (`lib/services/system_shelf_background.dart`), and resolves with
* the bool that isolate reports through the `backgroundSyncComplete` method on
* `com.plezy/watch_next`. The run is hard-capped at [RUN_TIMEOUT_MS]; the
* engine is always destroyed on the main thread, timeout included.
*/
class ShelfRefreshWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
companion object {
private const val TAG = "ShelfRefreshWorker"
internal const val RUN_TIMEOUT_MS = 90_000L
/** Test seam: replaces the headless Flutter launch so tests never boot an engine. */
@Volatile
@VisibleForTesting
internal var engineLauncherOverride: (suspend (Context) -> Boolean)? = null
}
override suspend fun doWork(): Result {
val context = applicationContext
// Same support gate as WatchNextPlugin.handleIsSupported: no leanback
// launcher, no Watch Next row worth refreshing.
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) return Result.success()
// A live engine (the foreground app) owns the shelf and keeps it fresh
// itself; a concurrent headless engine would only fight it for ownership.
if (SystemShelfLifecycle.hasLiveLease()) return Result.success()
val launcher = engineLauncherOverride ?: ::runHeadlessShelfSync
val success = try {
withTimeoutOrNull(RUN_TIMEOUT_MS) { launcher(context) } ?: false
} catch (e: Exception) {
Log.e(TAG, "Background shelf refresh failed", e)
false
}
return if (success) Result.success() else Result.retry()
}
}
private suspend fun runHeadlessShelfSync(context: Context): Boolean {
val appContext = context.applicationContext
val completion = CompletableDeferred<Boolean>()
val engine = withContext(Dispatchers.Main) {
val loader = FlutterInjector.instance().flutterLoader()
if (!loader.initialized()) {
loader.startInitialization(appContext)
}
loader.ensureInitializationComplete(appContext, null)
// The engine constructor auto-registers GeneratedPluginRegistrant plugins
// (shared_preferences, path_provider, connectivity, sqlite, ...);
// WatchNextPlugin is app-local and must be added explicitly.
val engine = FlutterEngine(appContext)
try {
WatchNextPlugin.backgroundSyncCompletionListener = { success ->
completion.complete(success)
}
engine.plugins.add(WatchNextPlugin())
engine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint(
loader.findAppBundlePath(),
"package:plezy/services/system_shelf_background.dart",
"systemShelfBackgroundMain"
)
)
} catch (e: Throwable) {
WatchNextPlugin.backgroundSyncCompletionListener = null
engine.destroy()
throw e
}
engine
}
return try {
completion.await()
} finally {
withContext(NonCancellable + Dispatchers.Main) {
WatchNextPlugin.backgroundSyncCompletionListener = null
engine.destroy()
}
}
}
@@ -22,11 +22,18 @@ class SystemShelfUpdateReceiver private constructor(
executor.execute {
try {
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
// Snapshot before maintenance: restoreReadGrants() re-commits the
// granted-URI key even when no sync ever wrote it.
val hadPriorSync = provider.hasPersistedShelfState()
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
provider.migrateShelfSchema()
} else {
provider.restoreReadGrants()
}
// WorkManager normally survives reboots on its own; re-arming here
// covers force-stop and update edge cases, and only for devices whose
// persisted state says a shelf was actually synced before.
if (hadPriorSync) ShelfRefreshScheduler.schedule(context.applicationContext)
} finally {
pending?.finish()
if (ownsExecutor) (executor as ExecutorService).shutdown()
@@ -24,9 +24,16 @@ class WatchNextPlugin() :
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private const val SCHEMA_VERSION = 2
internal const val SCHEMA_VERSION = 3
private var pendingDeepLink: String? = null
// Resolves ShelfRefreshWorker's headless run. The worker installs it
// before launching the background engine and clears it on engine destroy;
// a foreground engine never sets it, so its own channel calls are unaffected.
@Volatile
@JvmStatic
var backgroundSyncCompletionListener: ((Boolean) -> Unit)? = null
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
return if (data.scheme == "plezy" && data.authority == "play") {
@@ -104,6 +111,7 @@ class WatchNextPlugin() :
"clear" -> handleClear(call, result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
"backgroundSyncComplete" -> handleBackgroundSyncComplete(call, result)
else -> result.notImplemented()
}
}
@@ -136,7 +144,12 @@ class WatchNextPlugin() :
val provider = session.provider ?: return@executeOnIo false
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
if (!session.isOpen()) return@executeOnIo false
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
val synced = provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
// Only a committed shelf warrants the periodic background refresh; KEEP
// makes re-arming from every foreground sync (and the headless worker's
// own sync) idempotent.
if (synced) ShelfRefreshScheduler.schedule(session.context)
synced
}
}
@@ -150,7 +163,10 @@ class WatchNextPlugin() :
val provider = session.provider ?: return@executeOnIo false
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
if (!session.isOpen()) return@executeOnIo false
provider.clearAll(owner, generation, ownership, session::isOpen)
val cleared = provider.clearAll(owner, generation, ownership, session::isOpen)
// A cleared shelf has nothing to refresh; the next successful sync re-arms.
if (cleared) ShelfRefreshScheduler.cancel(session.context)
cleared
}
}
@@ -192,6 +208,12 @@ class WatchNextPlugin() :
result.success(contentId)
}
private fun handleBackgroundSyncComplete(call: MethodCall, result: MethodChannel.Result) {
val success = call.arguments as? Boolean ?: false
backgroundSyncCompletionListener?.invoke(success)
result.success(true)
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
val title = data["title"] as? String ?: return null
@@ -25,6 +25,7 @@ internal object SystemShelfLifecycle {
private var claimToken = 0L
private var currentOwner = ""
private var currentGeneration = 0L
private var leaseHeld = false
fun acquire(): Lease = acquireIf { true }!!
@@ -35,6 +36,7 @@ internal object SystemShelfLifecycle {
claimToken += 1
currentOwner = ""
currentGeneration = 0
leaseHeld = true
Lease(token)
}
}
@@ -45,11 +47,19 @@ internal object SystemShelfLifecycle {
if (token == lease.token) {
token += 1
claimToken += 1
leaseHeld = false
}
}
}
}
/**
* True while the most recently acquired engine lease has not been
* invalidated — i.e. a live engine (normally the UI) currently owns the
* shelf. ShelfRefreshWorker checks this before booting a headless engine.
*/
fun hasLiveLease(): Boolean = synchronized(lock) { leaseHeld }
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
synchronized(lock) {
if (
@@ -130,6 +140,13 @@ class WatchNextProvider internal constructor(
internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
/**
* Whether a prior sync's committed state is still on disk. Cleared rows
* ([clearAll]) and schema-migration wipes remove the granted-URI key, so
* this distinguishes "user had a shelf" from "never synced / cleared".
*/
internal fun hasPersistedShelfState(): Boolean = prefs.contains(GRANTED_URIS)
internal fun syncWatchNextPrograms(
ownerId: String,
generation: Long,
@@ -0,0 +1,85 @@
package com.edde746.plezy.exoplayer
import android.media.AudioFormat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AudioOutputCachePolicyTest {
@Test
fun decodedPcmIsCacheable() {
assertTrue(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = 34
)
)
}
@Test
fun everyPcmWidthIsCacheable() {
val pcmEncodings = listOf(
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT,
AudioFormat.ENCODING_PCM_FLOAT
)
for (encoding in pcmEncodings) {
assertTrue(
"expected $encoding to be cacheable",
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
)
}
}
/** Parking a bitstream track holds the platform's direct output, which is often single-instance. */
@Test
fun bitstreamOutputIsNeverCacheable() {
val passthroughEncodings = listOf(
AudioFormat.ENCODING_AC3,
AudioFormat.ENCODING_E_AC3,
AudioFormat.ENCODING_E_AC3_JOC,
AudioFormat.ENCODING_DTS,
AudioFormat.ENCODING_DTS_HD,
AudioFormat.ENCODING_DOLBY_TRUEHD
)
for (encoding in passthroughEncodings) {
assertFalse(
"expected $encoding to be excluded from the cache",
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
)
}
}
@Test
fun offloadOutputIsNeverCacheable() {
assertFalse(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = true,
sdkInt = 34
)
)
}
@Test
fun cachingIsOffBelowTheFlushableApiLevel() {
assertFalse(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT - 1
)
)
assertTrue(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT
)
)
}
}
@@ -0,0 +1,241 @@
package com.edde746.plezy.exoplayer
import com.edde746.plezy.exoplayer.BufferingStallPolicy.Verdict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BufferingStallPolicyTest {
private val position = 85_206L
private fun evaluate(
elapsedMs: Long = BufferingStallPolicy.STALL_TIMEOUT_MS,
baselinePositionMs: Long = position,
currentPositionMs: Long = position,
bufferedAheadMs: Long = 30_000L,
playbackSpeed: Float = 1f,
loadControlReady: Boolean? = null,
loading: Boolean = true
) = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = baselinePositionMs,
currentPositionMs = currentPositionMs,
bufferedPositionMs = currentPositionMs + bufferedAheadMs,
playbackSpeed = playbackSpeed,
loadControlReady = loadControlReady,
loading = loading
)
// The loader's own state, for the case media3 never answers
@Test
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
// The #1790 shape on a high bitrate stream: the byte cap is full at a couple of seconds, the
// renderers never become ready so media3 never asks its load control, and nothing moves.
val belowTheDurationBar = 2_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = true))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = false))
}
@Test
fun aLoaderStillFetchingWithATinyBufferIsAnOrdinaryRebuffer() {
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 500L, loading = true))
}
@Test
fun aStaleNotYetFromTheLoadControlCannotVetoAStoppedLoader() {
// media3 stops asking the moment a renderer goes unready — the failure this watchdog exists for
// — so a `false` recorded before that must not outrank a loader that has since stopped.
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
}
// The load control's own verdict
@Test
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
// The real failure this watchdog exists for, on a high bitrate stream: media3 released playback
// off its byte target with only a couple of seconds buffered, and the player still will not move.
val belowTheDurationBar = 2_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loadControlReady = true))
}
@Test
fun aLoadControlHoldingPlaybackBackIsNotIndictedWhileTheLoaderStillWants() {
// Plenty buffered by duration, so the duration signal alone would indict; media3 saying "not
// yet" while its loader keeps fetching is an ordinary rebuffer.
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L, loadControlReady = false))
assertEquals(
"nothing says this player could start",
Verdict.STARVED,
evaluate(bufferedAheadMs = 1_000L, loading = true, loadControlReady = false)
)
}
@Test
fun progressOutranksTheLoadControlVerdict() {
assertEquals(
Verdict.HEALTHY,
evaluate(currentPositionMs = position + 5_000, loadControlReady = true)
)
}
// Playback speed
@Test
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
// Enough media to start at 1x, but the load control measures its bar in playout time, so at 2x
// this player is still legitimately rebuffering rather than stalled.
val justEnoughAtNormalSpeed = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1_000L
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed))
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed, playbackSpeed = 2f))
}
@Test
fun aSlowMotionPlayerNeedsLessMediaBeforeItIsIndicted() {
val shortOfTheBar = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = shortOfTheBar))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = shortOfTheBar, playbackSpeed = 0.5f))
}
@Test
fun anImpossibleSpeedIsTreatedAsNormal() {
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = 0f))
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
}
// Progress
@Test
fun advancingPositionIsHealthy() {
assertEquals(
Verdict.HEALTHY,
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS)
)
}
@Test
fun advancingPositionOutranksAnExpiredTimeout() {
assertEquals(
Verdict.HEALTHY,
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, currentPositionMs = position + 5_000)
)
}
@Test
fun clockJitterBelowTheEpsilonIsNotProgress() {
assertEquals(
Verdict.STALLED,
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS - 1)
)
}
@Test
fun positionMovingBackwardsIsNotProgress() {
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
}
// Timeout
@Test
fun frozenPositionWaitsOutTheTimeout() {
assertEquals(Verdict.WAITING, evaluate(elapsedMs = BufferingStallPolicy.STALL_TIMEOUT_MS - 1))
}
/** The #1790 shape: data available, nothing playing, no error raised. */
@Test
fun bufferedButFrozenIsStalled() {
assertEquals(Verdict.STALLED, evaluate())
}
// Starvation — the loader's problem, not the renderer's
@Test
fun emptyBufferIsStarved() {
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 0))
}
@Test
fun starvationOutranksAnExpiredTimeout() {
assertEquals(
Verdict.STARVED,
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, bufferedAheadMs = 0)
)
}
/**
* `DefaultLoadControl` deliberately holds playback in `STATE_BUFFERING` until it has
* [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS] buffered. A watchdog that indicts
* the player inside that window is indicting it for obeying its own load control.
*/
@Test
fun aBufferBelowTheLoadControlPlayStartThresholdIsStarved() {
assertEquals(
Verdict.STARVED,
evaluate(
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
bufferedAheadMs = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS.toLong()
)
)
}
@Test
fun theStallThresholdClearsTheLoadControlPlayStartThreshold() {
assertTrue(
"MIN_BUFFER_AHEAD_MS (${BufferingStallPolicy.MIN_BUFFER_AHEAD_MS}) must exceed the load " +
"control's post-rebuffer play-start threshold " +
"(${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS})",
BufferingStallPolicy.MIN_BUFFER_AHEAD_MS > LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}
@Test
fun starvedIsStickyWhileTheBufferStaysThin() {
assertEquals(
Verdict.STARVED,
evaluate(
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1
)
)
}
// Stall clock ownership
@Test
fun starvationAndProgressBothRestartTheStallClock() {
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.STARVED))
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.HEALTHY))
}
@Test
fun waitingKeepsTheStallClockRunning() {
assertFalse(BufferingStallPolicy.resetsStallClock(Verdict.WAITING))
}
/**
* Regression: a long network stall used to accrue the whole timeout while starved, so the very
* first poll after the buffer refilled reported a stall that never happened and force-reloaded
* a perfectly healthy rebuffer. Starvation restarts the clock, so the refilled buffer gets the
* full timeout to start playing.
*/
@Test
fun aRecoveringBufferIsNotStalledByTimeSpentStarved() {
val starvedFor = 60_000L
val starved = evaluate(elapsedMs = starvedFor, bufferedAheadMs = 0)
assertEquals(Verdict.STARVED, starved)
assertTrue(BufferingStallPolicy.resetsStallClock(starved))
// The watchdog re-baselines on that verdict, so the next poll starts from zero elapsed.
assertEquals(
Verdict.WAITING,
evaluate(
elapsedMs = BufferingStallPolicy.CHECK_INTERVAL_MS,
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1
)
)
}
}
@@ -0,0 +1,61 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalSubtitleIdsTest {
@Test
fun unmergedTagResolves() {
assertTrue(ExternalSubtitleIds.isExternal("external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("external_0"))
assertEquals(7, ExternalSubtitleIds.indexOf("external_7"))
}
@Test
fun mediaSourceFactoryMergePrefixResolves() {
// DefaultMediaSourceFactory always merges side-loaded subtitles with the
// primary source, so MergingMediaPeriod prefixes the period index.
assertTrue(ExternalSubtitleIds.isExternal("1:external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("1:external_0"))
assertEquals(2, ExternalSubtitleIds.indexOf("3:external_2"))
}
@Test
fun containerSidecarOuterMergePrefixResolves() {
// The container-sidecar path wraps that merge in a second MergingMediaSource.
assertTrue(ExternalSubtitleIds.isExternal("0:1:external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("0:1:external_0"))
assertEquals(4, ExternalSubtitleIds.indexOf("0:2:external_4"))
}
@Test
fun embeddedAndUnknownIdsAreNotExternal() {
assertFalse(ExternalSubtitleIds.isExternal(null))
assertFalse(ExternalSubtitleIds.isExternal("1:2"))
assertFalse(ExternalSubtitleIds.isExternal("0:"))
// A container child whose own id merely ends in the tag's text.
assertFalse(ExternalSubtitleIds.isExternal("1:not_external_0"))
assertNull(ExternalSubtitleIds.indexOf("1:2"))
}
@Test
fun malformedTagIsExternalWithoutAnIndex() {
// Still a side-loaded track, but no usable URI lookup.
assertTrue(ExternalSubtitleIds.isExternal("1:external_x"))
assertNull(ExternalSubtitleIds.indexOf("1:external_x"))
}
@Test
fun writtenIdRoundTrips() {
for (index in 0..3) {
val id = ExternalSubtitleIds.idFor(index)
assertEquals(index, ExternalSubtitleIds.indexOf(id))
assertEquals(index, ExternalSubtitleIds.indexOf("1:$id"))
assertEquals(index, ExternalSubtitleIds.indexOf("0:1:$id"))
}
}
}
@@ -0,0 +1,72 @@
package com.edde746.plezy.exoplayer
import androidx.media3.exoplayer.DefaultLoadControl
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* [LoadControlPolicyTest] checks that the policy's arithmetic satisfies the ordering we believe
* media3 requires. This file checks the belief, by handing every value the settings screen can
* produce to a real `DefaultLoadControl.Builder`.
*
* The distinction matters because media3 validates with Guava `Preconditions.checkArgument`, not
* `androidx.media3.common.util.Assertions` and not a JVM `assert`. It is an unconditional throw
* that survives R8 — nothing in `proguard-rules.pro` elides it — so a rejected pair is an
* `IllegalArgumentException` out of player construction on a user's device, not a degraded buffer.
* A media3 upgrade that tightens the ordering should fail here rather than there.
*/
@RunWith(RobolectricTestRunner::class)
class LoadControlPolicyBuildTest {
/** Both Auto memory tiers, their boundary, and the unknown-memory case. */
private val memoryTiers = intArrayOf(0, 512, 1024, 2048, 2049, 4096, 8192)
private fun build(tier: LoadControlPolicy.BufferTier, availableMB: Int): DefaultLoadControl {
val durations = LoadControlPolicy.bufferDurations(tier, availableMB)
return DefaultLoadControl.Builder()
.setTargetBufferBytes(LoadControlPolicy.MIN_TARGET_BYTES)
.setPrioritizeTimeOverSizeThresholds(false)
.setBufferDurationsMs(
durations.minBufferMs,
durations.maxBufferMs,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
}
@Test
fun everyTierBuildsOnEveryMemoryTier() {
for (tier in LoadControlPolicy.BufferTier.entries) {
for (availableMB in memoryTiers) {
// build() throws IllegalArgumentException on a rejected ordering; reaching the next
// iteration is the assertion.
build(tier, availableMB)
}
}
}
@Test
fun aTierNameThisVersionDoesNotKnowStillBuilds() {
// The tier crosses a MethodChannel as a string, so the enum is not the only possible input:
// a Dart build newer than this native build can send a name that is not in `entries`.
for (wire in arrayOf(null, "", "gigantic", "EXTRA_LARGE", "0")) {
build(LoadControlPolicy.BufferTier.fromWire(wire), availableMB = 1024)
}
}
@Test
fun theShippedAutoTiersAreUnchanged() {
// #1816 adds a setting; it must not quietly retune the default everyone already has.
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2048)
)
assertEquals(
LoadControlPolicy.BufferDurations(30_000, 60_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2049)
)
}
}
@@ -2,6 +2,7 @@ package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
private const val MIB = 1024 * 1024
@@ -84,4 +85,112 @@ class LoadControlPolicyTest {
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, 0L))
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
}
// bufferDurations
/**
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
* a JVM assert, so a bad pair takes down `DefaultLoadControl.Builder.build()` in release too.
* Every path through the policy has to satisfy it, including inputs no UI can produce.
*/
private fun assertBuildable(durations: LoadControlPolicy.BufferDurations) {
assertTrue(
"bufferForPlaybackMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS}) must not exceed " +
"minBufferMs (${durations.minBufferMs})",
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS <= durations.minBufferMs
)
assertTrue(
"bufferForPlaybackAfterRebufferMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS}) " +
"must not exceed minBufferMs (${durations.minBufferMs})",
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS <= durations.minBufferMs
)
assertTrue(
"minBufferMs (${durations.minBufferMs}) must not exceed maxBufferMs (${durations.maxBufferMs})",
durations.minBufferMs <= durations.maxBufferMs
)
}
@Test
fun autoKeepsTheConservativeTierOnALowMemoryDevice() {
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 1024)
)
}
@Test
fun autoKeepsTheRoomierTierWhenMemoryAllows() {
assertEquals(
LoadControlPolicy.BufferDurations(30_000, 60_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 4096)
)
}
@Test
fun unknownMemoryTakesTheConservativeTier() {
// availMem is reported as non-positive when unknown; guessing roomy there would be the
// wrong way to be wrong on the device that can least afford it.
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 0)
)
}
@Test
fun anExplicitTierIgnoresTheMemoryTier() {
// The whole point of #1816: a low-memory box may still want a deep buffer, because the
// constraint it is working around is the network, not the heap. Auto is the only mode that
// gets to consult memory.
assertEquals(
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 8192),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 512)
)
}
@Test
fun theTiersMatchTheJellyfinClients() {
// Borrowed verbatim from jellyfin-androidtv BufferLength and jellyfin-android
// PlayerViewModel so the same words mean the same thing across Jellyfin clients.
// Changing these silently diverges Plezy from that shared vocabulary.
assertEquals(
LoadControlPolicy.BufferDurations(50_000, 120_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 4096)
)
assertEquals(
LoadControlPolicy.BufferDurations(80_000, 240_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.EXTRA_LARGE, availableMB = 4096)
)
}
@Test
fun everyTierProducesABuildablePairOnEveryMemoryTier() {
// A pair media3 rejects is a crash on play, not a bad buffer.
for (tier in LoadControlPolicy.BufferTier.entries) {
for (availableMB in intArrayOf(0, 512, 1024, 2048, 4096, 8192)) {
assertBuildable(LoadControlPolicy.bufferDurations(tier, availableMB))
}
}
}
// BufferTier.fromWire
@Test
fun theWireNamesMatchTheDartNativeValues() {
assertEquals(LoadControlPolicy.BufferTier.LARGE, LoadControlPolicy.BufferTier.fromWire("large"))
assertEquals(
LoadControlPolicy.BufferTier.EXTRA_LARGE,
LoadControlPolicy.BufferTier.fromWire("extra_large")
)
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("auto"))
}
@Test
fun anUnrecognisedOrAbsentTierFallsBackToAuto() {
// A Dart/Kotlin skew must degrade to the shipped defaults, not to a deep buffer nobody
// asked for on a device that may not afford it.
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(null))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(""))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("EXTRA_LARGE"))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("extraLarge"))
}
}
@@ -0,0 +1,125 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.LoadControl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
/**
* The wrapper has to be a complete [LoadControl], not a partial one.
*
* media3's interface gives almost every member a default implementation that throws
* `IllegalStateException`, so a forwarder this class forgets does not fail to compile — it takes the
* player down at construction, or the first time ExoPlayer asks. Building a real player is the only
* assertion that covers all of them at once, including methods a media3 upgrade adds later.
*/
@RunWith(RobolectricTestRunner::class)
class ObservingLoadControlTest {
private fun loadControl() = DefaultLoadControl.Builder().setTargetBufferBytes(4 * 1024 * 1024).build()
@Test
fun aRealPlayerCanBeBuiltAndReleasedThroughTheWrapper() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val player = ExoPlayer.Builder(activity).setLoadControl(ObservingLoadControl(loadControl())).build()
try {
// Construction alone already routes through getBackBufferDurationUs and getAllocator.
assertEquals(ExoPlayer.STATE_IDLE, player.playbackState)
} finally {
player.release()
}
}
@Test
fun theStartPlaybackVerdictIsRecordedAndResettable() {
// A fake delegate, because a fabricated `Parameters` cannot satisfy DefaultLoadControl's own
// timeline lookups. What matters here is that the wrapper reports what the delegate said.
val delegate = FakeLoadControl()
val observing = ObservingLoadControl(delegate)
assertNull("nothing asked yet", observing.startPlaybackVerdict)
delegate.startPlayback = true
assertTrue(observing.shouldStartPlayback(delegate.lastParameters))
assertEquals(true, observing.startPlaybackVerdict)
delegate.startPlayback = false
assertEquals(false, observing.shouldStartPlayback(delegate.lastParameters))
assertEquals(false, observing.startPlaybackVerdict)
observing.reset()
assertNull("a fresh buffering window must not inherit the last verdict", observing.startPlaybackVerdict)
}
@Test
fun otherCallsReachTheDelegate() {
val delegate = loadControl()
val observing = ObservingLoadControl(delegate)
val playerId = androidx.media3.exoplayer.analytics.PlayerId.UNSET
observing.onPrepared(playerId)
try {
assertSame(delegate.getAllocator(playerId)::class.java, observing.getAllocator(playerId)::class.java)
assertEquals(delegate.getBackBufferDurationUs(playerId), observing.getBackBufferDurationUs(playerId))
assertEquals(delegate.retainBackBufferFromKeyframe(playerId), observing.retainBackBufferFromKeyframe(playerId))
} finally {
observing.onReleased(playerId)
}
}
}
/** Minimal complete [LoadControl], so the wrapper's recording can be exercised on its own. */
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private class FakeLoadControl : LoadControl {
var startPlayback = false
val lastParameters: LoadControl.Parameters = LoadControl.Parameters(
androidx.media3.exoplayer.analytics.PlayerId.UNSET,
androidx.media3.common.Timeline.EMPTY,
LoadControl.EMPTY_MEDIA_PERIOD_ID,
/* playbackPositionUs= */
0L,
/* bufferedDurationUs= */
0L,
/* playbackSpeed= */
1f,
/* playWhenReady= */
true,
/* rebuffering= */
false,
/* targetLiveOffsetUs= */
androidx.media3.common.C.TIME_UNSET,
/* lastRebufferRealtimeMs= */
androidx.media3.common.C.TIME_UNSET
)
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean = startPlayback
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = true
override fun onPrepared(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun onTracksSelected(
parameters: LoadControl.Parameters,
trackGroups: androidx.media3.exoplayer.source.TrackGroupArray,
trackSelections: Array<out androidx.media3.exoplayer.trackselection.ExoTrackSelection>
) = Unit
override fun onStopped(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun onReleased(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun getAllocator(
playerId: androidx.media3.exoplayer.analytics.PlayerId
): androidx.media3.exoplayer.upstream.Allocator = androidx.media3.exoplayer.upstream.DefaultAllocator(true, 64 * 1024)
override fun getBackBufferDurationUs(playerId: androidx.media3.exoplayer.analytics.PlayerId): Long = 0L
override fun retainBackBufferFromKeyframe(playerId: androidx.media3.exoplayer.analytics.PlayerId): Boolean = false
}
@@ -0,0 +1,302 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioOutput
import androidx.media3.exoplayer.audio.AudioOutputProvider
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `DefaultAudioSink` increments a static, process-wide pending-release counter on every flush and
* decrements it only when the output reports `onReleased`. A dropped report pins that counter above
* zero for the life of the process, which silently disables media3's own AudioTrack-init retry and
* turns any later init failure into a permanent buffering hang (#1790). These tests pin the
* wrapper's side of that contract: exactly one report per release, from every path.
*/
@OptIn(UnstableApi::class)
class RawPositionAudioOutputTest {
private val rawPositionUs = AtomicLong(Long.MIN_VALUE)
private fun config(
encoding: Int = AudioFormat.ENCODING_PCM_16BIT,
isOffload: Boolean = false,
isTunneling: Boolean = false
): AudioOutputProvider.OutputConfig = AudioOutputProvider.OutputConfig.Builder()
.setEncoding(encoding)
.setSampleRate(48_000)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setBufferSize(40_000)
.setIsOffload(isOffload)
.setIsTunneling(isTunneling)
.build()
private fun provider(delegate: FakeOutputProvider) = RawPositionOutputProvider(delegate, rawPositionUs, log = null, sdkInt = 34)
private fun acquire(
provider: RawPositionOutputProvider,
config: AudioOutputProvider.OutputConfig
): Pair<RawPositionAudioOutput, RecordingListener> {
val output = provider.getAudioOutput(config) as RawPositionAudioOutput
val listener = RecordingListener()
output.addListener(listener)
return output to listener
}
// Release reporting
/**
* A parked track is never going to release, so its flush has to be answered at once. Deferring
* until the eviction would hold `DefaultAudioSink`'s process-wide pending-release count above
* zero for the whole live track after the first seek, and any nonzero value stops media3
* escalating *both* init and write failures — the #1790 hang, re-armed by an ordinary seek.
*/
@Test
fun aParkedOutputAnswersItsFlushImmediately() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config())
output.release()
assertEquals("the flush must be answered while the track is parked", 1, listener.releasedCount)
assertFalse("a parked output must keep its AudioTrack", delegate.outputs.single().released)
}
@Test
fun realReleaseReportsOnlyOnceTheDelegateConfirms() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
output.release()
val real = delegate.outputs.single()
assertTrue("a bitstream output must really release", real.released)
assertEquals("release must not be reported before the AudioTrack is gone", 0, listener.releasedCount)
real.confirmReleased()
assertEquals(1, listener.releasedCount)
}
@Test
fun releaseIsReportedOnceWhenTheDelegateNeverConfirms() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
// Player teardown: the sink flushes, then media3 posts the release completion to a playback
// looper that is already quitting, so the confirmation never arrives.
output.release()
provider.release()
assertEquals(1, listener.releasedCount)
}
@Test
fun aLateDelegateConfirmationDoesNotReportTwice() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
output.release()
output.settleReleaseNow()
delegate.outputs.single().confirmReleased()
assertEquals(1, listener.releasedCount)
}
// Reuse
@Test
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (first, _) = acquire(provider, config())
first.release()
val (second, _) = acquire(provider, config())
assertSame(first, second)
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
}
/**
* The sink charges the pending-release counter once per flush and builds a fresh listener per
* acquisition, so every reuse cycle has to settle its own flush. One unanswered cycle is enough
* to pin the counter above zero for the rest of the process.
*/
@Test
fun everyReuseCycleAnswersItsOwnFlush() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
repeat(5) {
val (output, listener) = acquire(provider, config())
output.release()
assertEquals("cycle $it must report exactly one release", 1, listener.releasedCount)
}
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
}
/** A stale listener per cycle on the real output is how the accounting drifted in the first place. */
@Test
fun reuseDoesNotAccumulateListenersOnTheRealOutput() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
repeat(5) {
val (output, _) = acquire(provider, config())
output.release()
}
assertEquals(1, delegate.outputs.single().listeners.size)
}
@Test
fun forwardedEventsReachTheCurrentListenerOnly() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (first, firstListener) = acquire(provider, config())
first.release()
val (_, secondListener) = acquire(provider, config())
delegate.outputs.single().emitUnderrun()
assertEquals(0, firstListener.underrunCount)
assertEquals(1, secondListener.underrunCount)
}
// Eviction — the overlap media3 itself tolerates, kept tolerable
/**
* The replacement is deliberately built while the evicted track is still going away. Refusing
* until it confirms looks safer, but the refusal reaches media3 as an init failure with no
* pending release to excuse it, so its 200ms deadline starts immediately — and on the TVs this
* cache exists for a teardown can outlast that, turning an ordinary config change into a
* playback error. Upstream tolerates the same overlap.
*/
@Test
fun anEvictedParkedOutputIsReplacedWithoutWaitingForItsRelease() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, _) = acquire(provider, config())
output.release()
provider.getAudioOutput(config(isTunneling = true))
assertTrue("the parked output must have been released", delegate.outputs.first().released)
assertEquals("the replacement must not wait for the release", 2, delegate.outputs.size)
}
/**
* The evicted track's flush was already answered when it parked, so its late confirmation —
* however late — must not report a second release and drive the process-wide count negative.
*/
@Test
fun aSlowEvictionReleaseDoesNotReportASecondTime() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config())
output.release()
val evicted = delegate.outputs.first()
provider.getAudioOutput(config(isTunneling = true))
assertEquals(1, listener.releasedCount)
evicted.confirmReleased()
evicted.confirmReleased()
assertEquals(1, listener.releasedCount)
}
// Fakes
private class RecordingListener : AudioOutput.Listener {
var releasedCount = 0
var underrunCount = 0
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
override fun onOffloadDataRequest() = Unit
override fun onOffloadPresentationEnded() = Unit
override fun onUnderrun() {
underrunCount++
}
override fun onReleased() {
releasedCount++
}
}
private class FakeOutputProvider : AudioOutputProvider {
val outputs = mutableListOf<FakeAudioOutput>()
override fun getFormatSupport(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
override fun getOutputConfig(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput = FakeAudioOutput().also { outputs.add(it) }
override fun addListener(listener: AudioOutputProvider.Listener) = Unit
override fun removeListener(listener: AudioOutputProvider.Listener) = Unit
override fun release() = Unit
}
private class FakeAudioOutput : AudioOutput {
val listeners = mutableListOf<AudioOutput.Listener>()
var released = false
var stopped = false
var flushed = false
fun confirmReleased() {
for (listener in listeners.toList()) listener.onReleased()
}
fun emitUnderrun() {
for (listener in listeners.toList()) listener.onUnderrun()
}
override fun play() = Unit
override fun pause() = Unit
override fun flush() {
flushed = true
}
override fun stop() {
stopped = true
}
override fun release() {
released = true
}
override fun write(buffer: ByteBuffer, encodedAccessUnitCount: Int, presentationTimeUs: Long) = true
override fun setVolume(volume: Float) = Unit
override fun isOffloadedPlayback() = false
override fun getAudioSessionId() = 1
override fun getSampleRate() = 48_000
override fun getBufferSizeInFrames() = 0L
override fun getPositionUs() = 0L
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun isStalled() = false
override fun addListener(listener: AudioOutput.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: AudioOutput.Listener) {
listeners.remove(listener)
}
override fun setPlaybackParameters(playbackParams: PlaybackParameters) = Unit
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = Unit
override fun setOffloadEndOfStream() = Unit
override fun attachAuxEffect(effectId: Int) = Unit
override fun setAuxEffectSendLevel(level: Float) = Unit
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = Unit
}
}
@@ -45,9 +45,49 @@ class SubtitleViewLayoutTest {
assertAspectCloseTo16By9(bitmap!!)
}
@Test
fun anchorToScreenSizesTextToContainerAndKeepsBitmapOnVideoRect() {
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, anchorToScreen = true)
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
assertEquals(SubtitleViewDimensions(2424, 1080), text)
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
assertAspectCloseTo16By9(bitmap!!)
}
@Test
fun anchorToScreenReachesBelowLetterboxedWideVideo() {
// 2.37:1 video on a 16:9 screen — the #1730 use case. Without the anchor
// the text view stops at the bottom letterbox bar; with it the view spans
// the physical screen height.
val unanchored = SubtitleViewLayout.textDimensions(
containerWidth = 1920,
containerHeight = 1080,
videoWidth = 2560,
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
zoomScale = 1f
)
val anchored = SubtitleViewLayout.textDimensions(
containerWidth = 1920,
containerHeight = 1080,
videoWidth = 2560,
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
zoomScale = 1f,
anchorToScreen = true
)
assertEquals(SubtitleViewDimensions(1920, 810), unanchored)
assertEquals(SubtitleViewDimensions(1920, 1080), anchored)
}
private fun textDimensions(
resizeMode: Int,
zoomScale: Float = 1f
zoomScale: Float = 1f,
anchorToScreen: Boolean = false
): SubtitleViewDimensions? = SubtitleViewLayout.textDimensions(
containerWidth = 2424,
containerHeight = 1080,
@@ -55,7 +95,8 @@ class SubtitleViewLayoutTest {
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = resizeMode,
zoomScale = zoomScale
zoomScale = zoomScale,
anchorToScreen = anchorToScreen
)
private fun bitmapDimensions(
@@ -0,0 +1,496 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.AuxEffectInfo
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioSink
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Routing and back-pressure contract for the TrueHD MAT carrier (#1804).
*
* The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting
* the wrong sink handle TrueHD.
*/
@OptIn(UnstableApi::class)
class TrueHdCarrierSinkTest {
private val accessUnits: ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
.use { it.readBytes() }
private val golden: ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_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 sink(
carrier: FakeSink = FakeSink(),
normal: FakeSink = FakeSink(),
routeAvailable: Boolean = true,
blocked: Boolean = false
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
// --- Selection ---
/**
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
* its raw ENCODING_DOLBY_TRUEHD path, which is the configuration that wedges on these devices.
*/
@Test
fun trueHdWithoutACarrierRouteIsReportedUnsupportedSoItDecodes() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal, routeAvailable = false)
assertFalse(carrierSink.supportsFormat(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
@Test
fun trueHdWithACarrierRouteIsSupportedDirectly() {
val carrierSink = sink()
assertTrue(carrierSink.supportsFormat(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
/** Downmix and normalization already force decoding; the carrier must not override that. */
@Test
fun blockedDirectOutputDeclinesTheCarrier() {
val carrierSink = sink(blocked = true)
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/**
* 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
* selecting the carrier for it would produce silence.
*/
@Test
fun theFortyFourPointOneFamilyDeclinesTheCarrierFromTheFormatAlone() {
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)))
}
/** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */
@Test
fun aSpeedChangeDeclinesTheCarrier() {
val carrierSink = sink()
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/**
* The real UI path: speed changes while the carrier is already live. Querying support after
* setting speed passes trivially; nothing re-asks the sink unless capabilities are invalidated,
* so without this the carrier keeps running and is handed a speed it cannot apply.
*/
@Test
fun leavingUnitSpeedMidPlaybackInvalidatesCapabilities() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals("the renderer must be asked to re-evaluate", 1, listener.capabilityInvalidations)
assertEquals(
"the carrier delegate must never receive a speed it cannot apply",
1f,
carrier.lastPlaybackParameters.speed,
0f
)
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/** Returning to 1x has to re-offer the carrier, or Atmos is lost until the next open. */
@Test
fun returningToUnitSpeedInvalidatesCapabilitiesAgain() {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
assertEquals(2, listener.capabilityInvalidations)
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
/** A speed change that stays at 1x is not a transition and must not disturb selection. */
@Test
fun aNoOpSpeedChangeDoesNotInvalidateCapabilities() {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
assertEquals(0, listener.capabilityInvalidations)
}
// --- Rate-family mismatch discovered mid-stream ---
/**
* Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed.
* When they disagree the packer emits nothing, so consuming the input would turn the stream into
* silence. The unit has to survive for whoever takes over.
*/
@Test
fun aRateFamilyMismatchLeavesTheCarrierInsteadOfGoingSilent() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
val buffer = ByteBuffer.wrap(fortyFourFamilyAccessUnits())
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
assertFalse("a mismatch must apply back pressure, not report success", accepted)
assertEquals("the offending access unit must stay in the buffer", 0, buffer.position())
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
assertEquals(
"TrueHD must now decode rather than ride the carrier",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(trueHdFormat())
)
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
* would re-offer the carrier and loop straight back into the mismatch.
*/
@Test
fun theMismatchLatchSurvivesTheResetThatAccompaniesTheHandover() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
carrierSink.flush()
carrierSink.reset()
assertEquals(
"the carrier must stay off across the handover",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/** The latch is scoped to the sink, so a released sink starts clean. */
@Test
fun releasingTheSinkClearsTheMismatchLatch() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
carrierSink.release()
assertEquals(
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/**
* Genuine 44.1kHz TrueHD access units, encoder-produced. Flipping the rate nibble in a 48kHz
* stream would invalidate the major sync checksum, which is not the case being modelled.
*/
private val fortyFourFamilyUnits by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_441_access_units.bin"))
.use { it.readBytes() }
}
private fun fortyFourFamilyAccessUnits(): ByteArray = fortyFourFamilyUnits
/**
* The mismatch is a property of one stream. The owner signals the real boundary, because the sink
* cannot see it: renderer resets and flushes happen constantly within a single item.
*/
@Test
fun aNewMediaItemClearsTheMismatchLatch() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
assertEquals(
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(trueHdFormat())
)
carrierSink.beginMediaItem()
assertEquals(
"a new item must be able to bitstream again",
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/**
* The boundary hook runs on the app thread while the mismatch is found on the playback thread, so
* a buffer from the outgoing stream can be handled after the new item began. That write must name
* the stream it came from rather than disabling the carrier for its successor.
*/
@Test
fun aLateMismatchFromThePreviousItemDoesNotPoisonTheNewOne() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
// The new item starts before the outgoing stream's last buffer is handled.
carrierSink.beginMediaItem()
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
assertEquals(
"the stale mismatch belongs to the previous item",
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/** Everything that is not TrueHD keeps going to the existing processed sink. */
@Test
fun otherFormatsAreLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal)
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(ac3))
assertTrue(carrierSink.supportsFormat(ac3))
}
// --- Back pressure ---
/**
* The regression this exists for: a delegate that refuses a burst part-way through a sample must
* not cost the access units behind it. media3 re-delivers the same buffer after a false return,
* so the carrier has to resume from where it stopped and come out byte-identical.
*/
@Test
fun aRefusedBurstMidSampleLosesNoAccessUnits() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
// Refuse every third burst once, so rejections land inside samples rather than between them.
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink)
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
}
/** With no back pressure the same stream must produce exactly the same bytes. */
@Test
fun theCarrierOutputMatchesTheGoldenStream() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
assertArrayEquals(golden, feedWholeStream(carrierSink))
}
/** A refused burst must be re-offered before any further input is taken. */
@Test
fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
carrier.refuseNext = true
val buffer = ByteBuffer.wrap(accessUnits)
// Drive until the first refusal is observed.
while (buffer.hasRemaining() && carrierSink.handleBuffer(buffer, 0L, 1)) Unit
assertTrue("expected a refusal to leave input unconsumed", buffer.hasRemaining())
assertTrue(carrierSink.hasPendingData())
}
// --- Routing of controls ---
@Test
fun persistentControlsReachBothDelegates() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
carrierSink.setVolume(0.5f)
carrierSink.setAudioSessionId(7)
assertEquals(0.5f, carrier.lastVolume, 0f)
assertEquals(0.5f, normal.lastVolume, 0f)
assertEquals(7, carrier.sessionId)
assertEquals(7, normal.sessionId)
}
/** Silence skipping deletes "silent" bytes, which would break the carrier's frame cadence. */
@Test
fun silenceSkippingNeverReachesTheCarrier() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
carrierSink.setSkipSilenceEnabled(true)
assertTrue(normal.skipSilence)
assertFalse("the carrier delegate must never skip silence", carrier.skipSilence)
}
@Test
fun theCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
val configured = checkNotNull(carrier.configuredFormat)
assertEquals(MimeTypes.AUDIO_RAW, configured.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.pcmEncoding)
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.sampleRate)
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.channelCount)
}
@Test
fun nonCarrierAudioIsConfiguredOnTheNormalSink() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
carrierSink.configure(ac3, 0, null)
assertSame(ac3, normal.configuredFormat)
assertEquals(null, carrier.configuredFormat)
}
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
val buffer = ByteBuffer.wrap(accessUnits)
var guard = 0
while (buffer.hasRemaining() && 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")
field.isAccessible = true
return field.get(sink) as FakeSink
}
private class RecordingSinkListener : AudioSink.Listener {
var capabilityInvalidations = 0
override fun onPositionDiscontinuity() = Unit
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = Unit
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = Unit
override fun onAudioSinkError(audioSinkError: Exception) = Unit
override fun onAudioCapabilitiesChanged() {
capabilityInvalidations++
}
}
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
private class FakeSink : AudioSink {
val written = ByteArrayOutputStream()
var configuredFormat: Format? = null
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
var lastVolume: Float = -1f
var sessionId: Int = -1
var skipSilence: Boolean = false
var refuseNext: Boolean = false
var refuseEveryNth: Int = 0
var refusals: Int = 0
var lastPlaybackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var offered = 0
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
offered++
val refuse = refuseNext || (refuseEveryNth > 0 && offered % refuseEveryNth == 0)
if (refuse) {
refuseNext = false
refusals++
// Refuse once per burst: the retry must be accepted or the stream cannot progress.
offered++
return false
}
val copy = ByteArray(buffer.remaining())
buffer.duplicate().get(copy)
written.write(copy)
buffer.position(buffer.limit())
return true
}
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
configuredFormat = inputFormat
}
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
override fun getFormatSupport(format: Format) = formatSupport
override fun setVolume(volume: Float) {
lastVolume = volume
}
override fun setAudioSessionId(audioSessionId: Int) {
sessionId = audioSessionId
}
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
skipSilence = skipSilenceEnabled
}
override fun getSkipSilenceEnabled() = skipSilence
override fun setListener(listener: AudioSink.Listener) = Unit
override fun setPlayerId(playerId: PlayerId?) = Unit
override fun setClock(clock: Clock) = Unit
override fun getCurrentPositionUs(sourceEnded: Boolean) = 0L
override fun play() = Unit
override fun handleDiscontinuity() = Unit
override fun playToEndOfStream() = Unit
override fun isEnded() = false
override fun hasPendingData() = false
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
lastPlaybackParameters = playbackParameters
}
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun setAudioAttributes(audioAttributes: AudioAttributes) = Unit
override fun getAudioAttributes(): AudioAttributes? = null
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) = Unit
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) = Unit
override fun getAudioTrackBufferSizeUs() = 0L
override fun enableTunnelingV21() = Unit
override fun disableTunneling() = Unit
override fun pause() = Unit
override fun flush() = Unit
override fun reset() = Unit
}
}
@@ -0,0 +1,180 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the TrueHD -> MAT/IEC 61937 carrier against FFmpeg (#1804).
*
* 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:
*
* ```
* ffmpeg -i surround_5_1_truehd.mka -c:a copy -f truehd truehd_access_units.bin
* ffmpeg -i surround_5_1_truehd.mka -c:a copy -f spdif truehd_iec61937_golden.bin
* ```
*/
class TrueHdMatPackerTest {
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private val accessUnits by lazy { resource("truehd_access_units.bin") }
private val golden by lazy { resource("truehd_iec61937_golden.bin") }
/** 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 / TrueHdMatPacker.MAT_PKT_OFFSET,
packed.size / TrueHdMatPacker.MAT_PKT_OFFSET
)
// Compare per burst so a failure names the frame rather than dumping 240KB.
for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) {
val from = index * TrueHdMatPacker.MAT_PKT_OFFSET
val to = from + TrueHdMatPacker.MAT_PKT_OFFSET
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 the inter-frame gap. */
@Test
fun everyBurstCarriesTheTrueHdPreamble() {
val packed = packAll(accessUnits)
assertTrue("expected at least one burst", packed.isNotEmpty())
for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) {
val at = index * TrueHdMatPacker.MAT_PKT_OFFSET
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
assertEquals("burst $index Pc (IEC61937_TRUEHD)", 0x16, readLittleEndianShort(packed, at + 4))
assertEquals("burst $index Pd", TrueHdMatPacker.MAT_FRAME_SIZE, readLittleEndianShort(packed, at + 6))
}
}
/** 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
assertEquals(20_000L, durationUs)
}
/**
* Media3's Matroska path concatenates 16 syncframes into one sample, so the packer has to split
* them itself; feeding a whole sample as if it were one access unit reads a single timing for
* sixteen frames and desynchronises the carrier.
*/
@Test
fun aRechunkedSampleIsSplitIntoItsAccessUnits() {
var offset = 0
var units = 0
while (offset < accessUnits.size) {
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
units++
offset += length
}
assertTrue("fixture should contain many access units, found $units", units > 16)
assertEquals("access unit walk must consume the stream exactly", accessUnits.size, offset)
}
/** Reset must not leave a half-filled frame behind, or the next seek emits a spliced burst. */
@Test
fun resetDropsPartialCarrierState() {
val packer = TrueHdMatPacker()
feed(packer, accessUnits, stopAfterBursts = 1)
packer.reset()
val afterReset = packAll(accessUnits, packer)
assertArrayEquals(
"a reset packer must reproduce the stream from the start",
packAll(accessUnits),
afterReset
)
}
/** The packer must not allocate a burst per frame; buffers alternate and are reused. */
@Test
fun burstBuffersAreReusedRatherThanAllocated() {
val packer = TrueHdMatPacker()
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
var bursts = 0
var offset = 0
while (offset < accessUnits.size) {
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
packer.packAccessUnit(accessUnits, offset, length)?.let {
seen[it] = true
bursts++
}
offset += length
}
assertTrue("expected several bursts, saw $bursts", bursts > 2)
assertTrue("at most two buffers may ever be handed out, saw ${seen.size}", seen.size <= 2)
}
private fun packAll(units: ByteArray, packer: TrueHdMatPacker = TrueHdMatPacker()): ByteArray {
val out = java.io.ByteArrayOutputStream()
var offset = 0
while (offset < units.size) {
val length = TrueHdMatPacker.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()
}
private fun feed(packer: TrueHdMatPacker, units: ByteArray, stopAfterBursts: Int) {
var offset = 0
var bursts = 0
while (offset < units.size && bursts < stopAfterBursts) {
val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size)
if (length == 0) break
if (packer.packAccessUnit(units, offset, length) != null) bursts++
offset += length
}
}
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
/**
* The flag gates every later call, so leaving it latched across a reset would make a packer that
* once saw a 44.1kHz-family stream emit nothing for the rest of its life.
*/
@Test
fun resetClearsTheUnsupportedRateFamilyFlag() {
val packer = TrueHdMatPacker()
val units = resource("truehd_441_access_units.bin")
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)
packer.reset()
assertFalse("reset must clear the flag", packer.unsupportedRateFamily)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
packAll(accessUnits, TrueHdMatPacker())
)
}
}
@@ -0,0 +1,58 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DeviceQuirksTest {
private fun unreliable(
manufacturer: String = "Amazon",
model: String = "AFTMM",
fps: Float = 23.976f
) = DeviceQuirks.hasUnreliableTunneledPlayback(manufacturer, model, fps)
@Test
fun fireTvStick4kJuddersOnFilmRateSoTunnelingIsWithdrawn() {
assertTrue(unreliable(fps = 23.976f))
assertTrue(unreliable(fps = 24f))
assertTrue(unreliable(fps = 25f))
assertTrue(unreliable(fps = 29.97f))
assertTrue(unreliable(fps = DeviceQuirks.MAX_UNRELIABLE_TUNNELED_FPS))
}
@Test
fun highFrameRateKeepsTunnelingOnTheSameDevice() {
// Tunneling exists for this workload and Amazon recommends it here, so the
// quirk must not reach 4K50/60 content.
assertFalse(unreliable(fps = 50f))
assertFalse(unreliable(fps = 59.94f))
assertFalse(unreliable(fps = 60f))
}
@Test
fun unknownFrameRateLeavesBehaviourUnchanged() {
assertFalse(unreliable(fps = -1f))
assertFalse(unreliable(fps = 0f))
}
@Test
fun otherAmazonHardwareIsUnaffected() {
// Only AFTMM was reported and A/B-tested; siblings keep stock behaviour.
assertFalse(unreliable(model = "AFTKA"))
assertFalse(unreliable(model = "AFTMM2"))
assertFalse(unreliable(model = "AFTSSS"))
assertFalse(unreliable(model = "KFMAWI"))
}
@Test
fun otherManufacturersAreUnaffected() {
assertFalse(unreliable(manufacturer = "NVIDIA", model = "SHIELD Android TV"))
assertFalse(unreliable(manufacturer = "Sony", model = "BRAVIA 4K GB"))
}
@Test
fun buildStringCasingDoesNotDefeatTheMatch() {
assertTrue(unreliable(manufacturer = "amazon", model = "aftmm"))
}
}
@@ -0,0 +1,286 @@
package com.edde746.plezy.watchnext
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.work.NetworkType
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.lang.reflect.Proxy
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowContentResolver
@RunWith(RobolectricTestRunner::class)
class ShelfRefreshWorkerTest {
private val context: Context get() = RuntimeEnvironment.getApplication()
@Before
fun setUp() {
context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, StubTvProvider())
WorkManagerTestInitHelper.initializeTestWorkManager(context)
}
@After
fun tearDown() {
ShelfRefreshWorker.engineLauncherOverride = null
// Leave no live lease behind for the next test in this sandbox.
SystemShelfLifecycle.invalidate(SystemShelfLifecycle.acquire())
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
}
@Test
fun successfulSyncSchedulesUniquePeriodicRefreshWithKeepAndNetworkConstraint() {
val plugin = WatchNextPlugin()
val binding = pluginBinding()
plugin.onAttachedToEngine(binding)
try {
val first = ShelfRecordingResult()
plugin.onMethodCall(syncCall(generation = 1), first)
awaitResult(first)
assertEquals(true, first.successValue)
val infos = uniqueWorkInfos()
assertEquals(1, infos.size)
val info = infos.single()
assertEquals(WorkInfo.State.ENQUEUED, info.state)
assertEquals(NetworkType.CONNECTED, info.constraints.requiredNetworkType)
assertEquals(TimeUnit.HOURS.toMillis(6), info.periodicityInfo?.repeatIntervalMillis)
// KEEP: a second successful sync must not replace the pending request.
val second = ShelfRecordingResult()
plugin.onMethodCall(syncCall(generation = 2), second)
awaitResult(second)
assertEquals(true, second.successValue)
assertEquals(info.id, uniqueWorkInfos().single().id)
} finally {
plugin.onDetachedFromEngine(binding)
}
}
@Test
fun clearCancelsScheduledRefresh() {
val plugin = WatchNextPlugin()
val binding = pluginBinding()
plugin.onAttachedToEngine(binding)
try {
val sync = ShelfRecordingResult()
plugin.onMethodCall(syncCall(generation = 1), sync)
awaitResult(sync)
assertEquals(true, sync.successValue)
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
val clear = ShelfRecordingResult()
plugin.onMethodCall(
MethodCall("clear", mapOf("schemaVersion" to 3, "ownerId" to "owner-a", "generation" to 2L)),
clear
)
awaitResult(clear)
assertEquals(true, clear.successValue)
assertEquals(WorkInfo.State.CANCELLED, uniqueWorkInfos().single().state)
} finally {
plugin.onDetachedFromEngine(binding)
}
}
@Test
fun receiverDoesNotArmRefreshWithoutPersistedShelfState() {
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
assertTrue(uniqueWorkInfos().isEmpty())
}
@Test
fun receiverRearmsRefreshOnBootWhenShelfStateIsPersisted() {
// granted_uris is only written by a committed sync (clear removes it), so
// its presence — even as an empty set — marks a prior sync.
context.getSharedPreferences("system_shelf_state", 0).edit()
.putStringSet("granted_uris", emptySet())
.putInt("shelf_schema_version", 1)
.commit()
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
}
@Test
fun receiverRearmsRefreshOnPackageReplacedWhenShelfStateIsPersisted() {
context.getSharedPreferences("system_shelf_state", 0).edit()
.putStringSet("granted_uris", emptySet())
.putInt("shelf_schema_version", 1)
.commit()
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
}
@Test
fun workerSkipsWithoutLaunchingEngineWhenLiveEngineHoldsLease() {
shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_LEANBACK, true)
val launched = AtomicBoolean(false)
ShelfRefreshWorker.engineLauncherOverride = {
launched.set(true)
true
}
val lease = SystemShelfLifecycle.acquire()
try {
val result = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
assertEquals(androidx.work.ListenableWorker.Result.success(), result)
assertFalse(launched.get())
} finally {
SystemShelfLifecycle.invalidate(lease)
}
}
@Test
fun workerSkipsWithoutLaunchingEngineOnNonLeanbackDevices() {
val launched = AtomicBoolean(false)
ShelfRefreshWorker.engineLauncherOverride = {
launched.set(true)
true
}
val result = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
assertEquals(androidx.work.ListenableWorker.Result.success(), result)
assertFalse(launched.get())
}
@Test
fun unattendedWorkerRunsInjectedLauncherAndMapsCompletionToResult() {
shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_LEANBACK, true)
val launched = AtomicBoolean(false)
ShelfRefreshWorker.engineLauncherOverride = {
launched.set(true)
true
}
val success = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
assertEquals(androidx.work.ListenableWorker.Result.success(), success)
assertTrue(launched.get())
ShelfRefreshWorker.engineLauncherOverride = { false }
val failure = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
assertEquals(androidx.work.ListenableWorker.Result.retry(), failure)
}
private val directExecutor = Executor { it.run() }
private fun uniqueWorkInfos(): List<WorkInfo> = WorkManager.getInstance(context).getWorkInfosForUniqueWork(ShelfRefreshScheduler.WORK_NAME).get()
private fun syncCall(generation: Long) = MethodCall(
"sync",
mapOf(
"schemaVersion" to 3,
"ownerId" to "owner-a",
"generation" to generation,
"items" to emptyList<Map<String, Any?>>()
)
)
private fun awaitResult(result: ShelfRecordingResult) {
repeat(100) {
shadowOf(android.os.Looper.getMainLooper()).idle()
if (result.completed.await(10, TimeUnit.MILLISECONDS)) return
}
assertTrue("Watch Next result never completed", false)
}
private fun pluginBinding(): FlutterPlugin.FlutterPluginBinding {
val messenger = Proxy.newProxyInstance(
BinaryMessenger::class.java.classLoader,
arrayOf(BinaryMessenger::class.java)
) { _, _, _ -> null } as BinaryMessenger
val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
val arguments = constructor.parameterTypes.map { type ->
when {
Context::class.java.isAssignableFrom(type) -> context
BinaryMessenger::class.java.isAssignableFrom(type) -> messenger
else -> null
}
}.toTypedArray()
return constructor.newInstance(*arguments) as FlutterPlugin.FlutterPluginBinding
}
}
/** Just enough TV provider for an empty-items sync/clear to commit. */
private class StubTvProvider : ContentProvider() {
private val inserted = mutableListOf<ContentValues>()
private var nextRowId = 1L
override fun onCreate(): Boolean = true
override fun insert(uri: Uri, values: ContentValues?): Uri {
inserted += ContentValues(values)
return uri.buildUpon().appendPath((nextRowId++).toString()).build()
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val deleted = inserted.size
inserted.clear()
return deleted
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor = MatrixCursor(
projection ?: arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
)
)
override fun getType(uri: Uri): String? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
}
private class ShelfRecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
override fun success(result: Any?) {
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completed.countDown()
}
override fun notImplemented() {
completed.countDown()
}
}
@@ -291,7 +291,7 @@ class WatchNextProviderTest {
MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to "active-owner",
"generation" to 1L,
"items" to listOf(mapOf("contentId" to "active", "title" to "Active"))
@@ -308,7 +308,7 @@ class WatchNextProviderTest {
MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to "queued-owner",
"generation" to 2L,
"items" to listOf(mapOf("contentId" to "queued", "title" to "Queued"))
@@ -1281,7 +1281,7 @@ class WatchNextProviderTest {
private fun syncCall(ownerId: String, generation: Long) = MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to ownerId,
"generation" to generation,
"items" to emptyList<Map<String, Any?>>()
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"/>
</svg>

After

Width:  |  Height:  |  Size: 790 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M1.928.029A2.47 2.47 0 0 0 .093 1.673c-.085.248-.09.629-.09 10.33s.005 10.08.09 10.33a2.51 2.51 0 0 0 1.512 1.558l.276.108h20.237l.277-.108a2.51 2.51 0 0 0 1.512-1.559c.085-.25.09-.63.09-10.33s-.005-10.08-.09-10.33A2.51 2.51 0 0 0 22.395.115l-.277-.109L12.117 0C6.615-.004 2.032.011 1.929.029m7.48 8.067 2.123 2.004v1.54c0 .897-.02 1.536-.043 1.527s-.92-.845-1.995-1.86c-1.071-1.01-1.962-1.84-1.977-1.84s-.024 1.91-.024 4.248v4.25H4.911V6.085h1.188l1.183.006zm9.729 3.93v5.94h-2.63l-.01-4.25-.013-4.25-1.907 1.795a367 367 0 0 1-1.98 1.864c-.076.056-.08-.047-.08-1.489v-1.555l2.127-1.995 2.122-1.995 1.187-.005h1.184z"/></svg>

After

Width:  |  Height:  |  Size: 715 B

+1 -5
View File
@@ -24,7 +24,6 @@
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
@@ -80,7 +79,6 @@
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
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; };
B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AtmosProbePlugin.swift; path = ../shared/apple/AtmosProbe/AtmosProbePlugin.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.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>"; };
@@ -134,7 +132,6 @@
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */,
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */,
B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */,
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
@@ -428,7 +425,6 @@
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */,
B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */,
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
@@ -802,7 +798,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = exactVersion;
version = 1.0.16;
version = 1.0.17;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "39df1216941c1442e9163d2a574ca37ef2c2b4ff",
"version" : "1.0.16"
"revision" : "8627bae097f45b705ac3cbc8972df115018f2090",
"version" : "1.0.17"
}
},
{
@@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "39df1216941c1442e9163d2a574ca37ef2c2b4ff",
"version" : "1.0.16"
"revision" : "8627bae097f45b705ac3cbc8972df115018f2090",
"version" : "1.0.17"
}
},
{
-4
View File
@@ -42,10 +42,6 @@ import MediaPlayer
MpvAudioPlayerPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AtmosProbePlugin") {
AtmosProbePlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeviceAdjustmentChannel") {
registerDeviceAdjustmentChannel(messenger: registrar.messenger())
}
-134
View File
@@ -101,46 +101,6 @@ final class ReleaseTrackingCore: MpvPlayerCoreBase {
deinit { onDeinit() }
}
final class ProbeURLProtocol: URLProtocol {
private static let lock = NSLock()
private static var startHandler: ((ProbeURLProtocol) -> Void)?
private static var stopHandler: (() -> Void)?
static func configure(
start: @escaping (ProbeURLProtocol) -> Void,
stop: (() -> Void)? = nil
) {
lock.lock()
startHandler = start
stopHandler = stop
lock.unlock()
}
static func reset() {
lock.lock()
startHandler = nil
stopHandler = nil
lock.unlock()
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.lock.lock()
let handler = Self.startHandler
Self.lock.unlock()
handler?(self)
}
override func stopLoading() {
Self.lock.lock()
let handler = Self.stopHandler
Self.lock.unlock()
handler?()
}
}
final class MpvPlayerContractTests: XCTestCase {
private let failure = NSError(
domain: "MpvPlayerContractTests",
@@ -480,100 +440,6 @@ final class MpvPlayerContractTests: XCTestCase {
XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384))
}
func testRawEc3LoaderBoundsAndIgnoresLateCallbacksForBothModes() {
for finiteLength in [false, true] {
let loader = RawEc3Loader(
source: URL(string: "https://example.invalid/test.ec3")!,
finiteLength: finiteLength,
maximumBufferedBytes: 8,
sessionConfiguration: .ephemeral
)
let session = URLSession(configuration: .ephemeral)
let task = session.dataTask(with: URL(string: "https://example.invalid/test.ec3")!)
loader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
var snapshot = loader.statusSnapshot()
XCTAssertEqual(snapshot.bytesReceived, 4)
XCTAssertEqual(snapshot.retainedBytes, 4)
XCTAssertNil(snapshot.errorCode)
loader.urlSession(session, dataTask: task, didReceive: Data([5, 6, 7, 8, 9]))
snapshot = loader.statusSnapshot()
XCTAssertEqual(snapshot.bytesReceived, 4)
XCTAssertEqual(snapshot.retainedBytes, 0)
XCTAssertEqual(snapshot.errorCode, "response_too_large")
loader.urlSession(session, dataTask: task, didReceive: Data([10]))
let lateSnapshot = loader.statusSnapshot()
XCTAssertEqual(lateSnapshot.bytesReceived, snapshot.bytesReceived)
XCTAssertEqual(lateSnapshot.retainedBytes, 0)
loader.cancel()
loader.cancel()
XCTAssertEqual(loader.statusSnapshot().pendingRequestCount, 0)
let cancelledLoader = RawEc3Loader(
source: URL(string: "https://example.invalid/cancel.ec3")!,
finiteLength: finiteLength,
maximumBufferedBytes: 8,
sessionConfiguration: .ephemeral
)
cancelledLoader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
XCTAssertEqual(cancelledLoader.statusSnapshot().retainedBytes, 4)
cancelledLoader.cancel()
cancelledLoader.cancel()
let cancelledSnapshot = cancelledLoader.statusSnapshot()
XCTAssertEqual(cancelledSnapshot.retainedBytes, 0)
XCTAssertEqual(cancelledSnapshot.pendingRequestCount, 0)
session.invalidateAndCancel()
}
}
func testRawEc3LoaderCompletesThroughInjectedURLProtocolForBothModes() {
defer { ProbeURLProtocol.reset() }
for finiteLength in [false, true] {
let requestStarted = expectation(description: "probe request started")
let loaderFinished = expectation(description: "probe loader finished")
ProbeURLProtocol.configure { protocolInstance in
let response = URLResponse(
url: protocolInstance.request.url!,
mimeType: "audio/eac3",
expectedContentLength: -1,
textEncodingName: nil
)
protocolInstance.client?.urlProtocol(
protocolInstance,
didReceive: response,
cacheStoragePolicy: .notAllowed
)
protocolInstance.client?.urlProtocol(protocolInstance, didLoad: Data([1, 2, 3, 4]))
protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
requestStarted.fulfill()
}
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [ProbeURLProtocol.self]
let loader = RawEc3Loader(
source: URL(string: "https://probe.test/audio.ec3")!,
finiteLength: finiteLength,
maximumBufferedBytes: 8,
sessionConfiguration: configuration,
terminalHandlerForTesting: { loaderFinished.fulfill() }
)
loader.begin()
wait(for: [requestStarted, loaderFinished], timeout: 2)
let snapshot = loader.statusSnapshot()
XCTAssertTrue(snapshot.isFinished)
XCTAssertEqual(snapshot.bytesReceived, 4)
XCTAssertEqual(snapshot.retainedBytes, 4)
XCTAssertNil(snapshot.errorCode)
loader.cancel()
let cancelled = loader.statusSnapshot()
XCTAssertEqual(cancelled.retainedBytes, 0)
XCTAssertEqual(cancelled.pendingRequestCount, 0)
}
}
func testPipStartWaitsForDelegateAndCompletesOnce() {
let fake = FakePictureInPictureController()
fake.isPictureInPicturePossible = true
+69 -7
View File
@@ -1,6 +1,8 @@
import '../media/media_backend.dart';
import '../media/media_browser_dialect.dart';
import '../models/plex/plex_home_user.dart';
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
@@ -8,22 +10,38 @@ import '../utils/url_utils.dart';
/// (e.g. database column values).
enum ConnectionKind {
plex,
jellyfin;
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,
};
}
@@ -187,7 +205,9 @@ class PlexAccountConnection extends Connection {
}
}
/// A single-server Jellyfin connection.
/// A single-server connection to a MediaBrowser-family server — Jellyfin or its
/// Emby ancestor. [dialect] selects which of the two wire dialects this
/// connection speaks; every other field has the same meaning on both.
class JellyfinConnection extends Connection {
@override
final String id;
@@ -201,10 +221,14 @@ class JellyfinConnection extends Connection {
@override
final DateTime? lastAuthenticatedAt;
/// Which MediaBrowser dialect this server speaks. Drives [kind], [backend]
/// and every route/capability delta in [JellyfinClient].
final MediaBrowserDialect dialect;
/// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
final String baseUrl;
/// Candidate server URLs for this Jellyfin server, with [baseUrl] first.
/// Candidate server URLs for this server, with [baseUrl] first.
/// Existing installs only have [baseUrl]; deserialization backfills this.
final List<String> baseUrls;
@@ -214,7 +238,7 @@ class JellyfinConnection extends Connection {
/// Server's machine identifier (System/Info `Id`).
final String serverMachineId;
/// Authenticated Jellyfin user id (UUID).
/// Authenticated user id. A UUID on Jellyfin, an opaque hex string on Emby.
final String userId;
/// Authenticated user's display name.
@@ -227,11 +251,18 @@ class JellyfinConnection extends Connection {
/// `Authorization: MediaBrowser DeviceId="..."` header).
final String deviceId;
/// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`).
/// Whether this user is a server admin (`/Users/{id}.Policy.IsAdministrator`).
/// Captured at auth time so the UI can gate admin-only entries (delete,
/// match/unmatch, edit metadata) without an extra round-trip.
final bool isAdministrator;
/// The authenticated user's `PrimaryImageTag`, or `null` when they have no
/// profile picture. The server omits the key entirely in that case, and the
/// tag is `MD5(imagePath + lastModified)` so it changes on every upload —
/// which makes the derived avatar URL self-invalidating. Captured at auth
/// time and refreshed by [JellyfinClient.checkHealth].
final String? primaryImageTag;
JellyfinConnection({
required this.id,
required String baseUrl,
@@ -242,7 +273,9 @@ class JellyfinConnection extends Connection {
required this.userName,
required this.accessToken,
required this.deviceId,
this.dialect = MediaBrowserDialect.jellyfin,
this.isAdministrator = false,
this.primaryImageTag,
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
@@ -250,7 +283,7 @@ class JellyfinConnection extends Connection {
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
@override
ConnectionKind get kind => ConnectionKind.jellyfin;
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
@override
String get displayName => '$userName · $serverName';
@@ -297,7 +330,14 @@ class JellyfinConnection extends Connection {
String? userName,
String? accessToken,
String? deviceId,
MediaBrowserDialect? dialect,
bool? isAdministrator,
String? primaryImageTag,
/// Deleting a profile picture drops `PrimaryImageTag` from the user DTO, so
/// 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,
@@ -313,13 +353,18 @@ class JellyfinConnection extends Connection {
userName: userName ?? this.userName,
accessToken: accessToken ?? this.accessToken,
deviceId: deviceId ?? this.deviceId,
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,
);
}
/// The persisted payload deliberately omits [dialect]: the `connections.kind`
/// column is the authoritative, indexed discriminator and
/// [JellyfinConnection.fromConfigJson] receives it from there.
@override
Map<String, Object?> toConfigJson() {
return {
@@ -332,6 +377,7 @@ class JellyfinConnection extends Connection {
'accessToken': accessToken,
'deviceId': deviceId,
'isAdministrator': isAdministrator,
'primaryImageTag': primaryImageTag,
};
}
@@ -341,6 +387,7 @@ class JellyfinConnection extends Connection {
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
final rawBaseUrls = json['baseUrls'];
final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType<String>().toList(growable: false) : const <String>[];
@@ -352,16 +399,31 @@ class JellyfinConnection extends Connection {
id: id,
baseUrl: baseUrl,
baseUrls: baseUrls,
serverName: json['serverName'] as String? ?? 'Jellyfin',
dialect: dialect,
serverName: json['serverName'] as String? ?? dialect.productName,
serverMachineId: json['serverMachineId'] as String? ?? '',
userId: json['userId'] as String? ?? '',
userName: json['userName'] as String? ?? '',
accessToken: json['accessToken'] as String? ?? '',
deviceId: json['deviceId'] as String? ?? '',
isAdministrator: json['isAdministrator'] as bool? ?? false,
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
}
/// Tolerant read of a Jellyfin user DTO's `PrimaryImageTag`.
///
/// The tag is decorative — a fork or a drifted scalar type must never brick
/// sign-in — so this coerces through [readStringField] rather than casting,
/// and collapses absent/blank to `null` ("no picture").
static String? readPrimaryImageTag(Map<String, Object?> userDto) =>
normalizePrimaryImageTag(readStringField(userDto, 'PrimaryImageTag'));
static String? normalizePrimaryImageTag(Object? raw) {
final tag = raw?.toString().trim();
return tag == null || tag.isEmpty ? null : tag;
}
}
+14 -4
View File
@@ -42,14 +42,22 @@ class ConnectionRegistry {
/// 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` (so token/metadata refreshes don't clear
/// the default flag).
/// the row's current `isDefault` and `createdAt` (so token/metadata
/// refreshes don't clear the default flag or 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.
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)
@@ -57,6 +65,7 @@ class ConnectionRegistry {
..limit(1))
.getSingleOrNull();
isDefault = any == null;
createdAt = connection.createdAt.millisecondsSinceEpoch;
}
final protectedConfig = await CredentialVault.protectConnectionConfig(
connection.kind.id,
@@ -68,7 +77,7 @@ class ConnectionRegistry {
displayName: Value(connection.displayName),
configJson: Value(jsonEncode(protectedConfig)),
isDefault: Value(isDefault),
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
createdAt: Value(createdAt),
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.connections).insertOnConflictUpdate(row);
@@ -152,12 +161,13 @@ class ConnectionRegistry {
createdAt: createdAt,
lastAuthenticatedAt: lastAuth,
),
ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson(
ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson(
id: row.id,
json: revealed.config,
status: ConnectionStatus.unknown,
createdAt: createdAt,
lastAuthenticatedAt: lastAuth,
dialect: kind.dialect!,
),
};
if (revealed.migrated) {
+104 -31
View File
@@ -235,7 +235,7 @@ class AppDatabase extends _$AppDatabase {
static bool _containsPlaintextConnectionCredential(String kind, Map<String, dynamic> config) {
bool isPlaintext(Object? value) => value is String && value.isNotEmpty && !CredentialVault.isProtected(value);
if (kind == 'jellyfin') return isPlaintext(config['accessToken']);
if (kind == 'jellyfin' || kind == 'emby') return isPlaintext(config['accessToken']);
if (kind != 'plex') return false;
if (isPlaintext(config['accountToken'])) return true;
final servers = config['servers'];
@@ -882,7 +882,7 @@ class AppDatabase extends _$AppDatabase {
}
/// Insert or update a progress action (merges with existing).
Future<void> upsertProgressAction({
Future<({int rowId, int revision})> upsertProgressAction({
String? profileId,
required ServerId serverId,
String? clientScopeId,
@@ -895,7 +895,7 @@ class AppDatabase extends _$AppDatabase {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
return transaction(() async {
final existing =
await (select(offlineWatchProgress)
..where(
@@ -910,6 +910,12 @@ class AppDatabase extends _$AppDatabase {
final keep = existing.isEmpty ? null : existing.first;
if (keep != null) {
// The row id survives a merge, so its timestamp must advance even
// when multiple playback updates land in one clock millisecond.
final nextRevision = keep.updatedAt + 1;
final revision = now > nextRevision ? now : nextRevision;
// A merge is a new logical action; retry history belongs only to
// the revision whose server write failed.
await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
@@ -917,37 +923,41 @@ class AppDatabase extends _$AppDatabase {
shouldMarkWatched: Value(shouldMarkWatched),
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
updatedAt: Value(now),
updatedAt: Value(revision),
syncAttempts: const Value(0),
lastError: const Value<String?>(null),
),
);
final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false);
if (duplicateIds.isNotEmpty) {
await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go();
}
} else {
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
return (rowId: keep.id, revision: revision);
}
final rowId = await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
return (rowId: rowId, revision: now);
});
});
}
/// Insert a manual watch action (watched or unwatched).
/// Removes conflicting actions for the same item.
Future<void> insertWatchAction({
Future<({int rowId, int revision})> insertWatchAction({
String? profileId,
required ServerId serverId,
String? clientScopeId,
@@ -958,7 +968,7 @@ class AppDatabase extends _$AppDatabase {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
return transaction(() async {
// Remove conflicting actions (opposite action type and progress).
await (delete(offlineWatchProgress)..where(
(t) =>
@@ -968,7 +978,7 @@ class AppDatabase extends _$AppDatabase {
))
.go();
await into(offlineWatchProgress).insert(
final rowId = await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
@@ -980,27 +990,90 @@ class AppDatabase extends _$AppDatabase {
updatedAt: now,
),
);
return (rowId: rowId, revision: now);
});
});
}
/// Delete a specific watch action after successful sync
/// Drop queued `progress` rows for one item, leaving `watched`/`unwatched`
/// rows alone. Returns how many were removed.
///
/// The mirror of the purge [insertWatchAction] performs: a terminal watch
/// state written straight to the server (the online path, which queues
/// nothing) also supersedes any progress still waiting to replay. Without
/// it, [getPendingWatchActions] hands back the older progress row — it
/// orders by `createdAt` — and replaying it rewrites the resume position the
/// mark just cleared, pinning the item to Continue Watching (#1812).
///
/// When [beforeRevision] is present, the notifier is settling a persisted
/// offline mark. Its listener is asynchronous, so only older revisions are
/// stale; an equal or newer progress revision is a genuine concurrent rewatch.
Future<int> deleteQueuedProgressForItem({
String? profileId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
int? beforeRevision,
}) {
return _runPendingMutation(() async {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
return (delete(offlineWatchProgress)..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_nullableTextPredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id) &
(beforeRevision == null ? const Constant(true) : t.updatedAt.isSmallerThanValue(beforeRevision)),
))
.go();
});
}
/// Delete a watch action only if it is still the snapshotted revision.
Future<bool> deleteWatchActionIfUnchanged(int id, int revision) {
return _runPendingMutation(() async {
final deleted = await (delete(
offlineWatchProgress,
)..where((t) => t.id.equals(id) & t.updatedAt.equals(revision))).go();
return deleted != 0;
});
}
/// Update the retry state only if the action is still the snapshotted revision.
Future<bool> updateSyncAttemptIfUnchanged(int id, int revision, String? errorMessage) {
return _runPendingMutation(() async {
final existing = await (select(
offlineWatchProgress,
)..where((t) => t.id.equals(id) & t.updatedAt.equals(revision))).getSingleOrNull();
if (existing == null) return false;
final updated = await (update(offlineWatchProgress)..where((t) => t.id.equals(id) & t.updatedAt.equals(revision)))
.write(
OfflineWatchProgressCompanion(
syncAttempts: Value(existing.syncAttempts + 1),
lastError: Value(errorMessage),
),
);
return updated != 0;
});
}
/// 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 sync attempt count and error message
Future<void> updateSyncAttempt(int id, String? errorMessage) async {
/// 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;
if (existing != null) {
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
);
}
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
);
});
}
+1 -1
View File
@@ -4497,7 +4497,7 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
/// (one per account); for Jellyfin it's the server's machineId.
final String id;
/// Backend kind: `'plex'` or `'jellyfin'`.
/// Backend kind: `'plex'`, `'jellyfin'`, or `'emby'`.
final String kind;
/// User-visible label (account email, server name).
+37 -26
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart';
import '../media/media_backend.dart';
import 'app_database.dart';
import '../models/download_models.dart';
@@ -202,22 +203,30 @@ extension DownloadDatabaseOperations on AppDatabase {
final connectionRows = await select(connections).get();
final connectionIds = connectionRows.map((row) => row.id).toSet();
final connectionKindsById = {for (final row in connectionRows) row.id: row.kind};
final jellyfinIdentities = <String, ({String machineId, String? userId})>{};
final jellyfinMachineIds = <String>{};
for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) {
final identity = _jellyfinConnectionIdentity(connection);
jellyfinIdentities[connection.id] = identity;
jellyfinMachineIds.add(identity.machineId);
final mediaBrowserIdentities = <String, ({String machineId, String? userId, String backendId})>{};
final mediaBrowserMachineIds = <String>{};
// `Connections.kind` is the authoritative dialect discriminator; both
// MediaBrowser kinds use the same compound machine/user scope shape.
for (final connection in connectionRows.where(
(row) => row.kind == MediaBackend.jellyfin.id || row.kind == MediaBackend.emby.id,
)) {
final identity = _mediaBrowserConnectionIdentity(connection);
mediaBrowserIdentities[connection.id] = (
machineId: identity.machineId,
userId: identity.userId,
backendId: connection.kind,
);
mediaBrowserMachineIds.add(identity.machineId);
}
final jellyfinScopesByProfileAndMachine = <String, Map<String, Set<String>>>{};
final mediaBrowserScopesByProfileAndMachine = <String, Map<String, Set<({String scopeId, String backendId})>>>{};
for (final binding in await select(profileConnections).get()) {
if (binding.userIdentifier.isEmpty) continue;
final identity = jellyfinIdentities[binding.connectionId];
final identity = mediaBrowserIdentities[binding.connectionId];
if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue;
jellyfinScopesByProfileAndMachine
.putIfAbsent(binding.profileId, () => <String, Set<String>>{})
.putIfAbsent(identity.machineId, () => <String>{})
.add('${identity.machineId}/${binding.userIdentifier}');
mediaBrowserScopesByProfileAndMachine
.putIfAbsent(binding.profileId, () => <String, Set<({String scopeId, String backendId})>>{})
.putIfAbsent(identity.machineId, () => <({String scopeId, String backendId})>{})
.add((scopeId: '${identity.machineId}/${binding.userIdentifier}', backendId: identity.backendId));
}
final ownedKeys = <String>{
for (final owner in owners)
@@ -239,35 +248,37 @@ extension DownloadDatabaseOperations on AppDatabase {
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: 'plex',
backendId: MediaBackend.plex.id,
clientScopeId: scopeId,
);
continue;
}
final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const <String>{};
if (jellyfinScopes.length == 1) {
final adoptingScope = jellyfinScopes.single;
final mediaBrowserScopes =
mediaBrowserScopesByProfileAndMachine[profileId]?[row.serverId] ??
const <({String scopeId, String backendId})>{};
if (mediaBrowserScopes.length == 1) {
final adopting = mediaBrowserScopes.single;
await transaction(() async {
if (isStillActive != null && !isStillActive()) return;
await updateDownloadedMediaClientScope(row.globalKey, adoptingScope);
await updateDownloadedMediaClientScope(row.globalKey, adopting.scopeId);
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: 'jellyfin',
clientScopeId: adoptingScope,
backendId: adopting.backendId,
clientScopeId: adopting.scopeId,
);
});
continue;
}
// A compound non-Plex scope is a legacy Jellyfin user namespace.
// A compound non-Plex scope is a legacy MediaBrowser user namespace.
// Never attach it to another profile unless that profile has exactly
// one matching Jellyfin binding. The same applies when persisted
// Jellyfin connections identify the machine but the profile has zero
// or multiple possible users.
final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false;
if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue;
// one matching MediaBrowser binding. The same applies when persisted
// MediaBrowser connections identify the machine but the profile has
// zero or multiple possible users.
final hasLegacyMediaBrowserScope = scopeId?.startsWith('${row.serverId}/') ?? false;
if (hasLegacyMediaBrowserScope || mediaBrowserMachineIds.contains(row.serverId)) continue;
final backendId = connectionKindsById[scopeId];
await addDownloadOwner(
@@ -694,7 +705,7 @@ bool _isValidDownloadOwner(
return localProfileIds.isEmpty;
}
({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) {
({String machineId, String? userId}) _mediaBrowserConnectionIdentity(ConnectionRow connection) {
final separator = connection.id.indexOf('/');
var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator);
String? userId = separator < 0 || separator == connection.id.length - 1
+3 -3
View File
@@ -131,8 +131,8 @@ class SyncRuleDownloads extends Table {
/// Persisted media-server connections.
///
/// One row per "connection" the user has added — a Plex account (with its
/// discovered servers and active Home profile) or a single Jellyfin server.
/// The [configJson] payload is backend-specific and parsed by the
/// discovered servers and active Home profile) or a single MediaBrowser
/// server/user. The [configJson] payload is backend-specific and parsed by the
/// [Connection] sealed class.
@DataClassName('ConnectionRow')
@TableIndex(name: 'idx_connections_kind', columns: {#kind})
@@ -141,7 +141,7 @@ class Connections extends Table {
/// (one per account); for Jellyfin it's the server's machineId.
TextColumn get id => text()();
/// Backend kind: `'plex'` or `'jellyfin'`.
/// Backend kind: `'plex'`, `'jellyfin'`, or `'emby'`.
TextColumn get kind => text()();
/// User-visible label (account email, server name).
+308
View File
@@ -0,0 +1,308 @@
// Measurement harness entrypoint. NOT part of the app.
//
// Drives the real PlayerLinux/mpv/Video rendering path with a local file so
// the compositing cost can be measured without a Plex server. Build with:
// flutter build linux --target=lib/dev/harness_main.dart
// Run with:
// PLEZY_HARNESS_MEDIA=/path/to/file.mp4 PLEZY_HARNESS_SECONDS=40 ./plezy
//
// Optional knobs:
// PLEZY_HARNESS_MPV_LOG=v|debug mpv's own log stream
// PLEZY_HARNESS_MPV_PROPS=name=value,... arbitrary mpv properties, so an
// option can be swept without a
// rebuild for each value
// PLEZY_HARNESS_HDR=1 request HDR passthrough
// PLEZY_HARNESS_TONEMAP=compositor|player which side tone-maps; the A/B leg
// PLEZY_HARNESS_INSET=<px> toggles a padding every 6s, so the
// plane has to move, not just resize
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../mpv/models.dart';
import '../mpv/player/player.dart';
import '../mpv/video.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const _HarnessApp());
}
class _HarnessApp extends StatefulWidget {
const _HarnessApp();
@override
State<_HarnessApp> createState() => _HarnessAppState();
}
class _HarnessAppState extends State<_HarnessApp> {
Player? _player;
String _status = 'starting';
int _frames = 0;
int _buildUs = 0;
int _rasterUs = 0;
final Stopwatch _clock = Stopwatch()..start();
Timer? _reportTimer;
Timer? _probeTimer;
Timer? _quitTimer;
Timer? _insetTimer;
double _inset = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onFrames);
_reportTimer = Timer.periodic(const Duration(seconds: 2), (_) => _report());
_probeTimer = Timer.periodic(const Duration(seconds: 2), (_) => _probe());
final seconds = int.tryParse(Platform.environment['PLEZY_HARNESS_SECONDS'] ?? '');
if (seconds != null && seconds > 0) {
_quitTimer = Timer(Duration(seconds: seconds), () {
_report();
stdout.writeln('HARNESS_DONE');
exit(0);
});
}
final inset = double.tryParse(Platform.environment['PLEZY_HARNESS_INSET'] ?? '');
if (inset != null && inset > 0) {
_insetTimer = Timer.periodic(const Duration(seconds: 6), (_) {
setState(() => _inset = _inset == 0 ? inset : 0);
stdout.writeln('HARNESS_INSET $_inset');
});
}
_start();
}
void _onFrames(List<FrameTiming> timings) {
for (final t in timings) {
_frames++;
_buildUs += t.buildDuration.inMicroseconds;
_rasterUs += t.rasterDuration.inMicroseconds;
}
}
void _report() {
final n = _frames;
final secs = _clock.elapsedMilliseconds / 1000.0;
final fps = secs > 0 ? n / secs : 0.0;
final build = n > 0 ? (_buildUs / n / 1000.0) : 0.0;
final raster = n > 0 ? (_rasterUs / n / 1000.0) : 0.0;
stdout.writeln(
'FRAMESTAT t=${secs.toStringAsFixed(1)} frames=$n '
'fps=${fps.toStringAsFixed(2)} build_ms=${build.toStringAsFixed(2)} '
'raster_ms=${raster.toStringAsFixed(2)}',
);
_frames = 0;
_buildUs = 0;
_rasterUs = 0;
_clock.reset();
}
// Independent of Flutter's frame loop: tells us whether mpv is actually
// advancing even when nothing is being composited.
Future<void> _probe() async {
final player = _player;
if (player == null) return;
try {
final pos = await player.getProperty('time-pos');
final paused = await player.getProperty('pause');
final dropped = await player.getProperty('frame-drop-count');
final decoded = await player.getProperty('decoder-frame-drop-count');
stdout.writeln('PROBE time-pos=$pos pause=$paused drops=$dropped dec_drops=$decoded');
} catch (e) {
stdout.writeln('PROBE_ERROR $e');
}
}
// Which side tone-maps, and against what peak, is decided by mpv options that
// leave no trace on screen: two very different curves look like "the video
// plane works". Reading the effective values back is the only way to tell a
// deliberate target from a default nobody chose - and the render API cannot
// discover the display for itself the way a windowed mpv does, so the answer
// here is not the answer `mpv` alone would give.
//
// Read after open() because the source-dependent ones are unset until a
// format is known.
Future<void> _reportColourState(Player player, String when) async {
const names = <String>[
'target-peak',
'target-trc',
'target-prim',
'tone-mapping',
'hdr-compute-peak',
'video-params/gamma',
'video-params/primaries',
'video-params/sig-peak',
'video-params/max-luma',
];
final parts = <String>[];
for (final name in names) {
try {
parts.add('$name=${await player.getProperty(name) ?? "-"}');
} catch (_) {
// The message is dropped rather than interpolated: this line is parsed
// as space-separated name=value pairs, and an exception string carries
// spaces of its own.
parts.add('$name=<error>');
}
}
stdout.writeln('HARNESS_COLOUR[$when] ${parts.join(' ')}');
}
// build() only shows _status while there is no player, and by this point one
// has usually been assigned. Without clearing it a deliberate abort renders as
// an empty transparent window, i.e. indistinguishable from a hang.
Future<void> _abort(Player? player, String status) async {
await player?.dispose();
if (!mounted) return;
setState(() {
_player = null;
_status = status;
});
}
Future<void> _start() async {
final media = Platform.environment['PLEZY_HARNESS_MEDIA'];
if (media == null || media.isEmpty) {
setState(() => _status = 'set PLEZY_HARNESS_MEDIA');
return;
}
final uri = media.startsWith('/') ? 'file://$media' : media;
try {
final player = Player();
setState(() => _player = player);
final level = Platform.environment['PLEZY_HARNESS_MPV_LOG'];
if (level != null && level.isNotEmpty) {
await player.setLogLevel(level);
stdout.writeln('HARNESS_MPV_LOG $level');
}
// Example: PLEZY_HARNESS_MPV_PROPS='tone-mapping=mobius,target-peak=200'.
// Names the plugin intercepts (hdr-enabled, hdr-tone-mapping) also have
// dedicated knobs, and those are applied *after* this block, so the
// dedicated one wins if both name the same thing. Both writes are logged
// and HARNESS_COLOUR[settled] reads the effective state back, so a capture
// cannot be silently mislabelled either way.
//
// Applied before open(), so the first configured frame already has them.
//
// That timing is also the catch, and it has already produced a wrong
// answer: the native side re-applies the whole output description on
// playback-restart and on every seek, so anything it manages -
// target-peak, target-prim, target-trc, tone-mapping - is overwritten
// moments later. A sweep of those reads back as the shipped value while
// looking perfectly plausible. Use this for properties the runner does not
// set itself; for the ones it does, change the code.
//
// A malformed or rejected override aborts the leg, for the same reason
// PLEZY_HARNESS_TONEMAP does: these captures get labelled with the value
// that was asked for, and carrying on would file the default curve under
// whatever was requested. A silently wrong label is worse than no capture.
final props = Platform.environment['PLEZY_HARNESS_MPV_PROPS'];
if (props != null && props.isNotEmpty) {
for (final pair in props.split(',')) {
final split = pair.indexOf('=');
final name = split > 0 ? pair.substring(0, split).trim() : '';
final value = split > 0 ? pair.substring(split + 1).trim() : '';
String? failure;
if (name.isEmpty || value.isEmpty) {
failure = 'not name=value';
} else {
try {
await player.setProperty(name, value);
stdout.writeln('HARNESS_MPV_PROP $name=$value');
} catch (e) {
failure = '$e';
}
}
if (failure != null) {
stdout.writeln('HARNESS_MPV_PROP_ERROR $pair: $failure');
await _abort(player, 'bad PLEZY_HARNESS_MPV_PROPS: $pair');
return;
}
}
}
// Selected before hdr-enabled so the first description built already uses
// the requested mode; the native side re-applies either way.
//
// A rejected value aborts instead of carrying on. This harness exists to
// produce A/B photographs, and continuing in whatever mode happened to be
// active would label the result with a leg that was never shown.
final toneMapping = Platform.environment['PLEZY_HARNESS_TONEMAP'];
if (toneMapping != null && toneMapping.isNotEmpty) {
try {
await player.setProperty('hdr-tone-mapping', toneMapping);
stdout.writeln('HARNESS_TONEMAP $toneMapping');
} catch (e) {
stdout.writeln('HARNESS_TONEMAP_ERROR $e');
await _abort(player, 'bad PLEZY_HARNESS_TONEMAP: $toneMapping');
return;
}
}
if (Platform.environment['PLEZY_HARNESS_HDR'] == '1') {
try {
await player.setProperty('hdr-enabled', 'yes');
stdout.writeln('HARNESS_HDR requested');
} catch (e) {
stdout.writeln('HARNESS_HDR_ERROR $e');
}
}
await player.open(Media(uri));
stdout.writeln('HARNESS_OPENED $uri');
// Twice, and the second one is the one that means anything. The native
// side applies the output description from playback-restart, which has
// not fired yet: reading only here reports the pre-HDR defaults and makes
// a working transaction look like it never ran. The delayed read is the
// independent check that mpv actually holds what the transaction logged
// asking for - our own log says what was requested, not what landed.
await _reportColourState(player, 'open');
Timer(const Duration(seconds: 6), () async {
if (!mounted || _player != player) return;
await _reportColourState(player, 'settled');
});
} catch (e, st) {
stdout.writeln('HARNESS_ERROR $e\n$st');
await _abort(_player, 'error: $e');
}
}
@override
void dispose() {
_reportTimer?.cancel();
_probeTimer?.cancel();
_quitTimer?.cancel();
_insetTimer?.cancel();
SchedulerBinding.instance.removeTimingsCallback(_onFrames);
// Reported, not ignored: this harness exists to say what mpv did, and a
// teardown that failed is part of that.
if (_player case final player?) {
unawaited(player.dispose().catchError((Object e) => stdout.writeln('HARNESS_DISPOSE_FAILED $e')));
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final player = _player;
return MaterialApp(
debugShowCheckedModeBanner: false,
// Transparent all the way down: in plane mode the video is a Wayland
// subsurface *below* this surface, so anything opaque here hides it.
home: Scaffold(
backgroundColor: Colors.transparent,
body: player == null
? Center(
child: Text(_status, style: const TextStyle(color: Colors.white)),
)
: Padding(
// The inset moves the plane to a non-zero origin, which a plain
// window resize would never exercise.
padding: EdgeInsets.all(_inset),
child: Video(player: player, backgroundColor: Colors.transparent),
),
),
);
}
}
+9 -1
View File
@@ -53,7 +53,15 @@ extension DpadKeyExtension on LogicalKeyboardKey {
bool get isBackKey => _backKeys.contains(this);
bool get isContextMenuKey => _contextMenuKeys.contains(this);
bool get isNavigationKey =>
/// Whether this key is a shell / remote control key rather than a text
/// character — D-pad direction, select, back, context menu, or Tab.
///
/// Use it to decide "is this a printable character?" and "must this route
/// consume the key so it cannot leak to the route below?". It is NOT evidence
/// that the viewer wants to navigate by focus — `eventRequestsFocusNavigation`
/// in focus_navigation_intent.dart answers that, and conflating the two is what
/// made a plain Enter switch the whole app into keyboard mode.
bool get isReservedControlKey =>
isDpadDirection || isSelectKey || isBackKey || isContextMenuKey || this == LogicalKeyboardKey.tab;
bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft;
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// A [FocusNode] whose owner consumes arrow / D-pad keys itself instead of
/// letting them traverse focus — the video player seeks with them.
///
/// Focus-mode detection needs this fact: an arrow pressed while such a node
/// holds focus is not evidence that the viewer wants to navigate by focus, so
/// it must not switch the app into keyboard mode and light up focus chrome
/// everywhere.
///
/// The fact rides on the node rather than on a subtree because it depends on
/// *what has focus*, not on where a widget sits. Every ordinary control, sheet,
/// prompt and OSD button therefore stays a plain [FocusNode] and keeps working.
///
/// Declare a node only when it can hold primary focus **without the viewer
/// having navigated to it** — autofocus, a focus reclaim, a self-heal.
/// Everything reached by traversal is already in keyboard mode, so the marker
/// would be redundant there: sliders, spinners, the TV keyboard and the OSD's
/// own buttons all consume arrows and all correctly stay plain nodes.
class DirectionalShortcutFocusNode extends FocusNode {
DirectionalShortcutFocusNode({required this.consumesDirectionalKeys, super.debugLabel, super.skipTraversal});
/// Evaluated per key press, so live settings and chrome state need no
/// syncing. Takes the key because a feature can own one axis and not the
/// other — the player seeks with Left/Right while Up/Down raises the chrome.
final bool Function(LogicalKeyboardKey key) consumesDirectionalKeys;
static bool ownsDirectionalKeys(FocusNode? node, LogicalKeyboardKey key) =>
node is DirectionalShortcutFocusNode && node.consumesDirectionalKeys(key);
}
/// Whether [event] is evidence that the viewer wants to navigate by focus.
///
/// This is the single answer to two questions that must never disagree:
/// whether [InputModeTracker] switches to keyboard mode, and whether a key may
/// hand focus to the video player's chrome. Deciding them separately is what
/// produced focus appearing on a control while the app still believed a pointer
/// was driving — focus chrome is mode-gated, so the viewer got an invisible
/// selection.
///
/// Activation (Enter) and dismissal (Escape) from a physical keyboard are *not*
/// navigation: they act on whatever already has focus. Promoting on them arms
/// focus chrome — and hides the desktop cursor — for a viewer who only pressed
/// play.
///
/// [focused] is the node that will receive the event; it defaults to the
/// primary focus. Handlers inside a `Focus.onKeyEvent` pass their own node
/// instead of re-reading the global.
bool eventRequestsFocusNavigation(KeyEvent event, {FocusNode? focused}) {
if (event is! KeyDownEvent) return false;
final key = event.logicalKey;
// Unambiguous traversal on every platform.
if (key == LogicalKeyboardKey.tab) return true;
// Opens a menu that takes focus into a new scope, so it does start a session.
if (key.isContextMenuKey) return true;
// `select` / `gameButtonA` are remote-only whatever the engine claims about
// deviceType; `enter` counts from a non-keyboard device, or on TV where a
// remote's OK legitimately arrives as a keyboard `enter` (the same shape
// pin_entry_dialog.dart already compensates for).
if (key.isSelectKey) {
return event.isTvSelectEvent || (PlatformDetector.isTV() && event.isPhysicalKeyboardEnter);
}
// Remote BACK proves a pointerless device. A physical keyboard's `escape` and
// a media keyboard's `browserBack` only dismiss, so they stay out — matching
// how classifyPlayerNavigationKey already reads a non-keyboard `escape`.
if (key.isBackKey) {
return key == LogicalKeyboardKey.goBack || key == LogicalKeyboardKey.gameButtonB || !event.isPhysicalKeyboardEvent;
}
// Arrows are navigation only where they are not the focused feature's own
// shortcut.
if (key.isDpadDirection) {
return !DirectionalShortcutFocusNode.ownsDirectionalKeys(focused ?? FocusManager.instance.primaryFocus, key);
}
return false;
}
+72 -29
View File
@@ -12,8 +12,10 @@ import 'key_event_utils.dart';
import 'owned_focus_node_binding.dart';
enum TvTextInputPresentation {
/// Use the native platform keyboard for single-line Apple TV input and the
/// Flutter overlay on other TVs or for multiline input.
/// Use the native platform keyboard wherever it can host the field: always
/// on Android TV (its docked IME handles multiline input), and for
/// single-line input on Apple TV, whose modal fullscreen keyboard cannot
/// edit multiline text — that falls back to the Flutter overlay.
automatic,
/// Always use the platform text input implementation.
@@ -27,7 +29,7 @@ bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputTy
if (!PlatformDetector.isTV()) return false;
return switch (presentation) {
TvTextInputPresentation.automatic =>
!PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
PlatformDetector.isAppleTV() && _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
TvTextInputPresentation.platform => false,
TvTextInputPresentation.flutterOverlay => true,
};
@@ -69,8 +71,8 @@ enum TvTextInputAutoOpenBehavior {
/// This is the one documented exception to the `automatic` rule that a field's
/// first focus opens text input — the URL field's first focus is the screen's
/// own `autofocus`, not the user arriving. Apple TV therefore waits for an
/// explicit Select; the in-app overlay is cheap enough to open on a deliberate
/// return.
/// explicit Select; Android's docked IME is cheap enough to open on a
/// deliberate return.
TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen =>
PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus;
@@ -310,19 +312,23 @@ bool _shouldPassNativeTvKeyToPlatform({
required bool enabled,
required KeyEvent event,
}) {
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isAppleTV()) {
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
'isAppleTV=${PlatformDetector.isAppleTV()} key=(${_describeTextInputKey(event)})',
);
}
return false;
}
// Android TV provides its own IME. Remote keys must reach the platform so
// users can move around that keyboard instead of escaping the app field.
// tvOS only: the custom engine routes remote keys through Flutter even
// while UIKit text input is live, so they must be skipped back to the
// platform to drive the system keyboard. Android needs no such pass — the
// IME sees hardware keys *before* the app (ImeInputStage), so a navigation
// key arriving here was already declined by the IME and must keep its
// local caret/traversal semantics (see the host's Android branch).
// Some remotes (Chromecast) are reported by Flutter as keyboard events, so
// native TV navigation cannot rely on deviceType.
final key = event.logicalKey;
@@ -393,7 +399,7 @@ KeyEventResult _handleTvHardwareKeyboardKey({
}
final character = event.character;
if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) {
if (character != null && character.isNotEmpty && !key.isReservedControlKey && !_isControlCharacter(character)) {
_insertText(
controller: controller,
text: character,
@@ -864,8 +870,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void _restoreFocusAfterPlatformDismissal() {
final node = _installedFocusNode;
if (node == null || node.hasFocus || !_nativeTextInputActivated) return;
// Apple TV only: Android TV's IME close keeps its historical semantics,
// and no production field selects the native path there anyway.
// Apple TV only: connectionClosed-driven unfocus is a behavior of the
// custom tvOS engine. Android's IME hide keeps the field focused and the
// connection alive, so there is nothing to restore there.
if (!PlatformDetector.isAppleTV()) return;
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
final scope = node.enclosingScope;
@@ -978,12 +985,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
}
void _syncNativeTextInputFocus() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
// Activation-based, not focus-based: the platform hint pauses the gamepad
// bridge and defers the pre-IME D-pad intercept to the IME, and it arms
// MainActivity's soft-input show-retry/repair session — all of which must
// track a *live* text input session, not a merely focused (read-only
// gated) field. A dismissed keyboard therefore hands D-pad routing back
// to the app immediately.
final focused =
_installedFocusNode?.hasFocus == true &&
widget.input.enabled &&
widget.input._usesNativeTvKeyboard &&
_nativeTextInputActivated;
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard} activated=$_nativeTextInputActivated',
);
}
_setNativeTextInputFocused(focused);
@@ -1172,30 +1189,56 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
}
var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
if (PlatformDetector.isAppleTV() &&
widget.input._usesNativeTvKeyboard &&
if (widget.input._usesNativeTvKeyboard &&
_nativeTextInputActivated &&
event is KeyDownEvent &&
isRemoteNavigation) {
// Remote navigation events are system-owned while the native keyboard
// is active. Receiving one here proves that UIKit has dismissed the
// keyboard while Flutter focus stayed on the field. Restore the
// read-only gate so this press navigates Flutter instead of reopening
// the input connection.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
activateNativeTextInput = true;
if (event.logicalKey.isBackKey) {
// This is the Menu press that dismissed UIKit's keyboard. Consume its
// Flutter continuation so one press cannot also pop the app route.
if (PlatformDetector.isAppleTV()) {
// Remote navigation events are system-owned while the native keyboard
// is active. Receiving one here proves that UIKit has dismissed the
// keyboard while Flutter focus stayed on the field. Restore the
// read-only gate so this press navigates Flutter instead of reopening
// the input connection.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
activateNativeTextInput = true;
if (event.logicalKey.isBackKey) {
// This is the Menu press that dismissed UIKit's keyboard. Consume its
// Flutter continuation so one press cannot also pop the app route.
return KeyEventResult.handled;
}
if (event.isTvSelectEvent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
return KeyEventResult.handled;
}
} else if (event.logicalKey.isBackKey) {
// Android: a healthy IME consumes Back to dismiss itself before the
// app ever sees it. One arriving here means the keyboard is already
// gone (or its key session is broken and MainActivity's repair budget
// ran out): close the session and consume the press so it cannot also
// pop the route underneath.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
return KeyEventResult.handled;
}
if (event.isTvSelectEvent) {
} else if (event.isTvSelectEvent) {
// Android: Select on a field whose keyboard was dismissed re-raises
// it (EditText parity). Toggle the connection so the engine issues a
// fresh TextInput.show; MainActivity's show-retry covers the
// served-view race (#1051/#1079).
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
return KeyEventResult.handled;
}
// Android arrows fall through deliberately: a healthy visible IME
// consumes them before the app, and leaked ones are repaired and eaten
// by MainActivity — so an arrow reaching this handler is real caret or
// traversal input (BT keyboards included) and keeps the caret-aware
// edge-escape semantics below.
}
return widget.input._handleKey(
context,
+26 -13
View File
@@ -4,9 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import '../services/gamepad_service.dart';
import 'dpad_navigator.dart';
import '../services/companion_remote/companion_remote_receiver.dart';
import 'focus_navigation_intent.dart';
/// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch).
///
@@ -55,6 +54,21 @@ class InputModeTracker extends StatefulWidget {
return Platform.isAndroid && isKeyboardMode(context);
}
/// Report input from a device that cannot point — gamepad, companion remote,
/// Siri Remote touch. Those services synthesize their key events through
/// [KeyEventSimulatorController], which dispatches straight down the focus
/// chain and never reaches [HardwareKeyboard], so they cannot be observed by
/// [eventRequestsFocusNavigation] and must announce themselves here.
///
/// Calls `setState`, so never call this during build. A no-op when no tracker
/// is mounted.
static void reportNonPointerInput() {
final state = _instance;
if (state != null && state.mounted) state._setMode(InputMode.keyboard);
}
static _InputModeTrackerState? _instance;
@override
State<InputModeTracker> createState() => _InputModeTrackerState();
}
@@ -66,23 +80,21 @@ class _InputModeTrackerState extends State<InputModeTracker> {
@override
void initState() {
super.initState();
// Published before anything can report input. The outgoing tracker of a
// subtree swap disposes *after* the incoming one initialises, so teardown
// is identity-guarded — otherwise startup's bootstrap→app swap would leave
// the live registration cleared.
InputModeTracker._instance = this;
// Initialize focus highlight strategy based on starting mode
_updateFocusHighlightStrategy(_mode);
// Listen to hardware keyboard events globally
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
// Register callback for gamepad input to switch to keyboard mode
GamepadService.onGamepadInput = () => _setMode(InputMode.keyboard);
// Register callback for companion remote input to switch to keyboard mode
CompanionRemoteReceiver.onRemoteInput = () => _setMode(InputMode.keyboard);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
GamepadService.onGamepadInput = null;
CompanionRemoteReceiver.onRemoteInput = null;
if (identical(InputModeTracker._instance, this)) InputModeTracker._instance = null;
super.dispose();
}
@@ -91,9 +103,10 @@ class _InputModeTrackerState extends State<InputModeTracker> {
// events after route pops (see BackKeySuppressorObserver).
BackKeyPressTracker.handleKeyEvent(event);
// Only switch to keyboard mode on navigation key down (not repeats, releases,
// or non-navigation keys like volume buttons or letter keys while typing)
if (event is KeyDownEvent && event.logicalKey.isNavigationKey) {
// Only a key that asks to navigate by focus starts a keyboard session.
// Activation and dismissal act on what is already focused, so promoting on
// them would arm focus chrome for a viewer who never asked to navigate.
if (eventRequestsFocusNavigation(event)) {
_setMode(InputMode.keyboard);
}
// Return false to let the event continue propagating
+17
View File
@@ -123,6 +123,23 @@ KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
return KeyEventResult.handled;
}
/// Whether the primary focus currently belongs to an active text editor.
///
/// Ancestor key handlers use this to stay off keys a focused field owns.
/// Flutter dispatches a key event from the focused node upwards, and the
/// editing shortcuts that turn Backspace into a deletion live in
/// [DefaultTextEditingShortcuts] at the very top of the app — *above* any
/// screen. An ancestor that claims Backspace as "back" therefore both steals
/// the navigation and stops the character from ever being deleted (#1741).
///
/// [EditableText] builds its [Focus] internally, so the focused node's context
/// resolves to the owning [EditableTextState].
bool isTextEditingFocused() {
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return false;
return context.findAncestorStateOfType<EditableTextState>() != null;
}
/// Expands a UTF-16 [range] to whole extended grapheme clusters in [text].
///
/// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a
+326 -287
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.",
"useBrowser": "Səyahətçini istifadə et",
"or": "və ya",
"connectToJellyfin": "Jellyfin-ə qoşul",
"connectToMediaBrowser": "${product}-a qoşul",
"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…",
@@ -64,7 +64,20 @@
"loading": "Yüklənir...",
"fullscreen": "Tam ekran",
"exitFullscreen": "Tam ekrandan çıx",
"pressBackAgainToExit": "Çıxmaq üçün geri düyməsinə bir daha basın"
"pressBackAgainToExit": "Çıxmaq üçün geri düyməsinə bir daha basın",
"ratingSource": {
"critic": "Tənqidçilər",
"audience": "Tamaşaçılar",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes tənqidçiləri",
"rottenTomatoesAudience": "Rotten Tomatoes tamaşaçıları"
}
},
"screens": {
"licenses": "Lisenziyalar",
@@ -107,6 +120,7 @@
"darkTheme": "Tünd",
"oledTheme": "OLED",
"libraryDensity": "Kitabxana sıxlığı",
"displayScale": "Ekran miqyası",
"compact": "Sıx",
"comfortable": "Rəhat",
"tvCornerSpotlightBackdrop": "Künc işıqlandırma fonu",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Musiqi keyfiyyəti",
"subtitleStyling": "Altyazı tənzimləmələri",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Çevirmədə səsi normallaşdır",
"audioDownmixNormalizeDescription": "Səs kəsilmələrinin qarşısını almaq üçün səviyyəni endirin.",
"atmosDiagnostics": "Atmos çıxış testi",
"atmosDiagnosticsDescription": "Sistem oynadıcısı vasitəsilə test siqnalları çalan Dolby Atmos çıxışını yoxlayın",
"atmosTestHlsAtmos": "Apple Atmos axını",
"atmosTestHlsAtmosDescription": "Düzgün işlədiyi məlum olan Dolby Atmos axını. Qəbuledici Dolby Atmos göstərməlidir.",
"atmosTestHlsControl": "Apple əhatəli səs axını",
"atmosTestHlsControlDescription": "Atmos olmayan idarəetmə axını.",
"atmosTestRawStream": "Xam EAC3 axını",
"atmosTestRawStreamDescription": "Test faylını eynilə oynadıcı daxili Atmos kimi yayımlayır.",
"atmosTestRawFile": "Xam EAC3 faylı",
"atmosTestRawFileDescription": "Məlum uzunluqda test faylını oynadır.",
"atmosTestAsbarNative": "Nümunə bufer rendereri (daxili)",
"atmosTestAsbarNativeDescription": "Faylın dəyişdirilməmiş sıxılmış səsini birbaşa sistem rendererinə ötürür. Test faylının URL-i tələb olunur.",
"atmosTestAsbarGenerated": "Nümunə bufer rendereri (yenidən qurulmuş)",
"atmosTestAsbarGeneratedDescription": "Eyni, lakin səs təsviri oynatmanın qurduğu kimi yenidən qurulur. Test faylının URL-i tələb olunur.",
"atmosTestSessionMode": "Film oynatma seansı rejimindən istifadə et",
"atmosTestSessionModeDescription": "Söndürüldükdə Dolby-nin sənədləşdirdiyi rejim işlədilir. Yandırıldıqda oynatmanın əvvəl istifadə etdiyi rejim işlədilir.",
"atmosTestShowRoutePicker": "AirPlay çıxışını seç",
"atmosTestHideRoutePicker": "AirPlay çıxış seçicisini gizlət",
"atmosTestRoutePickerDescription": "Testi AirPlay qəbuledicisinə göndərir. Müəyyən edilmiş səs rejimini yalnız AirPlay bildirir.",
"atmosTestStop": "Testi saxla",
"atmosTestUrl": "Test faylı URL-i",
"atmosTestUrlDescription": "Xam .ec3 Dolby Atmos faylının HTTP URL-i",
"atmosTestUrlMissing": "Əvvəlcə test faylı URL-ini təyin edin",
"atmosTestStatus": "Status",
"dvConversionMode": "Dolby Vision çevrilməsi",
"dvConversionModeDescription": "ExoPlayer-in Dolby Vision Profile 7 fayllarını necə emal edəcəyini seçin.",
"dvConversionAuto": "Avtomatik",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Naviqasiya paneli yazılarını göstər",
"showNavBarLabelsDescription": "Naviqasiya paneli ikonlarının altında mətni göstər",
"startupSection": "Başlanğıc bölməsi",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Canlı TV-də sevimli kanalları defolt et",
"liveTvDefaultFavoritesDescription": "Canlı TV açıldıqda yalnız sevimli kanalları göstər",
"display": "Ekran",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Fayl məlumatı",
"overview": "",
"overview": "Ümumi baxış",
"video": "Video",
"audio": "Səs",
"subtitles": "Altyazılar",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Daxili şəkillər",
"dataStreams": "Məlumat axınları",
"lyrics": "Mahnı sözləri",
"file": "Fayl",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Əlavələr",
"delivery": "Çatdırılma",
"versionCounter": "${count}-dən ${index} versiya",
"fileCounter": "${count}-dən ${index} fayl",
"noStreams": "Server bu fayl üçün heç bir axın bildirmədi.",
"copyPath": "Yolu kopyala",
"pathCopied": "Fayl yolu kopyalandı",
"codec": "Kodek",
"codecTag": "",
"codecTag": "Kodek teqi",
"resolution": "Ayırdetmə",
"codedResolution": "",
"codedResolution": "Kodlanmış rezolyusiya",
"bitrate": "Bit sürəti (Bitrate)",
"frameRate": "Kadr tezliyi",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Dönmə",
"comment": "Şərh",
"audioDescription": "Səs təsviri",
"headerCompression": "Başlıq sıxışdırması",
"sidecarFile": "Sidecar faylı",
"transportTimestamp": "Transport vaxt damğası",
"displayOffset": "Ekran ofseti",
"previewFailureCode": "Önizləmə xəta kodu",
"previewRetries": "Önizləmə cəhdləri",
"aspectRatio": "Tərəf nisbəti",
"pixelAspectRatio": "",
"pixelAspectRatio": "Piksel aspekt nisbəti",
"profile": "Profil",
"level": "",
"level": "Səviyyə",
"bitDepth": "Bit dərinliyi",
"pixelFormat": "",
"pixelFormat": "Piksel formatı",
"colorSpace": "Rəng sahəsi",
"colorRange": "Rəng diapazonu",
"colorPrimaries": "Əsas rənglər",
"colorTransfer": "",
"colorTransfer": "Rəng ötürməsi",
"chromaSubsampling": "Rəng alt-diskretləşdirməsi",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Chroma mövqeyi",
"scanType": "Tarama növü",
"interlaced": "Sətirlərarası",
"anamorphic": "Anamorfik",
"referenceFrames": "İstinad kadrları",
"dynamicRange": "Dinamik diapazon",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision səviyyəsi",
"dolbyVisionVersion": "Dolby Vision versiyası",
"dolbyVisionLayers": "Dolby Vision təbəqələri",
"baseLayerCompatibility": "Baza təbəqə uyğunluğu",
"avcBitstream": "AVC bit axını",
"nalLengthSize": "NAL uzunluq ölçüsü",
"scalingMatrix": "Xüsusi miqyaslama matrisi",
"streamIdentifier": "Axın identifikatoru",
"streamIndex": "Axın indeksi",
"streamId": "Axın ID-si",
"language": "Dil",
"languageCode": "Dil kodu",
"streamTitle": "Trek başlığı",
"channels": "Kanallar",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Diskretləşdirmə tezliyi",
"spatialAudio": "Məkan səsi",
"textBased": "Mətn əsaslı",
"subtitleFormat": "Sidecar formatı",
"provider": "Provayder",
"matchScore": "Uyğunluq balı",
"externalDelivery": "Ayrıca ötürülə bilər",
"sidecarPath": "Sidecar yolu",
"sourceStream": "Kopyalanan mənbə",
"temporary": "Müvəqqəti",
"timeBase": "Vaxt bazası",
"overallBitrate": "Ümumi bit sürəti",
"path": "Yol",
"fileName": "",
"fileName": "Fayl adı",
"size": "Həcm",
"totalSize": "",
"totalSize": "Ümumi ölçü",
"container": "Konteyner",
"duration": "Müddət",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Önizləmə miniatürləri",
"previewIndex": "Önizləmə indeksi",
"packetLength": "Paket uzunluğu",
"filePresent": "Fayl mövcuddur",
"fileReadable": "Server tərəfindən oxuna bilir",
"streamPath": "Axın yolu",
"optimizedForStreaming": "Yayım üçün optimallaşdırılıb",
"has64bitOffsets": "64-bit ofsetlər",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protokol",
"mediaType": "Media növü",
"sourceKind": "Mənbə növü",
"optimizedVersion": "Optimallaşdırılmış versiya",
"optimizationTarget": "Optimallaşdırma hədəfi",
"deletedAt": "Silinib",
"remoteSource": "Uzaq mənbə",
"infiniteStream": "Sonsuz axın",
"directPlay": "Birbaşa oynatma",
"directStream": "Birbaşa axın",
"transcoding": "Yenidən kodlaşdırma",
"etag": "ETag",
"versionId": "Versiya ID-si",
"fileId": "Fayl ID-si",
"defaultAudioTrack": "Defolt səs treki",
"defaultSubtitleTrack": "Defolt altyazı treki",
"subtitlesOff": "Söndürülüb",
"flagDefault": "Defolt",
"flagForced": "Məcburi",
"flagSelected": "Seçilib",
"flagExternal": "Xarici",
"flagHearingImpaired": "Eşitmə qüsurlu üçün",
"flagDub": "Dublyaj",
"flagOriginal": "Orijinal"
},
"mediaMenu": {
"markAsWatched": "Baxıldı olaraq işarələ",
@@ -505,9 +502,35 @@
"shufflePlay": "Qarışıq oynat",
"shuffleNotAvailableOffline": "Qarışıq oynatma oflayn rejimdə əlçatan deyil",
"fileInfo": "Fayl məlumatı",
"deleteFromServer": "Serverdən sil",
"confirmDelete": "Bu media və faylları serverinizdən silinsin?",
"deleteEpisodeFromServer": "Seriyanı serverdən sil",
"deleteSeasonFromServer": "Mövsümü serverdən sil",
"deleteShowFromServer": "Serialı serverdən sil",
"deleteMovieFromServer": "Kinonu serverdən sil",
"deleteEpisodeTitle": "Bu seriya silinsin?",
"deleteSeasonTitle": "Bu mövsüm silinsin?",
"deleteShowTitle": "Bu serial silinsin?",
"deleteMovieTitle": "Bu kino silinsin?",
"deleteEpisodeConfirm": "Seriyanı sil",
"deleteSeasonConfirm": "Mövsümü sil",
"deleteShowConfirm": "Serialı sil",
"deleteMovieConfirm": "Kinonu sil",
"deleteAnyway": "Yenə də sil",
"confirmDeleteTarget": "${title} serverinizdən birdəfəlik silinsin?",
"deleteMultipleWarning": "Bu bütün seriyaları və faylları əhatə edir.",
"deleteEpisodeCountWarning": {
"one": "Bu, içindəki ${n} seriyanı və onun faylını siləcək.",
"other": "Bu, içindəki ${n} seriyanı və onların fayllarını siləcək."
},
"deleteMultiPartWarning": {
"one": "Bu element ${n} fayl kimi saxlanılır və o silinəcək.",
"other": "Bu element ${n} faylda saxlanılır və hamısı silinəcək."
},
"deleteSharedFileHeading": {
"one": "Eyni faylda saxlanılan ${n} başqa seriya da silinəcək:",
"other": "Eyni faylda saxlanılan ${n} başqa seriya da silinəcək:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy bunun hansı faylları siləcəyini yoxlaya bilmədi, ona görə də yuxarıda adı çəkilən elementdən daha çoxunu silə bilər. Ləğv edib təzədən cəhd edin və ya yenə də silin.",
"deleteScopeUnverifiedNoFileInfo": "Serveriniz bu element üçün fayl təfərrüatlarını təqdim etmədi, ona görə Plezy bunun hansı faylları siləcəyini yoxlaya bilmir. Yuxarıda adı çəkilən elementdən daha çoxunu silə bilər.",
"mediaDeletedSuccessfully": "Media elementi uğurla silindi",
"mediaFailedToDelete": "Media elementi silinə bilmədi",
"rate": "Qiymətləndir",
@@ -583,8 +606,9 @@
"playNext": "Növbətini oynat",
"playButton": "Oynat",
"pauseButton": "Fasilə",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Fasilədə",
"playbackResumed": "Oynadılır",
"loadingVideo": "Video yüklənir",
"showPlaybackControls": "Oynatma idarəetmələrini göstər",
"hidePlaybackControls": "Oynatma idarəetmələrini gizlət",
"seekBackwardButton": "${seconds} saniyə geri sar",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Hissələr əlçatan deyil",
"queue": "Növbə",
"noQueueItems": "Növbədə element yoxdur",
"noAudioDevicesAvailable": "Səs cihazları əlçatan deyil",
"searchSubtitles": "Altyazı axtar",
"language": "Dil",
"noSubtitlesFound": "Altyazı tapılmadı",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Uyğun oynadıcıya keçilir...",
"serverLimitTitle": "Oynatma uğursuz oldu",
"serverLimitBody": "Server xətası (HTTP 500). Məhdudiyyət bu seansı rədd etdi.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Fayl əlçatan deyil",
"mediaUnreadableBody": "Server bu elementi tapdı, lakin onun faylını oxuya bilmədi (HTTP 404). Fayl yəqin ki, köçürülüb, silinib və ya onun saxlanma yeri oflayndır. Server sahibindən faylı yoxlamasını və kitabxananı yenidən skan etməsini xahiş edin.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Jurnallar yükləndi",
"logsUploadFailed": "Jurnallar yüklənə bilmədi",
"logId": "Jurnal ID-si"
"logId": "Jurnal ID-si",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Mətn",
@@ -718,6 +746,8 @@
"overrideStrip": "Formatlaşdırmanı sil",
"positionTop": "Yuxarı",
"positionBottom": "Aşağı",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Qalın",
"italic": "Kursiv",
"renderResolution": "Emal imkanı (Resolution)",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Qoşulma yoxdur — bu profili istifadə etmək üçün birini əlavə edin.",
"noConnections": "Qoşulma yoxdur",
"plexHomeAccount": "Plex Ev hesabı",
"plexAccountChip": "Plex hesabı: ${account}",
"plexAccountUserChip": "${account} hesabı vasitəsilə ${user}",
"connectionDefault": "Defolt",
"connectionAs": "${displayName} olaraq",
"makeDefault": "Defolt et",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "${name} üçün seansın vaxtı bitdi",
"sessionExpiredMany": "${count} server üçün seansın vaxtı bitdi",
"signInAgain": "Yenidən daxil ol",
"editJellyfinTitle": "Jellyfin qoşulmasını dəyişdir",
"editJellyfinIntro": "${serverName} üçün URL əlavə edin və ya silin."
"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."
},
"discover": {
"title": "Kəşf et",
@@ -972,31 +1004,36 @@
"uploadLogs": "Jurnalları yüklə"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy başlaya bilmədi",
"failedBody": "Başlatma zamanı nəsə səhv getdi. Aşağıdakı təfərrüatlar nəyin uğursuz olduğunu göstərir.",
"failedBodyRepairable": "Plezy-nin saxlanmış tənzimləmə faylı zədələnib və Plezy başlamazdan əvvəl yenidən qurulmalıdır. Təzədən cəhd etmək kömək etməyəcək — Yaddaşı təmir et seçin.",
"phaseLabel": "Addım",
"showDetails": "Təfərrüatları göstər",
"hideDetails": "Təfərrüatları gizlət",
"copyDetails": "Təfərrüatları kopyala",
"detailsCopied": "Təfərrüatlar buferə kopyalandı",
"uploadDetails": "Təfərrüatları yüklə",
"repairStorage": "Yaddaşı təmir et",
"repairTitle": "Saxlanmış məlumatlar təmir edilsin?",
"repairBodyCommon": "Plezy-nin tənzimləmə faylı zədələnib və oxuna bilmir. Təmir bütün tənzimləmələri defolt vəziyyətinə sıfırlayır.",
"repairBodyOneCredential": "Bir saxlanmış daxil olma məlumatı zədələnib və oxuna bilmir. Təmir yalnız onu silir; digər tənzimləmələrinizə toxunulmur.",
"repairBodySignInsKept": "Serverləriniz və profilləriniz daxil olmuş vəziyyətdə qalmalıdır.",
"repairBodySignInsLost": "Saxlanmış daxil olmalarınızı qoruyan açar bu fayldan bərpa edilə bilmir, ona görə də hər serverə və profilə yenidən daxil olmalı olacaqsınız. Media serverinizdə heç nəyə təsir olunmur.",
"repairBodySessionsUncertain": "İzləyicilər (MAL, AniList, Simkl, Trakt) və Seerr ayrıca saxlanılır və qalıb-qalmayacaqları bəlli deyil. Plezy sizə nəyi saxladığını dəqiq bildirəcək.",
"repairConfirm": "Təmir et",
"repairSucceeded": "Yaddaş təmir edildi",
"repairNeedsRestart": "Yaddaş təmir edildi — yenidən başlatma tələb olunur",
"restartRequiredBody": "Məlumatlarınız təmir edildi, lakin Plezy ondan istifadə etmək üçün yenidən başlamalıdır. Plezy-ni bağlayın və yenidən açın.",
"quitPlezy": "Plezy-dən çıx",
"repairFailed": "Təmir uğursuz oldu",
"repairKeptSignIns": "Serverləriniz və profilləriniz hələ də daxil olub.",
"repairLostSignIns": "Saxlanmış daxil olmalarınızı qoruyan açar bərpa edilə bilmədi. Hər serverə və profilə yenidən daxil olmalı olacaqsınız.",
"repairLostSessions": "Ən azı bir izləyici və ya Seerr əlaqəsi itirildi və yenidən qoşulmalıdır.",
"backupTitle": "Zədələnmiş faylın nüsxəsi saxlanıldı",
"backupWarning": "O, daxil olma məlumatlarınızı ehtiva edir. Onu yükləməyin və ya paylaşmayın.",
"deleteBackup": "Nüsxəni sil",
"backupDeleted": "Nüsxə silindi.",
"previousFailureTitle": "Plezy son dəfə başlaya bilmədi"
},
"licenses": {
"relatedPackages": "Əlaqəli paketlər",
@@ -1043,7 +1080,10 @@
"characters": "Personajlar",
"addToWatchlist": "İzləmə siyahısına əlavə et",
"removeFromWatchlist": "İzləmə siyahısından sil",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "İzləmə siyahısı yenilənə bilmədi",
"watchlistNoMatch": "",
"notInLibrary": "Kitabxananızda yoxdur",
"inTheseLibraries": "Bu kitabxanalarda var",
"checkingLibrary": "Kitabxananız yoxlanılır...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "${source} vasitəsilə kino və seriallar axtarın.",
"searchFailed": "Axtarış uğursuz oldu. Bağlantınızı yoxlayın.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"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",
"available": "Əlçatandır",
"partiallyAvailable": "Hissəvi əlçatandır",
"availableIn4k": "4K əlçatandır",
"requested": "Sorğu göndərildi",
"pendingApproval": "Təsdiq gözlənilir",
"processing": "Emal edilir",
"declined": "Rədd edildi",
"requestFailed": "Sorğu uğursuz oldu",
"requested4k": "4K sorğu göndərildi",
"seasonsAvailable": "${available}/${total} mövsüm",
"nextEpisodeIn": "Ep ${episode} ${duration} sonra",
"nextAiringIn": "Növbəti ${duration} sonra",
"episodesShort": "${n} ep",
"minutesPerEpisode": "${n} dəq/ep",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} siyahıya alınıb",
"viewersDay": "Bu gün ${n} izlədi",
"viewersWeek": "Bu həftə ${n} izlədi",
"viewersMonth": "Bu ay ${n} izlədi",
"viewersYear": "Bu il ${n} izlədi",
"viewersAllTime": "${n} izləyici",
"planning": "${n} izləməyi planlaşdırır",
"favorited": "${n} favorit",
"dropRate": "${percent} izləməyi dayandırdı",
"comments": {
"one": "",
"other": ""
"one": "${n} şərh",
"other": "${n} şərh"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} səs",
"watching": "${n} izləyir",
"completed": "${n} tamamladı",
"onHold": "${n} fasilədə",
"dropped": "${n} izləməyi dayandırdı"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Qış",
"spring": "Yaz",
"summer": "Yay",
"fall": "Payız",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "Qısa TV",
"movie": "Kino",
"special": "Xüsusi buraxılış",
"ova": "OVA",
"ona": "ONA",
"music": "Musiqi",
"other": "Digər"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Orijinal",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Roman",
"visualNovel": "Vizual roman",
"game": "Oyun",
"webComic": "Veb komiks",
"musicRelease": "Musiqi",
"otherMedia": "Digər"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Rejissor",
"writer": "Ssenarist",
"producer": "Prodüser",
"creator": "Yaradıcı",
"composer": "Bəstəkar"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prikvel",
"sequel": "Sikvel",
"sideStory": "Yan hekayə",
"spinOff": "Spin-off",
"alternativeVersion": "Alternativ versiya",
"summary": "Xülasə",
"parentStory": "Əsas hekayə",
"adaptation": "Adaptasiya",
"other": "Əlaqəli"
},
"broadcast": "${day} saat ${time}-da yayımlanır",
"broadcastWithZone": "${day} saat ${time} (${timezone}) yayımlanır",
"detail": {
"originalTitle": "Orijinal başlıq",
"alsoKnownAs": "Digər adları",
"studios": "Studiyalar",
"country": "Ölkə",
"language": "Dil",
"released": "Buraxılıb",
"physicalRelease": "Diskdə",
"ended": "Bitib",
"addedOn": "${date} əlavə edildi",
"yourRating": "Sizin qiymətləndirməniz",
"budget": "Büdcə",
"revenue": "Kassa gəliri",
"contentAdvisory": "Yaş tövsiyəsi",
"tags": "Teqlər",
"revealSpoilerTags": "Spoyler teqlərini göstər",
"links": "Keçidlər",
"watchOn": "İzlə",
"watchTrailer": "Treyleri izlə",
"openOn": "${site} saytında aç",
"crew": "Heyət",
"ratings": "Reytinq",
"schedule": "Cədvəl",
"recommendedByUsers": {
"one": "${n} istifadəçi tövsiyə edir",
"other": "${n} istifadəçi tövsiyə edir"
},
"recommendedBy": "${who} tövsiyə edir",
"favoritedBy": "${who} favorit edib",
"unairedEpisodes": "${n} hələ yayımlanmayıb",
"recommendedByPercent": "İzləyicilərin ${percent}-i tövsiyə edir",
"relatedTitles": "Əlaqəli başlıqlar",
"background": "Arxa fon"
},
"totalResults": {
"one": "${n} nəticə",
"other": "${n} nəticə"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Kanala bax",
"favorites": "Sevimlilər",
"reorderFavorites": "Sevimliləri yenidən sırala",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Sevimlilər yüklənə bilmədi.",
"favoritesUpdateFailed": "Sevimlilər yenilənə bilmədi.",
"joinSession": "Davam edən seansa qoşul",
@@ -1614,9 +1644,15 @@
"audioSync": "Səs sinxronizasiyası",
"subtitleSync": "Altyazı sinxronizasiyası",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Səs çıxışı",
"performanceOverlay": "Məhsuldarlıq paneli",
"audioPassthrough": "Səsin birbaşa ötürülməsi",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Əhatəli səs",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Plezy-ni ${service} üzərində aktivləşdirin",
"body": "${url} ünvanına keçin və bu kodu daxil edin:",
"instructions": "",
"openToActivate": "Aktivləşdirmək üçün ${service} açın",
"copyCode": "Aktivləşdirmə kodunu kopyala",
"waitingForAuthorization": "Səlahiyyət gözlənilir…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "${service} xidmətinə daxil olun",
"body": "Bu QR kodu skan edin və ya URL-i açın.",
"openToSignIn": "Daxil olmaq üçün ${service} açın",
"openToSignIn": "Daxil olmaq üçün ${service} açın"
},
"pendingAuth": {
"copyUrl": "Daxil olma URL-ini kopyala",
"urlCopied": "URL kopyalandı"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfin serveri əlavə et",
"addMediaBrowserTitle": "${product} serveri əlavə et",
"serverUrls": "Server URL-ləri",
"serverUrlsHelper": "Vergüllə ayrılmış bir neçə URL-ə icazə verilir.",
"findServer": "Server tap",
"searchingLocalServers": "Yerli Jellyfin serverləri axtarılır...",
"localServers": "Yerli Jellyfin serverləri",
"searchingLocalMediaBrowserServers": "Yerli ${product} serverləri axtarılır...",
"localMediaBrowserServers": "Yerli ${product} serverləri",
"username": "İstifadəçi adı",
"password": "Şifrə",
"signIn": "Daxil ol",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Plex ilə daxil ol",
"pinExpired": "PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.",
"failedToRegisterAccount": "Hesab qeydiyyatı uğursuz oldu: ${error}",
"enterJellyfinUrlError": "Jellyfin server URL-inizi daxil edin",
"enterMediaBrowserUrlError": "${product} server URL-inizi daxil edin",
"addConnectionTitle": "Qoşulma əlavə et",
"addConnectionTitleScoped": "${name} profilinə əlavə et",
"signInWithPlexCard": "Plex ilə daxil ol",
"signInWithPlexCardSubtitle": "Bu cihazı səlahiyyətləndirin.",
"signInWithPlexCardSubtitleScoped": "Plex hesabını səlahiyyətləndirin.",
"connectToJellyfinCard": "Jellyfin-ə qoşul",
"connectToJellyfinCardSubtitle": "Server URL, istifadəçi adı və şifrənizi daxil edin.",
"connectToJellyfinCardSubtitleScoped": "Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.",
"connectToMediaBrowserCard": "${product}-a qoşul",
"connectToMediaBrowserCardSubtitle": "Server URL-inizi, istifadəçi adınızı və şifrənizi daxil edin.",
"connectToMediaBrowserCardSubtitleScoped": "${product} serverinizə daxil olun. ${name} ilə əlaqələndirilir.",
"borrowFromAnotherProfile": "Başqa profildən götür",
"borrowFromAnotherProfileSubtitle": "Başqa profilin qoşulmasını yenidən istifadə edin."
}
+338 -299
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Изчакване на удостоверяване...\nВлезте от браузъра си.",
"useBrowser": "Използвай браузър",
"or": "или",
"connectToJellyfin": "Свържи се с Jellyfin",
"connectToMediaBrowser": "Свържи се с ${product}",
"useQuickConnect": "Използвай Quick Connect",
"quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.",
"quickConnectWaiting": "Изчакване на одобрение…",
@@ -64,7 +64,20 @@
"loading": "Зареждане...",
"fullscreen": "На цял екран",
"exitFullscreen": "Изход от цял екран",
"pressBackAgainToExit": "Натиснете Назад отново, за да излезете"
"pressBackAgainToExit": "Натиснете Назад отново, за да излезете",
"ratingSource": {
"critic": "Критици",
"audience": "Публика",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Критиците на Rotten Tomatoes",
"rottenTomatoesAudience": "Публиката на Rotten Tomatoes"
}
},
"screens": {
"licenses": "Лицензи",
@@ -107,6 +120,7 @@
"darkTheme": "Тъмна",
"oledTheme": "OLED",
"libraryDensity": "Плътност на библиотеката",
"displayScale": "Мащаб на дисплея",
"compact": "Компактна",
"comfortable": "Удобна",
"tvCornerSpotlightBackdrop": "Фон с акцент в ъгъла",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size} MB",
"bufferSizeAuto": "Автоматично (препоръчително)",
"bufferSizeWarning": "Налична памет: ${heap} MB. Буфер от ${size} MB може да повлияе на възпроизвеждането.",
"playbackBuffer": "Буфер за възпроизвеждане",
"playbackBufferAuto": "Автоматично (препоръчително)",
"playbackBufferLarge": "Голям",
"playbackBufferExtraLarge": "Изключително голям",
"playbackBufferDescription": "Буферира повече при нестабилни връзки. Също ограничен от размера на буфера.",
"defaultQualityTitle": "Качество по подразбиране",
"musicQualityTitle": "Качество на музиката",
"subtitleStyling": "Стил на субтитрите",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Включи подробни логове за диагностика",
"viewLogs": "Виж логовете",
"viewLogsDescription": "Преглед на логовете на приложението",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Изчисти кеша на изображенията",
"clearImageCacheDescription": "Изчиства кешираните обложки и миниатюри. Изображенията може да се зареждат по-бавно, докато не бъдат изтеглени отново.",
"clearImageCacheSuccess": "Кешът на изображенията е изчистен успешно",
"resetSettings": "Нулирай настройките",
"resetSettingsDescription": "Възстанови настройките по подразбиране. Това не може да бъде отменено.",
"resetSettingsSuccess": "Настройките са нулирани успешно",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "дБ",
"audioDownmixNormalize": "Нормализиране на звука при смесване",
"audioDownmixNormalizeDescription": "Понижава микса, за да се предотврати клипинг. Изключете, за да запазите оригиналната сила на звука (възможни изкривявания при силни сцени).",
"atmosDiagnostics": "Тест на Atmos изхода",
"atmosDiagnosticsDescription": "Диагностика на Dolby Atmos изхода чрез възпроизвеждане на тестови сигнали през системния плейър",
"atmosTestHlsAtmos": "Apple Atmos поток",
"atmosTestHlsAtmosDescription": "Гарантирано работещ Dolby Atmos поток. Ресийвърът трябва да покаже Dolby Atmos.",
"atmosTestHlsControl": "Apple съраунд поток",
"atmosTestHlsControlDescription": "Контролен поток без Atmos. Ресийвърът трябва да покаже съраунд без Atmos.",
"atmosTestRawStream": "Суров EAC3 поток",
"atmosTestRawStreamDescription": "Стриймва тестовия файл точно както Atmos възпроизвеждането в плейъра. Изисква URL на тестовия файл.",
"atmosTestRawFile": "Суров EAC3 файл",
"atmosTestRawFileDescription": "Възпроизвежда тестовия файл с известна дължина. Изисква URL на тестовия файл.",
"atmosTestAsbarNative": "Рендер със семпъл буфер (native)",
"atmosTestAsbarNativeDescription": "Подава несменения компресиран звук от файла директно към системния рендер. Изисква URL на тестовия файл.",
"atmosTestAsbarGenerated": "Рендер със семпъл буфер (възстановен)",
"atmosTestAsbarGeneratedDescription": "Същото, но с аудиоописание, изградено както при възпроизвеждане. Изисква URL на тестовия файл.",
"atmosTestSessionMode": "Използвай режим за възпроизвеждане на филми",
"atmosTestSessionModeDescription": "Изключено използва режима, документиран от Dolby. Включено използва предишния режим.",
"atmosTestShowRoutePicker": "Избери AirPlay изход",
"atmosTestHideRoutePicker": "Скрий избора на AirPlay изход",
"atmosTestRoutePickerDescription": "Изпраща теста към AirPlay приемник. Само AirPlay съобщава разрешения аудиорежим.",
"atmosTestStop": "Спри теста",
"atmosTestUrl": "URL на тестовия файл",
"atmosTestUrlDescription": "HTTP URL на суров .ec3 Dolby Atmos файл (напр. извлечен с ffmpeg)",
"atmosTestUrlMissing": "Първо задайте URL на тестовия файл",
"atmosTestStatus": "Състояние",
"dvConversionMode": "Преобразуване на Dolby Vision",
"dvConversionModeDescription": "Изберете как ExoPlayer обработва файлове с Dolby Vision Profile 7.",
"dvConversionAuto": "Автоматично",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Показвай етикети в навигационната лента",
"showNavBarLabelsDescription": "Показвай текстови етикети под иконите в навигационната лента",
"startupSection": "Начален раздел",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "По подразбиране към любими канали",
"liveTvDefaultFavoritesDescription": "Показвай само любими канали при отваряне на телевизия на живо",
"display": "Дисплей",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Информация за файла",
"overview": "",
"overview": "Общ преглед",
"video": "Видео",
"audio": "Аудио",
"subtitles": "Субтитри",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Вградени изображения",
"dataStreams": "Потоци от данни",
"lyrics": "Текстове на песни",
"file": "Файл",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Прикачени файлове",
"delivery": "Доставка",
"versionCounter": "Версия ${index} от ${count}",
"fileCounter": "Файл ${index} от ${count}",
"noStreams": "Сървърът не е отчел потоци за този файл.",
"copyPath": "Копирай пътя",
"pathCopied": "Пътят на файла е копиран",
"codec": "Кодек",
"codecTag": "",
"codecTag": "Етикет на кодека",
"resolution": "Резолюция",
"codedResolution": "",
"codedResolution": "Кодирана разделителна способност",
"bitrate": "Битрейт",
"frameRate": "Кадрова честота",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Завъртане",
"comment": "Коментар",
"audioDescription": "Аудио описание",
"headerCompression": "Компресия на заглавката",
"sidecarFile": "Сайдкар файл",
"transportTimestamp": "Времева отметка на транспорта",
"displayOffset": "Отместване при показване",
"previewFailureCode": "Код на грешка при прегледа",
"previewRetries": "Опити при прегледа",
"aspectRatio": "Съотношение на страните",
"pixelAspectRatio": "",
"pixelAspectRatio": "Съотношение на пиксела",
"profile": "Профил",
"level": "",
"level": "Ниво",
"bitDepth": "Битова дълбочина",
"pixelFormat": "",
"pixelFormat": "Формат на пикселите",
"colorSpace": "Цветово пространство",
"colorRange": "Цветови диапазон",
"colorPrimaries": "Основни цветове",
"colorTransfer": "",
"colorTransfer": "Цветов трансфер",
"chromaSubsampling": "Цветова субдискретизация",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Позиция на хрома",
"scanType": "Тип на сканиране",
"interlaced": "С преплетени редове",
"anamorphic": "Анаморфен",
"referenceFrames": "Референтни кадри",
"dynamicRange": "Динамичен обхват",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Ниво на Dolby Vision",
"dolbyVisionVersion": "Версия на Dolby Vision",
"dolbyVisionLayers": "Слоеве на Dolby Vision",
"baseLayerCompatibility": "Съвместимост на базовия слой",
"avcBitstream": "AVC битов поток",
"nalLengthSize": "Размер на дължината на NAL",
"scalingMatrix": "Персонализирана матрица за мащабиране",
"streamIdentifier": "Идентификатор на потока",
"streamIndex": "Индекс на потока",
"streamId": "ID на потока",
"language": "Език",
"languageCode": "Код на езика",
"streamTitle": "Заглавие на пистата",
"channels": "Канали",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Честота на дискретизация",
"spatialAudio": "Пространствено аудио",
"textBased": "Текстов",
"subtitleFormat": "Сайдкар формат",
"provider": "Доставчик",
"matchScore": "Степен на съвпадение",
"externalDelivery": "Може да се предоставя отделно",
"sidecarPath": "Път на сайдкар файла",
"sourceStream": "Копирано от",
"temporary": "Временен",
"timeBase": "Времева база",
"overallBitrate": "Общ битрейт",
"path": "Път",
"fileName": "",
"fileName": "Име на файла",
"size": "Размер",
"totalSize": "",
"totalSize": "Общ размер",
"container": "Контейнер",
"duration": "Продължителност",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Миниатюри за преглед",
"previewIndex": "Индекс на прегледа",
"packetLength": "Дължина на пакета",
"filePresent": "Файлът е наличен",
"fileReadable": "Четим от сървъра",
"streamPath": "Път на потока",
"optimizedForStreaming": "Оптимизирано за стрийминг",
"has64bitOffsets": "64-битови отмествания",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Протокол",
"mediaType": "Тип медия",
"sourceKind": "Вид източник",
"optimizedVersion": "Оптимизирана версия",
"optimizationTarget": "Цел на оптимизацията",
"deletedAt": "Изтрит",
"remoteSource": "Отдалечен източник",
"infiniteStream": "Безкраен поток",
"directPlay": "Директно възпроизвеждане",
"directStream": "Директен поток",
"transcoding": "Транскодиране",
"etag": "ETag",
"versionId": "ID на версията",
"fileId": "ID на файла",
"defaultAudioTrack": "Звукова пътека по подразбиране",
"defaultSubtitleTrack": "Субтитри по подразбиране",
"subtitlesOff": "Изкл.",
"flagDefault": "По подразбиране",
"flagForced": "Принудителни",
"flagSelected": "Избран",
"flagExternal": "Външен",
"flagHearingImpaired": "За хора с увреден слух",
"flagDub": "Дублаж",
"flagOriginal": "Оригинал"
},
"mediaMenu": {
"markAsWatched": "Маркирай като гледано",
@@ -505,9 +502,35 @@
"shufflePlay": "Разбъркано възпроизвеждане",
"shuffleNotAvailableOffline": "Разбърканото възпроизвеждане не е налично офлайн",
"fileInfo": "Информация за файла",
"deleteFromServer": "Изтрий от сървъра",
"confirmDelete": "Да се изтрият ли този елемент и файловете му от вашия сървър?",
"deleteEpisodeFromServer": "Изтрий епизода от сървъра",
"deleteSeasonFromServer": "Изтрий сезона от сървъра",
"deleteShowFromServer": "Изтрий сериала от сървъра",
"deleteMovieFromServer": "Изтрий филма от сървъра",
"deleteEpisodeTitle": "Да изтрия ли този епизод?",
"deleteSeasonTitle": "Да изтрия ли този сезон?",
"deleteShowTitle": "Да изтрия ли този сериал?",
"deleteMovieTitle": "Да изтрия ли този филм?",
"deleteEpisodeConfirm": "Изтрий епизода",
"deleteSeasonConfirm": "Изтрий сезона",
"deleteShowConfirm": "Изтрий сериала",
"deleteMovieConfirm": "Изтрий филма",
"deleteAnyway": "Изтрий въпреки това",
"confirmDeleteTarget": "Завинаги да изтрия ли ${title} от сървъра ви?",
"deleteMultipleWarning": "Това включва всички епизоди и техните файлове.",
"deleteEpisodeCountWarning": {
"one": "Това изтрива целия ${n} епизод в него, както и файла му.",
"other": "Това изтрива всичките ${n} епизода в него, както и техните файлове."
},
"deleteMultiPartWarning": {
"one": "Този елемент се съхранява като ${n} файл, който ще бъде изтрит.",
"other": "Този елемент се съхранява в ${n} файла и всички те ще бъдат изтрити."
},
"deleteSharedFileHeading": {
"one": "${n} друг епизод се съхранява в същия файл и също ще бъде изтрит:",
"other": "${n} други епизода се съхраняват в същия файл и също ще бъдат изтрити:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy не успя да провери кои файлове ще бъдат премахнати, така че може да изтрие повече от посочения по-горе елемент. Откажете и опитайте отново, или изтрийте въпреки това.",
"deleteScopeUnverifiedNoFileInfo": "Сървърът ви не предостави данни за файловете на този елемент, така че Plezy не може да провери кои файлове ще бъдат премахнати. Може да изтрие повече от посочения по-горе елемент.",
"mediaDeletedSuccessfully": "Елементът е изтрит успешно",
"mediaFailedToDelete": "Неуспешно изтриване на елемента",
"rate": "Оцени",
@@ -583,8 +606,9 @@
"playNext": "Пусни следващото",
"playButton": "Пусни",
"pauseButton": "Пауза",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "На пауза",
"playbackResumed": "Възпроизвежда се",
"loadingVideo": "Зареждане на видеото",
"showPlaybackControls": "Покажи контролите за възпроизвеждане",
"hidePlaybackControls": "Скрий контролите за възпроизвеждане",
"seekBackwardButton": "Превърти назад ${seconds} секунди",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Няма налични глави",
"queue": "Опашка",
"noQueueItems": "Няма елементи в опашката",
"noAudioDevicesAvailable": "Няма налични аудио устройства",
"searchSubtitles": "Търсене на субтитри",
"language": "Език",
"noSubtitlesFound": "Не са намерени субтитри",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Премахнато от продължаване на гледането",
"errorLoading": "Грешка: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Някои медийни сървъри не можаха да бъдат претърсени. Показват се наличните резултати.",
"streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.",
"liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.",
"fileInfoNotAvailable": "Информацията за файла не е налична",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Превключване към съвместим плейър...",
"serverLimitTitle": "Възпроизвеждането е неуспешно",
"serverLimitBody": "Грешка на сървъра (HTTP 500). Вероятно лимит за пропускателна способност/транскодиране е отхвърлил тази сесия. Помолете собственика да го коригира.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Файлът е недостъпен",
"mediaUnreadableBody": "Сървърът намери този елемент, но не можа да прочете файла му (HTTP 404). Файлът вероятно е бил преместен, изтрит или хранилището му е офлайн. Помолете собственика на сървъра да провери файла и да сканира отново библиотеката.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Логовете са качени",
"logsUploadFailed": "Неуспешно качване на логовете",
"logId": "ID на лога"
"logId": "ID на лога",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Текст",
@@ -718,6 +746,8 @@
"overrideStrip": "Премахване на стиловете",
"positionTop": "Горе",
"positionBottom": "Долу",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Получер",
"italic": "Курсив",
"renderResolution": "Резолюция на изобразяване",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Няма връзки — добавете такава, за да използвате този профил.",
"noConnections": "Няма връзки",
"plexHomeAccount": "Plex Home акаунт",
"plexAccountChip": "Plex акаунт: ${account}",
"plexAccountUserChip": "${user} през ${account}",
"connectionDefault": "По подразбиране",
"connectionAs": "като ${displayName}",
"makeDefault": "Направи по подразбиране",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Сесията за ${name} е изтекла",
"sessionExpiredMany": "Сесиите за ${count} сървъра са изтекли",
"signInAgain": "Влез отново",
"editJellyfinTitle": "Редактиране на Jellyfin връзка",
"editJellyfinIntro": "Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност."
"editMediaBrowserTitle": "Редактирай връзката с ${product}",
"editMediaBrowserIntro": "Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност."
},
"discover": {
"title": "Открий",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Търсенето е неуспешно: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Търсенето не можа да достигне до нито един медиен сървър.",
"connectionTimeout": "Изтече времето за връзка при зареждане на ${context}",
"connectionFailed": "Не може да се осъществи връзка с медиен сървър",
"unableToLoad": "Не може да се зареди ${context}. Опитайте отново.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Качи логовете"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy не можа да стартира",
"failedBody": "Нещо се обърка по време на стартирането. Детайлите по-долу показват какво е причинило грешката.",
"failedBodyRepairable": "Запазеният файл с настройки на Plezy е повреден и трябва да бъде възстановен, преди Plezy да стартира. Повторните опити няма да помогнат — изберете „Ремонт на хранилището“.",
"phaseLabel": "Стъпка",
"showDetails": "Покажи детайлите",
"hideDetails": "Скрий детайлите",
"copyDetails": "Копирай детайлите",
"detailsCopied": "Детайлите са копирани в клипборда",
"uploadDetails": "Качи детайлите",
"repairStorage": "Ремонт на хранилището",
"repairTitle": "Да ремонтирам ли съхранените данни?",
"repairBodyCommon": "Файлът с настройки на Plezy е повреден и не може да бъде прочетен. Ремонтът връща всяка настройка към стойността ѝ по подразбиране.",
"repairBodyOneCredential": "Един запис за вход е повреден и не може да бъде прочетен. Ремонтът премахва само него; останалите ви настройки не се пипат.",
"repairBodySignInsKept": "Сървърите и профилите ви трябва да останат вписани.",
"repairBodySignInsLost": "Ключът, защитаващ запазените ви вписвания, не може да бъде възстановен от този файл, така че ще трябва да се впишете отново във всеки сървър и профил. Нищо на медийния ви сървър не се засяга.",
"repairBodySessionsUncertain": "Трекърите (MAL, AniList, Simkl, Trakt) и Seerr се съхраняват отделно и може да оцелеят или не. Plezy ще ви каже точно какво е запазил.",
"repairConfirm": "Ремонтирай",
"repairSucceeded": "Хранилището е ремонтирано",
"repairNeedsRestart": "Хранилището е ремонтирано — изисква се рестартиране",
"restartRequiredBody": "Данните ви бяха ремонтирани, но Plezy трябва да стартира наново, преди да ги използва. Затворете Plezy и го отворете отново.",
"quitPlezy": "Изход от Plezy",
"repairFailed": "Ремонтът се провали",
"repairKeptSignIns": "Сървърите и профилите ви са все още вписани.",
"repairLostSignIns": "Ключът, защитаващ запазените ви вписвания, не можа да бъде възстановен. Ще трябва да се впишете отново във всеки сървър и профил.",
"repairLostSessions": "Поне една връзка с трекър или Seerr беше загубена и трябва да бъде възстановена.",
"backupTitle": "Запазено е копие на повредения файл",
"backupWarning": "То съдържа вашите данни за вписване. Не го качвайте и не го споделяйте.",
"deleteBackup": "Изтрий копието",
"backupDeleted": "Копието е изтрито.",
"previousFailureTitle": "Plezy не успя да стартира последния път"
},
"licenses": {
"relatedPackages": "Свързани пакети",
@@ -1007,7 +1044,7 @@
"navigation": {
"libraries": "Библиотеки",
"downloads": "Изтегляния",
"liveTv": "Телевизия на живо",
"liveTv": "TV на живо",
"explore": "Разгледай"
},
"explore": {
@@ -1043,7 +1080,10 @@
"characters": "Герои",
"addToWatchlist": "Добави в списъка за гледане",
"removeFromWatchlist": "Премахни от списъка за гледане",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Неуспешно обновяване на списъка за гледане",
"watchlistNoMatch": "",
"notInLibrary": "Не е в твоята библиотека",
"inTheseLibraries": "В тези библиотеки",
"checkingLibrary": "Проверка на твоята библиотека...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Търси филми и сериали в ${source}.",
"searchFailed": "Търсенето се провали. Провери връзката си и опитай отново.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} популярен",
"rankAiring": "#${n} в ефир",
"rankRated": "#${n} оценен",
"rankFavorited": "#${n} най-харесван",
"rankTrending": "#${n} в тенденция",
"rankSeasonal": "#${n} през ${season}",
"watchingNow": "${n} гледат",
"available": "Наличен",
"partiallyAvailable": "Частично наличен",
"availableIn4k": "Наличен в 4K",
"requested": "Заявен",
"pendingApproval": "В очакване на одобрение",
"processing": "Обработва се",
"declined": "Отхвърлен",
"requestFailed": "Заявката се провали",
"requested4k": "Заявен в 4K",
"seasonsAvailable": "${available}/${total} сезона",
"nextEpisodeIn": "Еп. ${episode} след ${duration}",
"nextAiringIn": "Следващ след ${duration}",
"episodesShort": "${n} еп.",
"minutesPerEpisode": "${n} мин/еп",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} в списък",
"viewersDay": "${n} гледали днес",
"viewersWeek": "${n} гледали тази седмица",
"viewersMonth": "${n} гледали този месец",
"viewersYear": "${n} гледали тази година",
"viewersAllTime": "${n} зрители",
"planning": "${n} планират да гледат",
"favorited": "${n} в любими",
"dropRate": "${percent} го изоставиха",
"comments": {
"one": "",
"other": ""
"one": "${n} коментар",
"other": "${n} коментара"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} гласа",
"watching": "${n} го гледат",
"completed": "${n} завършили",
"onHold": "${n} на пауза",
"dropped": "${n} изоставили"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Зима",
"spring": "Пролет",
"summer": "Лято",
"fall": "Есен",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "ТВ",
"tvShort": "ТВ късометражен",
"movie": "Филм",
"special": "Специален",
"ova": "OVA",
"ona": "ONA",
"music": "Музика",
"other": "Друго"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Оригинал",
"manga": "Манга",
"lightNovel": "Лайт новела",
"novel": "Роман",
"visualNovel": "Визуална новела",
"game": "Игра",
"webComic": "Уеб комикс",
"musicRelease": "Музика",
"otherMedia": "Друго"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Режисьор",
"writer": "Сценарист",
"producer": "Продуцент",
"creator": "Създател",
"composer": "Композитор"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Приквел",
"sequel": "Сиквел",
"sideStory": "Странична история",
"spinOff": "Спин-оф",
"alternativeVersion": "Алтернативна версия",
"summary": "Резюме",
"parentStory": "Основна история",
"adaptation": "Адаптация",
"other": "Свързано"
},
"broadcast": "Излъчва се в ${day} от ${time}",
"broadcastWithZone": "Излъчва се в ${day} от ${time} ${timezone}",
"detail": {
"originalTitle": "Оригинално заглавие",
"alsoKnownAs": "Известен също като",
"studios": "Студия",
"country": "Държава",
"language": "Език",
"released": "Излязъл",
"physicalRelease": "На диск",
"ended": "Приключил",
"addedOn": "Добавен на ${date}",
"yourRating": "Вашата оценка",
"budget": "Бюджет",
"revenue": "Боксофис",
"contentAdvisory": "Възрастова препоръка",
"tags": "Етикети",
"revealSpoilerTags": "Покажи етикетите за спойлери",
"links": "Връзки",
"watchOn": "Гледай в",
"watchTrailer": "Гледай трейлъра",
"openOn": "Отвори в ${site}",
"crew": "Екип",
"ratings": "Оценки",
"schedule": "Програма",
"recommendedByUsers": {
"one": "Препоръчан от ${n} потребител",
"other": "Препоръчан от ${n} потребители"
},
"recommendedBy": "Препоръчан от ${who}",
"favoritedBy": "В любими от ${who}",
"unairedEpisodes": "${n} все още не са излъчени",
"recommendedByPercent": "Препоръчан от ${percent} от зрителите",
"relatedTitles": "Свързани заглавия",
"background": "Фон"
},
"totalResults": {
"one": "${n} резултат",
"other": "${n} резултата"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Гледай канал",
"favorites": "Любими",
"reorderFavorites": "Пренареди любимите",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.",
"favoritesUpdateFailed": "Любимите не можаха да бъдат обновени. Проверете връзката си и опитайте отново.",
"joinSession": "Присъедини се към текуща сесия",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Редактирай правило за синхронизация",
"removeSyncRule": "Премахни правило за синхронизация",
"removeSyncRuleConfirm": "Да се спре ли синхронизацията за \"${title}\"? Изтеглените епизоди ще останат.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Да спра ли синхронизирането на „${title}“?",
"deleteSyncRuleDownloads": "Изтрий и свързаните изтегляния",
"deleteSyncRuleDownloadsDescription": "Изтеглянията, използвани от друго правило за синхронизация или профил, ще бъдат запазени.",
"syncRuleCreated": "Правилото за синхронизация е създадено — запазват се ${count} негледани епизода",
"syncRuleUpdated": "Правилото за синхронизация е обновено",
"syncRuleRemoved": "Правилото за синхронизация е премахнато",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Правилото за синхронизация и свързаните изтегляния са премахнати",
"syncRuleCleanupBusy": "Правилата за синхронизация в момента се обновяват. Опитайте отново след малко.",
"syncRuleCleanupUnavailable": "Свързаните изтегляния не можаха да бъдат идентифицирани безопасно. Свържете се отново със сървъра и опитайте отново, или премахнете правилото, без да изтривате изтеглянията.",
"syncedNewEpisodes": "Синхронизирани са ${count} нови епизода за ${title}",
"activeSyncRules": "Правила за синхронизация",
"noSyncRules": "Няма правила за синхронизация",
@@ -1614,9 +1644,15 @@
"audioSync": "Синхронизация на аудио",
"subtitleSync": "Синхронизация на субтитри",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудио изход",
"performanceOverlay": "Оверлей за производителност",
"audioPassthrough": "Директно предаване на аудио",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Съраунд",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Активиране на Plezy в ${service}",
"body": "Посетете ${url} и въведете този код:",
"instructions": "",
"openToActivate": "Отворете ${service}, за да активирате",
"copyCode": "Копирай кода за активиране",
"waitingForAuthorization": "Изчакване на оторизация…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Вход в ${service}",
"body": "Сканирайте този QR код или отворете URL-а на което и да е устройство.",
"openToSignIn": "Отворете ${service}, за да влезете",
"openToSignIn": "Отворете ${service}, за да влезете"
},
"pendingAuth": {
"copyUrl": "Копирай URL адреса за вход",
"urlCopied": "URL адресът е копиран"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Добави Jellyfin сървър",
"addMediaBrowserTitle": "Добави ${product} сървър",
"serverUrls": "URL адреси на сървъра",
"serverUrlsHelper": "Позволени са няколко URL адреса, разделени със запетаи.",
"findServer": "Намери сървър",
"searchingLocalServers": "Търсене на локални Jellyfin сървъри...",
"localServers": "Локални Jellyfin сървъри",
"searchingLocalMediaBrowserServers": "Търсене на локални ${product} сървъри...",
"localMediaBrowserServers": "Локални ${product} сървъри",
"username": "Потребителско име",
"password": "Парола",
"signIn": "Вход",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Вход с Plex",
"pinExpired": "PIN-ът изтече преди вход. Моля, опитайте отново.",
"failedToRegisterAccount": "Неуспешна регистрация на акаунт: ${error}",
"enterJellyfinUrlError": "Въведете URL адреса на вашия Jellyfin сървър",
"enterMediaBrowserUrlError": "Въведете URL адреса на вашия ${product} сървър",
"addConnectionTitle": "Добави връзка",
"addConnectionTitleScoped": "Добави към ${name}",
"signInWithPlexCard": "Вход с Plex",
"signInWithPlexCardSubtitle": "Удостоверете това устройство. Споделените сървъри се добавят.",
"signInWithPlexCardSubtitleScoped": "Удостоверете Plex акаунт. Домашните потребители стават профили.",
"connectToJellyfinCard": "Свързване с Jellyfin",
"connectToJellyfinCardSubtitle": "Въведете URL адрес на сървъра, потребителско име и парола.",
"connectToJellyfinCardSubtitleScoped": "Вход в Jellyfin сървър. Свързва се с ${name}.",
"connectToMediaBrowserCard": "Свържи се с ${product}",
"connectToMediaBrowserCardSubtitle": "Въведете URL адреса на сървъра, потребителското име и паролата.",
"connectToMediaBrowserCardSubtitleScoped": "Впишете се в своя ${product} сървър. Обвързва се с ${name}.",
"borrowFromAnotherProfile": "Използвай от друг профил",
"borrowFromAnotherProfileSubtitle": "Използвай връзка от друг профил. PIN-защитените профили изискват PIN."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Venter på godkendelse...\nLog ind fra din browser.",
"useBrowser": "Brug browseren",
"or": "eller",
"connectToJellyfin": "Forbind til Jellyfin",
"connectToMediaBrowser": "Opret forbindelse til ${product}",
"useQuickConnect": "Brug Quick Connect",
"quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.",
"quickConnectWaiting": "Venter på godkendelse…",
@@ -64,7 +64,20 @@
"loading": "Indlæser...",
"fullscreen": "Fuldskærm",
"exitFullscreen": "Forlad fuldskærm",
"pressBackAgainToExit": "Tryk på tilbage igen for at afslutte"
"pressBackAgainToExit": "Tryk på tilbage igen for at afslutte",
"ratingSource": {
"critic": "Anmeldere",
"audience": "Publikum",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes-anmeldere",
"rottenTomatoesAudience": "Rotten Tomatoes-publikum"
}
},
"screens": {
"licenses": "Licenser",
@@ -107,6 +120,7 @@
"darkTheme": "Mørk",
"oledTheme": "OLED",
"libraryDensity": "Bibliotekstæthed",
"displayScale": "Skalering",
"compact": "Kompakt",
"comfortable": "Komfortabel",
"tvCornerSpotlightBackdrop": "Fremhævet baggrundsbillede i hjørnet",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Musikkvalitet",
"subtitleStyling": "Undertekststil",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Aktiver detaljeret logning til fejlfinding",
"viewLogs": "Vis logfiler",
"viewLogsDescription": "Vis programmets logfiler",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Ryd billedcache",
"clearImageCacheDescription": "Ryd cachelagrede covers og thumbnails. Billeder kan indlæse langsommere, indtil de downloades igen.",
"clearImageCacheSuccess": "Billedcachen er ryddet",
"resetSettings": "Nulstil indstillinger",
"resetSettingsDescription": "Gendan standardindstillinger. Dette kan ikke fortrydes.",
"resetSettingsSuccess": "Indstillinger nulstillet",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normaliser lydstyrken ved downmix",
"audioDownmixNormalizeDescription": "Sænk mixets lydstyrke for at undgå clipping. Slå fra for at bevare den oprindelige lydstyrke (høje scener kan blive forvrænget).",
"atmosDiagnostics": "Atmos-outputtest",
"atmosDiagnosticsDescription": "Diagnosticér Dolby Atmos-output ved at afspille testsignaler gennem systemafspilleren",
"atmosTestHlsAtmos": "Apple Atmos-stream",
"atmosTestHlsAtmosDescription": "Kendt god Dolby Atmos-stream. Receiveren bør vise Dolby Atmos.",
"atmosTestHlsControl": "Apple surround-stream",
"atmosTestHlsControlDescription": "Kontrolstream uden Atmos. Receiveren bør vise surround uden Atmos.",
"atmosTestRawStream": "Rå EAC3-stream",
"atmosTestRawStreamDescription": "Streamer testfilen præcis som Atmos-afspilning i afspilleren. Kræver testfilens URL.",
"atmosTestRawFile": "Rå EAC3-fil",
"atmosTestRawFileDescription": "Afspiller testfilen med kendt længde. Kræver testfilens URL.",
"atmosTestAsbarNative": "Sample-buffer-renderer (native)",
"atmosTestAsbarNativeDescription": "Sender filens urørte komprimerede lyd direkte til systemets renderer. Kræver testfilens URL.",
"atmosTestAsbarGenerated": "Sample-buffer-renderer (genopbygget)",
"atmosTestAsbarGeneratedDescription": "Det samme, men med lydbeskrivelsen opbygget som ved afspilning. Kræver testfilens URL.",
"atmosTestSessionMode": "Brug filmafspilningstilstand",
"atmosTestSessionModeDescription": "Fra bruger den tilstand, Dolby dokumenterer. Til bruger den tidligere tilstand.",
"atmosTestShowRoutePicker": "Vælg AirPlay-udgang",
"atmosTestHideRoutePicker": "Skjul AirPlay-udgangsvælger",
"atmosTestRoutePickerDescription": "Sender testen til en AirPlay-modtager. Kun AirPlay rapporterer den valgte lydtilstand.",
"atmosTestStop": "Stop test",
"atmosTestUrl": "Testfilens URL",
"atmosTestUrlDescription": "HTTP-URL til en rå .ec3 Dolby Atmos-fil (f.eks. udtrukket med ffmpeg)",
"atmosTestUrlMissing": "Angiv testfilens URL først",
"atmosTestStatus": "Status",
"dvConversionMode": "Dolby Vision-konvertering",
"dvConversionModeDescription": "Vælg, hvordan ExoPlayer håndterer Dolby Vision Profile 7-filer.",
"dvConversionAuto": "Automatisk",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Vis tekst på navigationslinjen",
"showNavBarLabelsDescription": "Vis tekst under ikonerne på navigationslinjen",
"startupSection": "Startsektion",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Vis favoritkanaler som standard",
"liveTvDefaultFavoritesDescription": "Vis kun favoritkanaler ved åbning af Live TV",
"display": "Skærm",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Filinfo",
"overview": "",
"overview": "Oversigt",
"video": "Video",
"audio": "Lyd",
"subtitles": "Undertekster",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Indlejrede billeder",
"dataStreams": "Datastrømme",
"lyrics": "Tekster",
"file": "Fil",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Vedhæftninger",
"delivery": "Levering",
"versionCounter": "Version ${index} af ${count}",
"fileCounter": "Fil ${index} af ${count}",
"noStreams": "Serveren rapporterede ingen strømme for denne fil.",
"copyPath": "Kopiér sti",
"pathCopied": "Filstien er kopieret",
"codec": "Codec",
"codecTag": "",
"codecTag": "Codec-tag",
"resolution": "Opløsning",
"codedResolution": "",
"codedResolution": "Kodet opløsning",
"bitrate": "Bitrate",
"frameRate": "Billedhastighed",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotation",
"comment": "Kommentar",
"audioDescription": "Lydbeskrivelse",
"headerCompression": "Headerkomprimering",
"sidecarFile": "Sidecar-fil",
"transportTimestamp": "Transporttidsstempel",
"displayOffset": "Visningsforskydning",
"previewFailureCode": "Preview-fejlkode",
"previewRetries": "Preview-forsøg",
"aspectRatio": "Billedformat",
"pixelAspectRatio": "",
"pixelAspectRatio": "Pixel-billedformat",
"profile": "Profil",
"level": "",
"level": "Niveau",
"bitDepth": "Bitdybde",
"pixelFormat": "",
"pixelFormat": "Pixelformat",
"colorSpace": "Farverum",
"colorRange": "Farveområde",
"colorPrimaries": "Farveprimærer",
"colorTransfer": "",
"colorTransfer": "Farveoverførsel",
"chromaSubsampling": "Chroma-subsampling",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Chroma-placering",
"scanType": "Scanningstype",
"interlaced": "Interlaced",
"anamorphic": "Anamorfisk",
"referenceFrames": "Referenceframes",
"dynamicRange": "Dynamisk område",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision-niveau",
"dolbyVisionVersion": "Dolby Vision-version",
"dolbyVisionLayers": "Dolby Vision-lag",
"baseLayerCompatibility": "Basislagskompatibilitet",
"avcBitstream": "AVC-bitstrøm",
"nalLengthSize": "NAL-længdestørrelse",
"scalingMatrix": "Brugerdefineret skaleringsmatrix",
"streamIdentifier": "Strømidentifikator",
"streamIndex": "Strømindex",
"streamId": "Strøm-ID",
"language": "Sprog",
"languageCode": "Sprogkode",
"streamTitle": "Sportitel",
"channels": "Kanaler",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Samplingshastighed",
"spatialAudio": "Rumlig lyd",
"textBased": "Tekstbaseret",
"subtitleFormat": "Sidecar-format",
"provider": "Udbydder",
"matchScore": "Matchscore",
"externalDelivery": "Kan leveres separat",
"sidecarPath": "Sidecar-sti",
"sourceStream": "Kopieret fra",
"temporary": "Midlertidig",
"timeBase": "Tidsbase",
"overallBitrate": "Samlet bitrate",
"path": "Sti",
"fileName": "",
"fileName": "Filnavn",
"size": "Størrelse",
"totalSize": "",
"totalSize": "Samlet størrelse",
"container": "Container",
"duration": "Varighed",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Preview-thumbnails",
"previewIndex": "Preview-index",
"packetLength": "Pakkelængde",
"filePresent": "Fil til stede",
"fileReadable": "Læsbar af serveren",
"streamPath": "Strømsti",
"optimizedForStreaming": "Optimeret til streaming",
"has64bitOffsets": "64-bit-forskydninger",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protokol",
"mediaType": "Medietype",
"sourceKind": "Kildetype",
"optimizedVersion": "Optimeret version",
"optimizationTarget": "Optimeringsmål",
"deletedAt": "Slettet",
"remoteSource": "Fjernkilde",
"infiniteStream": "Uendelig strøm",
"directPlay": "Direkte afspilning",
"directStream": "Direkte strømning",
"transcoding": "Transkodning",
"etag": "ETag",
"versionId": "Versions-ID",
"fileId": "Fil-ID",
"defaultAudioTrack": "Standard lydspor",
"defaultSubtitleTrack": "Standard undertekstspor",
"subtitlesOff": "Fra",
"flagDefault": "Standard",
"flagForced": "Tvunget",
"flagSelected": "Valgt",
"flagExternal": "Ekstern",
"flagHearingImpaired": "Hørehæmmede",
"flagDub": "Dub",
"flagOriginal": "Original"
},
"mediaMenu": {
"markAsWatched": "Markér som set",
@@ -505,9 +502,35 @@
"shufflePlay": "Afspil tilfældigt",
"shuffleNotAvailableOffline": "Tilfældig afspilning er ikke tilgængelig offline",
"fileInfo": "Filinfo",
"deleteFromServer": "Slet fra server",
"confirmDelete": "Slet dette medie og dets filer fra din server?",
"deleteEpisodeFromServer": "Slet episode fra serveren",
"deleteSeasonFromServer": "Slet sæson fra serveren",
"deleteShowFromServer": "Slet TV-serie fra serveren",
"deleteMovieFromServer": "Slet film fra serveren",
"deleteEpisodeTitle": "Slet denne episode?",
"deleteSeasonTitle": "Slet denne sæson?",
"deleteShowTitle": "Slet denne TV-serie?",
"deleteMovieTitle": "Slet denne film?",
"deleteEpisodeConfirm": "Slet episode",
"deleteSeasonConfirm": "Slet sæson",
"deleteShowConfirm": "Slet TV-serie",
"deleteMovieConfirm": "Slet film",
"deleteAnyway": "Slet alligevel",
"confirmDeleteTarget": "Slet ${title} permanent fra din server?",
"deleteMultipleWarning": "Dette inkluderer alle episoder og deres filer.",
"deleteEpisodeCountWarning": {
"one": "Dette sletter alle ${n} episode i den og dens fil.",
"other": "Dette sletter alle ${n} episoder i den og deres filer."
},
"deleteMultiPartWarning": {
"one": "Dette emne er gemt som ${n} fil, som vil blive slettet.",
"other": "Dette emne er gemt på tværs af ${n} filer, og alle vil blive slettet."
},
"deleteSharedFileHeading": {
"one": "${n} anden episode er gemt i samme fil og vil også blive slettet:",
"other": "${n} andre episoder er gemt i samme fil og vil også blive slettet:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy kunne ikke kontrollere, hvilke filer dette vil fjerne, så det kan slette mere end det ovennævnte emne. Annuller og prøv igen, eller slet alligevel.",
"deleteScopeUnverifiedNoFileInfo": "Din server leverede ikke filoplysninger for dette emne, så Plezy kan ikke kontrollere, hvilke filer dette vil fjerne. Det kan slette mere end det ovennævnte emne.",
"mediaDeletedSuccessfully": "Mediet blev slettet",
"mediaFailedToDelete": "Mediet kunne ikke slettes",
"rate": "Bedøm",
@@ -583,8 +606,9 @@
"playNext": "Afspil næste",
"playButton": "Afspil",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Pauseret",
"playbackResumed": "Afspiller",
"loadingVideo": "Indlæser video",
"showPlaybackControls": "Vis afspilningsknapper",
"hidePlaybackControls": "Skjul afspilningsknapper",
"seekBackwardButton": "Spol ${seconds} sekunder tilbage",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Ingen kapitler tilgængelige",
"queue": "Kø",
"noQueueItems": "Ingen elementer i køen",
"noAudioDevicesAvailable": "Ingen lydenheder tilgængelige",
"searchSubtitles": "Søg undertekster",
"language": "Sprog",
"noSubtitlesFound": "Ingen undertekster fundet",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Fjernet fra Fortsæt med at se",
"errorLoading": "Fejl: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Nogle medieservere kunne ikke søges i. Viser tilgængelige resultater.",
"streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.",
"liveStreamInterrupted": "Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.",
"fileInfoNotAvailable": "Filinfo ikke tilgængelig",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Skifter til kompatibel afspiller...",
"serverLimitTitle": "Afspilning mislykkedes",
"serverLimitBody": "Serverfejl (HTTP 500). En båndbredde- eller transkodningsgrænse afviste sandsynligvis sessionen. Bed ejeren om at justere den.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Fil utilgængelig",
"mediaUnreadableBody": "Serveren fandt dette emne, men kunne ikke læse filen (HTTP 404). Filen er sandsynligvis flyttet, slettet, eller dens lager er offline. Bed serverejeren om at kontrollere filen og scanne biblioteket igen.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Logfilerne blev uploadet",
"logsUploadFailed": "Logfilerne kunne ikke uploades",
"logId": "Log-ID"
"logId": "Log-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
@@ -718,6 +746,8 @@
"overrideStrip": "Fjern formatering",
"positionTop": "Øverst",
"positionBottom": "Nederst",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fed",
"italic": "Kursiv",
"renderResolution": "Gengivelsesopløsning",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Ingen forbindelser — tilføj en for at bruge denne profil.",
"noConnections": "Ingen forbindelser",
"plexHomeAccount": "Plex Home-konto",
"plexAccountChip": "Plex-konto: ${account}",
"plexAccountUserChip": "${user} via ${account}",
"connectionDefault": "Standard",
"connectionAs": "som ${displayName}",
"makeDefault": "Gør til standard",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Sessionen er udløbet for ${name}",
"sessionExpiredMany": "Sessionerne er udløbet for ${count} servere",
"signInAgain": "Log ind igen",
"editJellyfinTitle": "Rediger Jellyfin-forbindelse",
"editJellyfinIntro": "Tilføj eller fjern URL'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid."
"editMediaBrowserTitle": "Rediger ${product}-forbindelse",
"editMediaBrowserIntro": "Tilføj eller fjern URL'er for ${serverName}. Plezy bruger den tilgængelige URL med laveste latenstid."
},
"discover": {
"title": "Opdag",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Søgning mislykkedes: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Søgningen kunne ikke nå nogen medieserver.",
"connectionTimeout": "Forbindelsen fik timeout under indlæsning af ${context}",
"connectionFailed": "Kan ikke oprette forbindelse til medieserver",
"unableToLoad": "Kunne ikke indlæse ${context}. Prøv igen.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Upload logfiler"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy kunne ikke starte",
"failedBody": "Der gik noget galt under opstart. Oplysningerne nedenfor viser, hvad der gik galt.",
"failedBodyRepairable": "Plezy's gemte indstillingsfil er beskadiget og skal genopbygges, før Plezy kan starte. Det hjælper ikke at prøve igen — vælg Reparer lager.",
"phaseLabel": "Trin",
"showDetails": "Vis detaljer",
"hideDetails": "Skjul detaljer",
"copyDetails": "Kopiér detaljer",
"detailsCopied": "Detaljer kopieret til udklipsholder",
"uploadDetails": "Upload detaljer",
"repairStorage": "Reparer lager",
"repairTitle": "Reparer gemte data?",
"repairBodyCommon": "Plezy's indstillingsfil er beskadiget og kan ikke læses. Reparation nulstiller alle indstillinger til deres standardværdier.",
"repairBodyOneCredential": "Én gemt loginoplysning er beskadiget og kan ikke læses. Reparation fjerner kun den; dine andre indstillinger forbliver urørt.",
"repairBodySignInsKept": "Dine servere og profiler bør forblive logget ind.",
"repairBodySignInsLost": "Nøglen, der beskytter dine gemte loginoplysninger, kan ikke gendannes fra denne fil, så du skal logge ind på alle servere og profiler igen. Intet på din medieserver er påvirket.",
"repairBodySessionsUncertain": "Trackere (MAL, AniList, Simkl, Trakt) og Seerr gemmes separat, og det er uvist, om de overlever. Plezy fortæller dig præcis, hvad det beholdt.",
"repairConfirm": "Reparer",
"repairSucceeded": "Lager repareret",
"repairNeedsRestart": "Lager repareret — genstart påkrævet",
"restartRequiredBody": "Dine data er blevet repareret, men Plezy skal starte på ny, før det kan bruge dem. Luk Plezy, og åbn det igen.",
"quitPlezy": "Afslut Plezy",
"repairFailed": "Reparation mislykkedes",
"repairKeptSignIns": "Dine servere og profiler er stadig logget ind.",
"repairLostSignIns": "Nøglen, der beskytter dine gemte loginoplysninger, kunne ikke gendannes. Du skal logge ind på alle servere og profiler igen.",
"repairLostSessions": "Mindst én tracker- eller Seerr-forbindelse gik tabt og skal genetableres.",
"backupTitle": "En kopi af den beskadigede fil blev gemt",
"backupWarning": "Den indeholder dine loginoplysninger. Upload eller del den ikke.",
"deleteBackup": "Slet kopi",
"backupDeleted": "Kopi slettet.",
"previousFailureTitle": "Plezy kunne ikke starte sidste gang"
},
"licenses": {
"relatedPackages": "Relaterede pakker",
@@ -1043,7 +1080,10 @@
"characters": "Figurer",
"addToWatchlist": "Føj til ønskeliste",
"removeFromWatchlist": "Fjern fra ønskeliste",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Kunne ikke opdatere ønskelisten",
"watchlistNoMatch": "",
"notInLibrary": "Ikke i dit bibliotek",
"inTheseLibraries": "I disse biblioteker",
"checkingLibrary": "Tjekker dit bibliotek...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Søg efter film og serier på ${source}.",
"searchFailed": "Søgningen mislykkedes. Tjek din forbindelse, og prøv igen.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"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",
"available": "Tilgængelig",
"partiallyAvailable": "Delvist tilgængelig",
"availableIn4k": "4K tilgængelig",
"requested": "Anmodet",
"pendingApproval": "Afventer godkendelse",
"processing": "Behandles",
"declined": "Afvist",
"requestFailed": "Anmodningen mislykkedes",
"requested4k": "4K anmodet",
"seasonsAvailable": "${available}/${total} sæsoner",
"nextEpisodeIn": "Afsnit ${episode} om ${duration}",
"nextAiringIn": "Næste om ${duration}",
"episodesShort": "${n} afsnit",
"minutesPerEpisode": "${n} min/afsnit",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} opført",
"viewersDay": "${n} så det i dag",
"viewersWeek": "${n} så det denne uge",
"viewersMonth": "${n} så det denne måned",
"viewersYear": "${n} så det i år",
"viewersAllTime": "${n} seere",
"planning": "${n} planlægger at se det",
"favorited": "${n} favoritter",
"dropRate": "${percent} droppede det",
"comments": {
"one": "",
"other": ""
"one": "${n} kommentar",
"other": "${n} kommentarer"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} stemmer",
"watching": "${n} ser det",
"completed": "${n} færdigset",
"onHold": "${n} på pause",
"dropped": "${n} droppede"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Vinter",
"spring": "Forår",
"summer": "Sommer",
"fall": "Efterår",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV-kort",
"movie": "Film",
"special": "Special",
"ova": "OVA",
"ona": "ONA",
"music": "Musik",
"other": "Andet"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Original",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Roman",
"visualNovel": "Visual novel",
"game": "Spil",
"webComic": "Webtegneserie",
"musicRelease": "Musik",
"otherMedia": "Andet"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Instruktør",
"writer": "Forfatter",
"producer": "Producer",
"creator": "Skaber",
"composer": "Komponist"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Bihistorie",
"spinOff": "Spin-off",
"alternativeVersion": "Alternativ version",
"summary": "Resumé",
"parentStory": "Hovedhistorie",
"adaptation": "Adaption",
"other": "Relateret"
},
"broadcast": "Sendes ${day} kl. ${time}",
"broadcastWithZone": "Sendes ${day} kl. ${time} ${timezone}",
"detail": {
"originalTitle": "Originaltitel",
"alsoKnownAs": "Også kendt som",
"studios": "Studier",
"country": "Land",
"language": "Sprog",
"released": "Udgivet",
"physicalRelease": "På disk",
"ended": "Afsluttet",
"addedOn": "Tilføjet ${date}",
"yourRating": "Din bedømmelse",
"budget": "Budget",
"revenue": "Billetindtægter",
"contentAdvisory": "Aldersvejledning",
"tags": "Tags",
"revealSpoilerTags": "Vis spoilertags",
"links": "Links",
"watchOn": "Se på",
"watchTrailer": "Se trailer",
"openOn": "Åbn på ${site}",
"crew": "Crew",
"ratings": "Bedømmelser",
"schedule": "Program",
"recommendedByUsers": {
"one": "Anbefalet af ${n} bruger",
"other": "Anbefalet af ${n} brugere"
},
"recommendedBy": "Anbefalet af ${who}",
"favoritedBy": "Favorit hos ${who}",
"unairedEpisodes": "${n} ikke sendt endnu",
"recommendedByPercent": "Anbefalet af ${percent} af seerne",
"relatedTitles": "Relaterede titler",
"background": "Baggrund"
},
"totalResults": {
"one": "${n} resultat",
"other": "${n} resultater"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Se kanal",
"favorites": "Favoritter",
"reorderFavorites": "Omarranger favoritter",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.",
"favoritesUpdateFailed": "Favoritterne kunne ikke opdateres. Kontrollér forbindelsen, og prøv igen.",
"joinSession": "Deltag i igangværende session",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Rediger synkroniseringsregel",
"removeSyncRule": "Fjern synkroniseringsregel",
"removeSyncRuleConfirm": "Stop synkronisering af \"${title}\"? Downloadede episoder beholdes.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Stop synkronisering af \"${title}\"?",
"deleteSyncRuleDownloads": "Slet også tilknyttede downloads",
"deleteSyncRuleDownloadsDescription": "Downloads, der bruges af en anden synkroniseringsregel eller profil, beholdes.",
"syncRuleCreated": "Synkroniseringsregel oprettet — beholder ${count} usete episoder",
"syncRuleUpdated": "Synkroniseringsregel opdateret",
"syncRuleRemoved": "Synkroniseringsregel fjernet",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Synkroniseringsregel og tilknyttede downloads fjernet",
"syncRuleCleanupBusy": "Synkroniseringsregler opdateres lige nu. Prøv igen om et øjeblik.",
"syncRuleCleanupUnavailable": "Tilknyttede downloads kunne ikke identificeres sikkert. Genopret forbindelse til serveren og prøv igen, eller fjern reglen uden at slette downloads.",
"syncedNewEpisodes": "Synkroniserede ${count} nye episoder for ${title}",
"activeSyncRules": "Synkroniseringsregler",
"noSyncRules": "Ingen synkroniseringsregler",
@@ -1614,9 +1644,15 @@
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Lydoutput",
"performanceOverlay": "Ydelsesoverlay",
"audioPassthrough": "Lyd-passthrough",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Aktiver Plezy på ${service}",
"body": "Besøg ${url} og indtast denne kode:",
"instructions": "",
"openToActivate": "Åbn ${service} for at aktivere",
"copyCode": "Kopiér aktiveringskode",
"waitingForAuthorization": "Venter på godkendelse…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Log ind på ${service}",
"body": "Scan denne QR-kode, eller åbn URL'en på en enhed.",
"openToSignIn": "Åbn ${service} for at logge ind",
"openToSignIn": "Åbn ${service} for at logge ind"
},
"pendingAuth": {
"copyUrl": "Kopiér login-URL",
"urlCopied": "URL kopieret"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Tilføj Jellyfin-server",
"addMediaBrowserTitle": "Tilføj ${product}-server",
"serverUrls": "Server-URL'er",
"serverUrlsHelper": "Du kan angive flere URL'er adskilt med komma.",
"findServer": "Find server",
"searchingLocalServers": "Søger efter lokale Jellyfin-servere...",
"localServers": "Lokale Jellyfin-servere",
"searchingLocalMediaBrowserServers": "Leder efter lokale ${product}-servere...",
"localMediaBrowserServers": "Lokale ${product}-servere",
"username": "Brugernavn",
"password": "Adgangskode",
"signIn": "Log ind",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Log ind med Plex",
"pinExpired": "PIN-koden udløb før login. Prøv igen.",
"failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}",
"enterJellyfinUrlError": "Angiv URL'en til din Jellyfin-server",
"enterMediaBrowserUrlError": "Indtast din ${product}-server-URL",
"addConnectionTitle": "Tilføj forbindelse",
"addConnectionTitleScoped": "Tilføj til ${name}",
"signInWithPlexCard": "Log ind med Plex",
"signInWithPlexCardSubtitle": "Godkend denne enhed. Delte servere tilføjes.",
"signInWithPlexCardSubtitleScoped": "Godkend en Plex-konto. Plex Home-brugere bliver til profiler.",
"connectToJellyfinCard": "Forbind til Jellyfin",
"connectToJellyfinCardSubtitle": "Indtast din server-URL, dit brugernavn og din adgangskode.",
"connectToJellyfinCardSubtitleScoped": "Log ind på en Jellyfin-server. Serveren knyttes til ${name}.",
"connectToMediaBrowserCard": "Opret forbindelse til ${product}",
"connectToMediaBrowserCardSubtitle": "Indtast din server-URL, brugernavn og adgangskode.",
"connectToMediaBrowserCardSubtitleScoped": "Log ind på din ${product}-server. Tilknyttes til ${name}.",
"borrowFromAnotherProfile": "Lån fra en anden profil",
"borrowFromAnotherProfileSubtitle": "Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.",
"useBrowser": "Browser verwenden",
"or": "oder",
"connectToJellyfin": "Mit Jellyfin verbinden",
"connectToMediaBrowser": "Mit ${product} verbinden",
"useQuickConnect": "Quick Connect verwenden",
"quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.",
"quickConnectWaiting": "Warte auf Bestätigung…",
@@ -64,7 +64,20 @@
"loading": "Wird geladen …",
"fullscreen": "Vollbild",
"exitFullscreen": "Vollbild verlassen",
"pressBackAgainToExit": "Zum Beenden erneut Zurück drücken"
"pressBackAgainToExit": "Zum Beenden erneut Zurück drücken",
"ratingSource": {
"critic": "Kritiker",
"audience": "Publikum",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten-Tomatoes-Kritiker",
"rottenTomatoesAudience": "Rotten-Tomatoes-Publikum"
}
},
"screens": {
"licenses": "Lizenzen",
@@ -107,6 +120,7 @@
"darkTheme": "Dunkel",
"oledTheme": "OLED",
"libraryDensity": "Darstellungsdichte der Mediathek",
"displayScale": "Skalierung",
"compact": "Kompakt",
"comfortable": "Großzügig",
"tvCornerSpotlightBackdrop": "Backdrop in der Ecke",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Musikqualität",
"subtitleStyling": "Untertitel-Stil",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren",
"viewLogs": "Protokolle anzeigen",
"viewLogsDescription": "App-Protokolle anzeigen",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Bildercache leeren",
"clearImageCacheDescription": "Zwischengespeicherte Artworks und Vorschaubilder löschen. Bilder können langsamer laden, bis sie erneut heruntergeladen werden.",
"clearImageCacheSuccess": "Bildercache erfolgreich geleert",
"resetSettings": "Einstellungen zurücksetzen",
"resetSettingsDescription": "Standardeinstellungen wiederherstellen. Dies kann nicht rückgängig gemacht werden.",
"resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Lautstärke beim Downmix normalisieren",
"audioDownmixNormalizeDescription": "Senkt den Mix ab, um Übersteuerung zu vermeiden. Deaktivieren, um die Originallautstärke zu behalten (laute Szenen können verzerren).",
"atmosDiagnostics": "Atmos-Ausgabetest",
"atmosDiagnosticsDescription": "Dolby-Atmos-Ausgabe diagnostizieren, indem Testsignale über den Systemplayer abgespielt werden",
"atmosTestHlsAtmos": "Apple-Atmos-Stream",
"atmosTestHlsAtmosDescription": "Garantiert funktionierender Dolby-Atmos-Stream. Der Receiver sollte Dolby Atmos anzeigen.",
"atmosTestHlsControl": "Apple-Surround-Stream",
"atmosTestHlsControlDescription": "Kontrollstream ohne Atmos. Der Receiver sollte Surround ohne Atmos anzeigen.",
"atmosTestRawStream": "Roher EAC3-Stream",
"atmosTestRawStreamDescription": "Streamt die Testdatei genau wie die Atmos-Wiedergabe im Player. Benötigt die URL der Testdatei.",
"atmosTestRawFile": "Rohe EAC3-Datei",
"atmosTestRawFileDescription": "Spielt die Testdatei mit bekannter Länge ab. Benötigt die URL der Testdatei.",
"atmosTestAsbarNative": "Sample-Buffer-Renderer (nativ)",
"atmosTestAsbarNativeDescription": "Übergibt die unveränderte komprimierte Audiospur direkt an den System-Renderer. Benötigt die URL der Testdatei.",
"atmosTestAsbarGenerated": "Sample-Buffer-Renderer (neu erstellt)",
"atmosTestAsbarGeneratedDescription": "Dasselbe, aber mit der Audiobeschreibung wie bei der Wiedergabe erstellt. Benötigt die URL der Testdatei.",
"atmosTestSessionMode": "Filmwiedergabe-Modus verwenden",
"atmosTestSessionModeDescription": "Aus verwendet den von Dolby dokumentierten Modus. Ein verwendet den bisherigen Modus.",
"atmosTestShowRoutePicker": "AirPlay-Ausgabe wählen",
"atmosTestHideRoutePicker": "AirPlay-Auswahl ausblenden",
"atmosTestRoutePickerDescription": "Sendet den Test an einen AirPlay-Empfänger. Nur AirPlay meldet den ermittelten Audiomodus.",
"atmosTestStop": "Test stoppen",
"atmosTestUrl": "URL der Testdatei",
"atmosTestUrlDescription": "HTTP-URL einer rohen .ec3-Dolby-Atmos-Datei (z. B. mit ffmpeg extrahiert)",
"atmosTestUrlMissing": "Zuerst die URL der Testdatei festlegen",
"atmosTestStatus": "Status",
"dvConversionMode": "Dolby-Vision-Konvertierung",
"dvConversionModeDescription": "Wähle, wie ExoPlayer Dateien mit Dolby-Vision-Profil 7 behandelt.",
"dvConversionAuto": "Automatisch",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Navigationsleisten-Beschriftungen anzeigen",
"showNavBarLabelsDescription": "Textbeschriftungen unter den Symbolen der Navigationsleiste anzeigen",
"startupSection": "Startbereich",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Standardmäßig Favoritenkanäle",
"liveTvDefaultFavoritesDescription": "Beim Öffnen von Live TV nur Favoritenkanäle anzeigen",
"display": "Anzeige",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Dateiinformationen",
"overview": "",
"overview": "Übersicht",
"video": "Video",
"audio": "Audio",
"subtitles": "Untertitel",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Eingebettete Bilder",
"dataStreams": "Datenstreams",
"lyrics": "Songtexte",
"file": "Datei",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Anhänge",
"delivery": "Bereitstellung",
"versionCounter": "Version ${index} von ${count}",
"fileCounter": "Datei ${index} von ${count}",
"noStreams": "Der Server hat für diese Datei keine Streams gemeldet.",
"copyPath": "Pfad kopieren",
"pathCopied": "Dateipfad kopiert",
"codec": "Codec",
"codecTag": "",
"codecTag": "Codec-Tag",
"resolution": "Auflösung",
"codedResolution": "",
"codedResolution": "Codierte Auflösung",
"bitrate": "Bitrate",
"frameRate": "Bildrate",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotation",
"comment": "Kommentar",
"audioDescription": "Audiodeskription",
"headerCompression": "Header-Komprimierung",
"sidecarFile": "Sidecar-Datei",
"transportTimestamp": "Transport-Zeitstempel",
"displayOffset": "Anzeige-Offset",
"previewFailureCode": "Fehlercode der Vorschau",
"previewRetries": "Vorschau-Wiederholungsversuche",
"aspectRatio": "Seitenverhältnis",
"pixelAspectRatio": "",
"pixelAspectRatio": "Pixel-Seitenverhältnis",
"profile": "Profil",
"level": "",
"level": "Level",
"bitDepth": "Bittiefe",
"pixelFormat": "",
"pixelFormat": "Pixelformat",
"colorSpace": "Farbraum",
"colorRange": "Farbbereich",
"colorPrimaries": "Primärfarben",
"colorTransfer": "",
"colorTransfer": "Farbübertragung",
"chromaSubsampling": "Chroma-Subsampling",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Chroma-Position",
"scanType": "Scan-Typ",
"interlaced": "Interlaced",
"anamorphic": "Anamorph",
"referenceFrames": "Referenzbilder",
"dynamicRange": "Dynamikbereich",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby-Vision-Level",
"dolbyVisionVersion": "Dolby-Vision-Version",
"dolbyVisionLayers": "Dolby-Vision-Ebenen",
"baseLayerCompatibility": "Kompatibilität der Basisebene",
"avcBitstream": "AVC-Bitstream",
"nalLengthSize": "NAL-Längengröße",
"scalingMatrix": "Benutzerdefinierte Skalierungsmatrix",
"streamIdentifier": "Stream-Kennung",
"streamIndex": "Stream-Index",
"streamId": "Stream-ID",
"language": "Sprache",
"languageCode": "Sprachcode",
"streamTitle": "Track-Titel",
"channels": "Kanäle",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Abtastrate",
"spatialAudio": "Räumliches Audio",
"textBased": "Textbasiert",
"subtitleFormat": "Sidecar-Format",
"provider": "Anbieter",
"matchScore": "Übereinstimmungswert",
"externalDelivery": "Kann separat bereitgestellt werden",
"sidecarPath": "Sidecar-Pfad",
"sourceStream": "Kopiert von",
"temporary": "Temporär",
"timeBase": "Zeitbasis",
"overallBitrate": "Gesamtbitrate",
"path": "Pfad",
"fileName": "",
"fileName": "Dateiname",
"size": "Größe",
"totalSize": "",
"totalSize": "Gesamtgröße",
"container": "Container",
"duration": "Dauer",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Vorschaubilder",
"previewIndex": "Vorschau-Index",
"packetLength": "Paketlänge",
"filePresent": "Datei vorhanden",
"fileReadable": "Vom Server lesbar",
"streamPath": "Stream-Pfad",
"optimizedForStreaming": "Für Streaming optimiert",
"has64bitOffsets": "64-Bit-Offsets",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protokoll",
"mediaType": "Medientyp",
"sourceKind": "Quellenart",
"optimizedVersion": "Optimierte Version",
"optimizationTarget": "Optimierungsziel",
"deletedAt": "Gelöscht",
"remoteSource": "Remote-Quelle",
"infiniteStream": "Endlos-Stream",
"directPlay": "Direct Play",
"directStream": "Direct Stream",
"transcoding": "Transkodierung",
"etag": "ETag",
"versionId": "Versions-ID",
"fileId": "Datei-ID",
"defaultAudioTrack": "Standard-Audiospur",
"defaultSubtitleTrack": "Standard-Untertitelspur",
"subtitlesOff": "Aus",
"flagDefault": "Standard",
"flagForced": "Erzwungen",
"flagSelected": "Ausgewählt",
"flagExternal": "Extern",
"flagHearingImpaired": "Für Hörgeschädigte",
"flagDub": "Dub",
"flagOriginal": "Original"
},
"mediaMenu": {
"markAsWatched": "Als gesehen markieren",
@@ -505,9 +502,35 @@
"shufflePlay": "Zufallswiedergabe",
"shuffleNotAvailableOffline": "Zufallswiedergabe ist offline nicht verfügbar",
"fileInfo": "Dateiinfo",
"deleteFromServer": "Vom Server löschen",
"confirmDelete": "Dieses Medium und seine Dateien von deinem Server löschen?",
"deleteEpisodeFromServer": "Folge vom Server löschen",
"deleteSeasonFromServer": "Staffel vom Server löschen",
"deleteShowFromServer": "Serie vom Server löschen",
"deleteMovieFromServer": "Film vom Server löschen",
"deleteEpisodeTitle": "Diese Folge löschen?",
"deleteSeasonTitle": "Diese Staffel löschen?",
"deleteShowTitle": "Diese Serie löschen?",
"deleteMovieTitle": "Diesen Film löschen?",
"deleteEpisodeConfirm": "Folge löschen",
"deleteSeasonConfirm": "Staffel löschen",
"deleteShowConfirm": "Serie löschen",
"deleteMovieConfirm": "Film löschen",
"deleteAnyway": "Trotzdem löschen",
"confirmDeleteTarget": "„${title}“ dauerhaft von deinem Server löschen?",
"deleteMultipleWarning": "Dies umfasst alle Episoden und deren Dateien.",
"deleteEpisodeCountWarning": {
"one": "Dies löscht alle ${n} Folge darin sowie ihre Datei.",
"other": "Dies löscht alle ${n} Folgen darin sowie ihre Dateien."
},
"deleteMultiPartWarning": {
"one": "Dieses Element ist als ${n} Datei gespeichert, die gelöscht wird.",
"other": "Dieses Element ist über ${n} Dateien verteilt gespeichert, die alle gelöscht werden."
},
"deleteSharedFileHeading": {
"one": "${n} weitere Folge ist in derselben Datei gespeichert und wird ebenfalls gelöscht:",
"other": "${n} weitere Folgen sind in derselben Datei gespeichert und werden ebenfalls gelöscht:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy konnte nicht prüfen, welche Dateien dadurch entfernt werden. Es könnte also mehr gelöscht werden als das oben genannte Element. Brich ab und versuche es erneut, oder lösche trotzdem.",
"deleteScopeUnverifiedNoFileInfo": "Dein Server hat für dieses Element keine Dateidetails bereitgestellt, daher kann Plezy nicht prüfen, welche Dateien entfernt werden. Es könnte mehr gelöscht werden als das oben genannte Element.",
"mediaDeletedSuccessfully": "Medienelement gelöscht",
"mediaFailedToDelete": "Medienelement konnte nicht gelöscht werden",
"rate": "Bewerten",
@@ -583,8 +606,9 @@
"playNext": "Nächstes abspielen",
"playButton": "Abspielen",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Pausiert",
"playbackResumed": "Wird abgespielt",
"loadingVideo": "Video wird geladen",
"showPlaybackControls": "Wiedergabesteuerung anzeigen",
"hidePlaybackControls": "Wiedergabesteuerung ausblenden",
"seekBackwardButton": "${seconds} Sekunden zurück",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Keine Kapitel verfügbar",
"queue": "Warteschlange",
"noQueueItems": "Keine Elemente in der Warteschlange",
"noAudioDevicesAvailable": "Keine Audiogeräte verfügbar",
"searchSubtitles": "Untertitel suchen",
"language": "Sprache",
"noSubtitlesFound": "Keine Untertitel gefunden",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Aus „Weiterschauen“ entfernt",
"errorLoading": "Fehler: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Einige Medienserver konnten nicht durchsucht werden. Verfügbare Ergebnisse werden angezeigt.",
"streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.",
"liveStreamInterrupted": "Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Wechsel zu einem kompatiblen Player …",
"serverLimitTitle": "Wiedergabe fehlgeschlagen",
"serverLimitBody": "Serverfehler (HTTP 500). Vermutlich hat ein Bandbreiten- oder Transkodierungslimit diese Sitzung abgelehnt. Bitte den Besitzer, das Limit anzupassen.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Datei nicht verfügbar",
"mediaUnreadableBody": "Der Server hat dieses Element gefunden, konnte seine Datei aber nicht lesen (HTTP 404). Die Datei wurde wahrscheinlich verschoben oder gelöscht, oder ihr Speicher ist offline. Bitte den Serverbesitzer, die Datei zu prüfen und die Mediathek neu zu scannen.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Protokolle hochgeladen",
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
"logId": "Protokoll-ID"
"logId": "Protokoll-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Text",
@@ -718,6 +746,8 @@
"overrideStrip": "Formatierung entfernen",
"positionTop": "Oben",
"positionBottom": "Unten",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fett",
"italic": "Kursiv",
"renderResolution": "Render-Auflösung",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Keine Verbindungen — füge eine hinzu, um dieses Profil zu nutzen.",
"noConnections": "Keine Verbindungen",
"plexHomeAccount": "Plex Home-Konto",
"plexAccountChip": "Plex-Konto: ${account}",
"plexAccountUserChip": "${user} über ${account}",
"connectionDefault": "Standard",
"connectionAs": "als ${displayName}",
"makeDefault": "Als Standard festlegen",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Sitzung für ${name} abgelaufen",
"sessionExpiredMany": "Sitzungen für ${count} Server abgelaufen",
"signInAgain": "Erneut anmelden",
"editJellyfinTitle": "Jellyfin-Verbindung bearbeiten",
"editJellyfinIntro": "Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz."
"editMediaBrowserTitle": "Verbindung zu ${product} bearbeiten",
"editMediaBrowserIntro": "URLs für ${serverName} hinzufügen oder entfernen. Plezy verwendet die erreichbare URL mit der geringsten Latenz."
},
"discover": {
"title": "Entdecken",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Suche fehlgeschlagen: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Die Suche konnte keinen Medienserver erreichen.",
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
"connectionFailed": "Keine Verbindung zum Medienserver möglich",
"unableToLoad": "${context} konnte nicht geladen werden. Bitte erneut versuchen.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Protokolle hochladen"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy konnte nicht gestartet werden",
"failedBody": "Beim Start ist etwas schiefgelaufen. Die Details unten zeigen, was fehlgeschlagen ist.",
"failedBodyRepairable": "Die gespeicherte Einstellungsdatei von Plezy ist beschädigt und muss neu aufgebaut werden, bevor Plezy starten kann. Erneutes Versuchen hilft nicht – wähle „Speicher reparieren“.",
"phaseLabel": "Schritt",
"showDetails": "Details anzeigen",
"hideDetails": "Details ausblenden",
"copyDetails": "Details kopieren",
"detailsCopied": "Details in die Zwischenablage kopiert",
"uploadDetails": "Details hochladen",
"repairStorage": "Speicher reparieren",
"repairTitle": "Gespeicherte Daten reparieren?",
"repairBodyCommon": "Die Einstellungsdatei von Plezy ist beschädigt und kann nicht gelesen werden. Beim Reparieren werden alle Einstellungen auf ihre Standardwerte zurückgesetzt.",
"repairBodyOneCredential": "Eine gespeicherte Anmeldung ist beschädigt und kann nicht gelesen werden. Beim Reparieren wird nur diese entfernt; deine übrigen Einstellungen bleiben unangetastet.",
"repairBodySignInsKept": "Deine Server und Profile sollten angemeldet bleiben.",
"repairBodySignInsLost": "Der Schlüssel, der deine gespeicherten Anmeldungen schützt, kann aus dieser Datei nicht wiederhergestellt werden. Du musst dich daher bei jedem Server und Profil erneut anmelden. Auf deinem Medienserver ändert sich nichts.",
"repairBodySessionsUncertain": "Tracker (MAL, AniList, Simkl, Trakt) und Seerr werden separat gespeichert und können erhalten bleiben oder auch nicht. Plezy teilt dir genau mit, was erhalten blieb.",
"repairConfirm": "Reparieren",
"repairSucceeded": "Speicher repariert",
"repairNeedsRestart": "Speicher repariert – Neustart erforderlich",
"restartRequiredBody": "Deine Daten wurden repariert, aber Plezy muss neu starten, bevor es sie verwenden kann. Schließe Plezy und öffne es erneut.",
"quitPlezy": "Plezy beenden",
"repairFailed": "Reparatur fehlgeschlagen",
"repairKeptSignIns": "Deine Server und Profile sind weiterhin angemeldet.",
"repairLostSignIns": "Der Schlüssel, der deine gespeicherten Anmeldungen schützt, konnte nicht wiederhergestellt werden. Du musst dich bei jedem Server und Profil erneut anmelden.",
"repairLostSessions": "Mindestens eine Tracker- oder Seerr-Verbindung ging verloren und muss neu verbunden werden.",
"backupTitle": "Eine Kopie der beschädigten Datei wurde aufbewahrt",
"backupWarning": "Sie enthält deine Anmeldedaten. Lade sie nicht hoch und teile sie nicht.",
"deleteBackup": "Kopie löschen",
"backupDeleted": "Kopie gelöscht.",
"previousFailureTitle": "Plezy konnte beim letzten Mal nicht starten"
},
"licenses": {
"relatedPackages": "Verwandte Pakete",
@@ -1043,7 +1080,10 @@
"characters": "Charaktere",
"addToWatchlist": "Zur Merkliste hinzufügen",
"removeFromWatchlist": "Von Merkliste entfernen",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Merkliste konnte nicht aktualisiert werden",
"watchlistNoMatch": "",
"notInLibrary": "Nicht in deiner Mediathek",
"inTheseLibraries": "In diesen Mediatheken",
"checkingLibrary": "Deine Mediathek wird überprüft …",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Suche nach Filmen und Serien auf ${source}.",
"searchFailed": "Suche fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} beliebt",
"rankAiring": "#${n} derzeit ausgestrahlt",
"rankRated": "#${n} bewertet",
"rankFavorited": "#${n} favorisiert",
"rankTrending": "#${n} angesagt",
"rankSeasonal": "#${n} in ${season}",
"watchingNow": "${n} schauen gerade",
"available": "Verfügbar",
"partiallyAvailable": "Teilweise verfügbar",
"availableIn4k": "4K verfügbar",
"requested": "Angefragt",
"pendingApproval": "Genehmigung ausstehend",
"processing": "Wird verarbeitet",
"declined": "Abgelehnt",
"requestFailed": "Anfrage fehlgeschlagen",
"requested4k": "4K angefragt",
"seasonsAvailable": "${available}/${total} Staffeln",
"nextEpisodeIn": "Folge ${episode} in ${duration}",
"nextAiringIn": "Nächste in ${duration}",
"episodesShort": "${n} Folgen",
"minutesPerEpisode": "${n} Min./Folge",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} gelistet",
"viewersDay": "${n} heute gesehen",
"viewersWeek": "${n} diese Woche gesehen",
"viewersMonth": "${n} diesen Monat gesehen",
"viewersYear": "${n} dieses Jahr gesehen",
"viewersAllTime": "${n} Zuschauer",
"planning": "${n} haben es geplant",
"favorited": "${n} Favoriten",
"dropRate": "${percent} haben es abgebrochen",
"comments": {
"one": "",
"other": ""
"one": "${n} Kommentar",
"other": "${n} Kommentare"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} Stimmen",
"watching": "${n} schauen es gerade",
"completed": "${n} abgeschlossen",
"onHold": "${n} pausiert",
"dropped": "${n} abgebrochen"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Winter",
"spring": "Frühling",
"summer": "Sommer",
"fall": "Herbst",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV-Kurzfilm",
"movie": "Film",
"special": "Special",
"ova": "OVA",
"ona": "ONA",
"music": "Musik",
"other": "Sonstiges"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Original",
"manga": "Manga",
"lightNovel": "Light Novel",
"novel": "Roman",
"visualNovel": "Visual Novel",
"game": "Spiel",
"webComic": "Webcomic",
"musicRelease": "Musik",
"otherMedia": "Sonstiges"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Regisseur",
"writer": "Autor",
"producer": "Produzent",
"creator": "Schöpfer",
"composer": "Komponist"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequel",
"sequel": "Fortsetzung",
"sideStory": "Nebengeschichte",
"spinOff": "Spin-off",
"alternativeVersion": "Alternative Version",
"summary": "Zusammenfassung",
"parentStory": "Hauptgeschichte",
"adaptation": "Adaption",
"other": "Verwandt"
},
"broadcast": "Läuft ${day} um ${time}",
"broadcastWithZone": "Läuft ${day} um ${time} ${timezone}",
"detail": {
"originalTitle": "Originaltitel",
"alsoKnownAs": "Auch bekannt als",
"studios": "Studios",
"country": "Land",
"language": "Sprache",
"released": "Erschienen",
"physicalRelease": "Auf Disc",
"ended": "Beendet",
"addedOn": "Hinzugefügt am ${date}",
"yourRating": "Deine Bewertung",
"budget": "Budget",
"revenue": "Einspielergebnis",
"contentAdvisory": "Altersfreigabe",
"tags": "Tags",
"revealSpoilerTags": "Spoiler-Tags anzeigen",
"links": "Links",
"watchOn": "Ansehen auf",
"watchTrailer": "Trailer ansehen",
"openOn": "Auf ${site} öffnen",
"crew": "Crew",
"ratings": "Bewertungen",
"schedule": "Sendetermine",
"recommendedByUsers": {
"one": "Von ${n} Nutzer empfohlen",
"other": "Von ${n} Nutzern empfohlen"
},
"recommendedBy": "Empfohlen von ${who}",
"favoritedBy": "Favorisiert von ${who}",
"unairedEpisodes": "${n} noch nicht ausgestrahlt",
"recommendedByPercent": "Von ${percent} der Zuschauer empfohlen",
"relatedTitles": "Verwandte Titel",
"background": "Hintergrund"
},
"totalResults": {
"one": "${n} Ergebnis",
"other": "${n} Ergebnisse"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Kanal ansehen",
"favorites": "Favoriten",
"reorderFavorites": "Favoriten sortieren",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.",
"favoritesUpdateFailed": "Favoriten konnten nicht aktualisiert werden. Prüfe deine Verbindung und versuche es erneut.",
"joinSession": "Aktueller Sitzung beitreten",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Synchronisierungsregel bearbeiten",
"removeSyncRule": "Synchronisierungsregel entfernen",
"removeSyncRuleConfirm": "Synchronisierung von „${title}“ beenden? Heruntergeladene Episoden werden behalten.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "„${title}“ nicht mehr synchronisieren?",
"deleteSyncRuleDownloads": "Zugehörige Downloads ebenfalls löschen",
"deleteSyncRuleDownloadsDescription": "Downloads, die von einer anderen Synchronisierungsregel oder einem Profil verwendet werden, bleiben erhalten.",
"syncRuleCreated": "Synchronisierungsregel erstellt – ${count} ungesehene Episoden werden behalten",
"syncRuleUpdated": "Synchronisierungsregel aktualisiert",
"syncRuleRemoved": "Synchronisierungsregel entfernt",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Synchronisierungsregel und zugehörige Downloads entfernt",
"syncRuleCleanupBusy": "Synchronisierungsregeln werden gerade aktualisiert. Versuche es gleich noch einmal.",
"syncRuleCleanupUnavailable": "Zugehörige Downloads konnten nicht sicher ermittelt werden. Verbinde den Server erneut und versuche es noch einmal, oder entferne die Regel, ohne die Downloads zu löschen.",
"syncedNewEpisodes": "${count} neue Episoden für ${title} synchronisiert",
"activeSyncRules": "Synchronisierungsregeln",
"noSyncRules": "Keine Synchronisierungsregeln",
@@ -1614,9 +1644,15 @@
"audioSync": "Audio-Synchronisation",
"subtitleSync": "Untertitel-Synchronisation",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige",
"audioPassthrough": "Audio-Durchleitung",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Plezy auf ${service} aktivieren",
"body": "Gehe zu ${url} und gib diesen Code ein:",
"instructions": "",
"openToActivate": "${service} zum Aktivieren öffnen",
"copyCode": "Aktivierungscode kopieren",
"waitingForAuthorization": "Warte auf Autorisierung…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Bei ${service} anmelden",
"body": "Scanne diesen QR-Code oder öffne die URL auf einem beliebigen Gerät.",
"openToSignIn": "${service} zum Anmelden öffnen",
"openToSignIn": "${service} zum Anmelden öffnen"
},
"pendingAuth": {
"copyUrl": "Anmelde-URL kopieren",
"urlCopied": "URL kopiert"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfin-Server hinzufügen",
"addMediaBrowserTitle": "${product}-Server hinzufügen",
"serverUrls": "Server-URLs",
"serverUrlsHelper": "Mehrere URLs möglich, durch Kommas getrennt.",
"findServer": "Server finden",
"searchingLocalServers": "Suche nach lokalen Jellyfin-Servern …",
"localServers": "Lokale Jellyfin-Server",
"searchingLocalMediaBrowserServers": "Suche nach lokalen ${product}-Servern …",
"localMediaBrowserServers": "Lokale ${product}-Server",
"username": "Benutzername",
"password": "Passwort",
"signIn": "Anmelden",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Mit Plex anmelden",
"pinExpired": "PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.",
"failedToRegisterAccount": "Konto konnte nicht registriert werden: ${error}",
"enterJellyfinUrlError": "Gib die URL deines Jellyfin-Servers ein",
"enterMediaBrowserUrlError": "Gib die URL deines ${product}-Servers ein",
"addConnectionTitle": "Verbindung hinzufügen",
"addConnectionTitleScoped": "Zu ${name} hinzufügen",
"signInWithPlexCard": "Mit Plex anmelden",
"signInWithPlexCardSubtitle": "Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.",
"signInWithPlexCardSubtitleScoped": "Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.",
"connectToJellyfinCard": "Mit Jellyfin verbinden",
"connectToJellyfinCardSubtitle": "Gib Server-URL, Benutzername und Passwort ein.",
"connectToJellyfinCardSubtitleScoped": "Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.",
"connectToMediaBrowserCard": "Mit ${product} verbinden",
"connectToMediaBrowserCardSubtitle": "Gib Server-URL, Benutzername und Passwort ein.",
"connectToMediaBrowserCardSubtitleScoped": "Melde dich bei deinem ${product}-Server an. Wird mit ${name} verknüpft.",
"borrowFromAnotherProfile": "Von einem anderen Profil ausleihen",
"borrowFromAnotherProfileSubtitle": "Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN."
}
+108 -71
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Waiting for authentication...\nSign in from your browser.",
"useBrowser": "Use browser",
"or": "or",
"connectToJellyfin": "Connect to Jellyfin",
"connectToMediaBrowser": "Connect to ${product}",
"useQuickConnect": "Use Quick Connect",
"quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.",
"quickConnectWaiting": "Waiting for approval…",
@@ -64,7 +64,20 @@
"loading": "Loading...",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit fullscreen",
"pressBackAgainToExit": "Press back again to exit"
"pressBackAgainToExit": "Press back again to exit",
"ratingSource": {
"critic": "Critics",
"audience": "Audience",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes critics",
"rottenTomatoesAudience": "Rotten Tomatoes audience"
}
},
"screens": {
"licenses": "Licenses",
@@ -107,6 +120,7 @@
"darkTheme": "Dark",
"oledTheme": "OLED",
"libraryDensity": "Library Density",
"displayScale": "Display Scale",
"compact": "Compact",
"comfortable": "Comfortable",
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Music Quality",
"subtitleStyling": "Subtitle Styling",
@@ -269,7 +288,7 @@
"matchDynamicRangeDescription": "Switch HDR on for HDR content, then back to SDR",
"displaySwitchDelay": "Display Switch Delay",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use video tunneling. Disable if HDR playback shows black video.",
"tunneledPlaybackDescription": "Use video tunneling. Disable if HDR playback shows black video or motion stutters.",
"audioPassthrough": "Audio Passthrough",
"audioPassthroughDescription": "Send Dolby/DTS audio to your receiver or TV without re-encoding, preserving surround sound. Turn off if you have no sound.",
"audioPassthroughDescriptionAppleTv": "Use Apple's native Dolby decoder for Dolby Digital Plus, including Atmos. DTS and TrueHD still play as multichannel PCM. Turn off if you have no sound.",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normalize Volume on Downmix",
"audioDownmixNormalizeDescription": "Lower the mix to prevent clipping. Turn off to keep the original volume (may distort loud scenes).",
"atmosDiagnostics": "Atmos Output Test",
"atmosDiagnosticsDescription": "Diagnose Dolby Atmos output by playing test signals through the system player",
"atmosTestHlsAtmos": "Apple Atmos stream",
"atmosTestHlsAtmosDescription": "Known-good Dolby Atmos stream. The receiver should show Dolby Atmos.",
"atmosTestHlsControl": "Apple surround stream",
"atmosTestHlsControlDescription": "Non-Atmos control stream. The receiver should show surround without Atmos.",
"atmosTestRawStream": "Raw EAC3 stream",
"atmosTestRawStreamDescription": "Streams the test file exactly like in-player Atmos playback. Needs the test file URL.",
"atmosTestRawFile": "Raw EAC3 file",
"atmosTestRawFileDescription": "Plays the test file with a known length. Needs the test file URL.",
"atmosTestAsbarNative": "Sample-buffer renderer (native)",
"atmosTestAsbarNativeDescription": "Feeds the file's untouched compressed audio straight to the system renderer. Needs the test file URL.",
"atmosTestAsbarGenerated": "Sample-buffer renderer (rebuilt)",
"atmosTestAsbarGeneratedDescription": "Same, but with the audio description rebuilt the way playback builds it. Needs the test file URL.",
"atmosTestSessionMode": "Use movie playback session mode",
"atmosTestSessionModeDescription": "Off uses the mode Dolby documents. On uses the mode playback used previously.",
"atmosTestShowRoutePicker": "Choose AirPlay output",
"atmosTestHideRoutePicker": "Hide AirPlay output picker",
"atmosTestRoutePickerDescription": "Send the test to an AirPlay receiver. Only AirPlay reports the resolved audio mode.",
"atmosTestStop": "Stop test",
"atmosTestUrl": "Test file URL",
"atmosTestUrlDescription": "HTTP URL of a raw .ec3 Dolby Atmos file (e.g. extracted with ffmpeg)",
"atmosTestUrlMissing": "Set the test file URL first",
"atmosTestStatus": "Status",
"dvConversionMode": "Dolby Vision Conversion",
"dvConversionModeDescription": "Choose how ExoPlayer handles Dolby Vision Profile 7 files.",
"dvConversionAuto": "Auto",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Show Navigation Bar Labels",
"showNavBarLabelsDescription": "Display text labels under navigation bar icons",
"startupSection": "Startup Section",
"showExploreTab": "Show Explore Tab",
"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",
"display": "Display",
@@ -505,9 +502,35 @@
"shufflePlay": "Shuffle Play",
"shuffleNotAvailableOffline": "Shuffle not available offline",
"fileInfo": "File Info",
"deleteFromServer": "Delete from server",
"confirmDelete": "Delete this media and its files from your server?",
"deleteEpisodeFromServer": "Delete episode from server",
"deleteSeasonFromServer": "Delete season from server",
"deleteShowFromServer": "Delete show from server",
"deleteMovieFromServer": "Delete movie from server",
"deleteEpisodeTitle": "Delete this episode?",
"deleteSeasonTitle": "Delete this season?",
"deleteShowTitle": "Delete this show?",
"deleteMovieTitle": "Delete this movie?",
"deleteEpisodeConfirm": "Delete episode",
"deleteSeasonConfirm": "Delete season",
"deleteShowConfirm": "Delete show",
"deleteMovieConfirm": "Delete movie",
"deleteAnyway": "Delete anyway",
"confirmDeleteTarget": "Permanently delete ${title} from your server?",
"deleteMultipleWarning": "This includes all episodes and their files.",
"deleteEpisodeCountWarning": {
"one": "This deletes all ${n} episode in it, and its file.",
"other": "This deletes all ${n} episodes in it, and their files."
},
"deleteMultiPartWarning": {
"one": "This item is stored as ${n} file, which will be deleted.",
"other": "This item is stored across ${n} files, and all of them will be deleted."
},
"deleteSharedFileHeading": {
"one": "${n} other episode is stored in the same file and will be deleted too:",
"other": "${n} other episodes are stored in the same file and will be deleted too:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.",
"deleteScopeUnverifiedNoFileInfo": "Your server did not provide file details for this item, so Plezy cannot check which files this will remove. It may delete more than the item named above.",
"mediaDeletedSuccessfully": "Media item deleted successfully",
"mediaFailedToDelete": "Failed to delete media item",
"rate": "Rate",
@@ -585,6 +608,7 @@
"pauseButton": "Pause",
"playbackPaused": "Paused",
"playbackResumed": "Playing",
"loadingVideo": "Loading video",
"showPlaybackControls": "Show playback controls",
"hidePlaybackControls": "Hide playback controls",
"seekBackwardButton": "Seek backward ${seconds} seconds",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "No chapters available",
"queue": "Queue",
"noQueueItems": "No items in queue",
"noAudioDevicesAvailable": "No audio devices available",
"searchSubtitles": "Search Subtitles",
"language": "Language",
"noSubtitlesFound": "No subtitles found",
@@ -697,9 +722,12 @@
"serverLimitBody": "Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.",
"mediaUnreadableTitle": "File unavailable",
"mediaUnreadableBody": "The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.",
"serverBusyTitle": "Stream unavailable",
"serverBusyBody": "The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file's storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file's storage.",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logId": "Log ID",
"burnedSubtitlesUseMenu": "Subtitles are burned into this stream. Change them from the subtitle menu."
},
"subtitlingStyling": {
"text": "Text",
@@ -718,6 +746,8 @@
"overrideStrip": "Remove styling",
"positionTop": "Top",
"positionBottom": "Bottom",
"anchorToScreen": "Anchor to Screen",
"anchorToScreenDescription": "Show text subtitles in the black bars below widescreen video",
"bold": "Bold",
"italic": "Italic",
"renderResolution": "Render Resolution",
@@ -780,6 +810,8 @@
"noConnectionsHint": "No connections — add one to use this profile.",
"noConnections": "No connections",
"plexHomeAccount": "Plex Home account",
"plexAccountChip": "Plex account: ${account}",
"plexAccountUserChip": "${user} via ${account}",
"connectionDefault": "Default",
"connectionAs": "as ${displayName}",
"makeDefault": "Make default",
@@ -788,7 +820,7 @@
"borrowAddTo": "Add to ${displayName}",
"borrowExplain": "Borrow another profile's connection. PIN-protected profiles require a PIN.",
"borrowEmpty": "Nothing to borrow yet.",
"borrowEmptySubtitle": "Connect Plex or Jellyfin to another profile first.",
"borrowEmptySubtitle": "Connect Plex, Jellyfin, or Emby to another profile first.",
"borrowLoadFailed": "Available connections could not be loaded. Try again.",
"borrowFromProfile": "From ${displayName}",
"borrowConnectionBorrowed": "Connection borrowed.",
@@ -808,13 +840,13 @@
"connections": {
"sectionTitle": "Connections",
"addConnection": "Add connection",
"addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin server",
"addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, or another profile connection",
"addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin or Emby server",
"addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection",
"sessionExpiredOne": "Session expired for ${name}",
"sessionExpiredMany": "Session expired for ${count} servers",
"signInAgain": "Sign in again",
"editJellyfinTitle": "Edit Jellyfin connection",
"editJellyfinIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency."
"editMediaBrowserTitle": "Edit ${product} connection",
"editMediaBrowserIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency."
},
"discover": {
"title": "Discover",
@@ -952,7 +984,7 @@
"title": "About",
"openSourceLicenses": "Open Source Licenses",
"versionLabel": "Version ${version}",
"appDescription": "A beautiful Plex and Jellyfin client for Flutter",
"appDescription": "A beautiful Plex, Jellyfin, and Emby client for Flutter",
"viewLicensesDescription": "View licenses of third-party libraries"
},
"serverSelection": {
@@ -974,6 +1006,7 @@
"startup": {
"failedTitle": "Plezy could not start",
"failedBody": "Something went wrong during startup. The details below identify what failed.",
"failedBodyRepairable": "Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.",
"phaseLabel": "Step",
"showDetails": "Show details",
"hideDetails": "Hide details",
@@ -984,7 +1017,9 @@
"repairTitle": "Repair stored data?",
"repairBodyCommon": "Plezy's settings file is damaged and cannot be read. Repairing resets every setting to its default.",
"repairBodyOneCredential": "One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.",
"repairBodySessionsAtRisk": "Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.",
"repairBodySignInsKept": "Your servers and profiles should stay signed in.",
"repairBodySignInsLost": "The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.",
"repairBodySessionsUncertain": "Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.",
"repairConfirm": "Repair",
"repairSucceeded": "Storage repaired",
"repairNeedsRestart": "Storage repaired — restart required",
@@ -1045,7 +1080,10 @@
"characters": "Characters",
"addToWatchlist": "Add to Watchlist",
"removeFromWatchlist": "Remove from Watchlist",
"addedToWatchlist": "Added to watchlist",
"removedFromWatchlist": "Removed from watchlist",
"watchlistUpdateFailed": "Couldn't update watchlist",
"watchlistNoMatch": "Couldn't match this item to a watchlist",
"notInLibrary": "Not in your library",
"inTheseLibraries": "In these libraries",
"checkingLibrary": "Checking your library...",
@@ -1134,18 +1172,16 @@
"creator": "Creator",
"composer": "Composer"
},
"ratingSource": {
"critic": "Critics",
"audience": "Audience",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes critics",
"rottenTomatoesAudience": "Rotten Tomatoes audience"
"relation": {
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Side story",
"spinOff": "Spin-off",
"alternativeVersion": "Alternative version",
"summary": "Summary",
"parentStory": "Parent story",
"adaptation": "Adaptation",
"other": "Related"
},
"broadcast": "Airs ${day} at ${time}",
"broadcastWithZone": "Airs ${day} at ${time} ${timezone}",
@@ -1186,17 +1222,6 @@
"totalResults": {
"one": "${n} result",
"other": "${n} results"
},
"relation": {
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Side story",
"spinOff": "Spin-off",
"alternativeVersion": "Alternative version",
"summary": "Summary",
"parentStory": "Parent story",
"adaptation": "Adaptation",
"other": "Related"
}
},
"liveTv": {
@@ -1228,6 +1253,9 @@
"watchChannel": "Watch Channel",
"favorites": "Favorites",
"reorderFavorites": "Reorder Favorites",
"noFavoriteChannels": "No favorite channels",
"noFavoriteChannelsHint": "Show all channels, then long-press a channel to add it to your favorites.",
"showAllChannels": "Show All Channels",
"favoritesLoadFailed": "Could not load favorites. Check your connection and try again.",
"favoritesUpdateFailed": "Could not update favorites. Check your connection and try again.",
"joinSession": "Join Session in Progress",
@@ -1616,9 +1644,15 @@
"audioSync": "Audio Sync",
"subtitleSync": "Subtitle Sync",
"hdr": "HDR",
"hdrUnsupported": "HDR isn't available here — this desktop compositor or video output can't carry it.",
"hdrToneMapping": "HDR Tone Mapping",
"hdrToneMappingCompositor": "Compositor",
"hdrToneMappingCompositorDescription": "Pass the source's HDR metadata through and let the desktop compositor map it.",
"hdrToneMappingPlayer": "Player",
"hdrToneMappingPlayerDescription": "Map to the display's peak brightness in the player, then tell the compositor the result.",
"hdrToneMappingFailed": "Couldn't change HDR tone mapping \u2014 the previous mode is still active.",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay",
"audioPassthrough": "Audio Passthrough",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1836,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Activate Plezy on ${service}",
"body": "Visit ${url} and enter this code:",
"instructions": "Scan the QR code, or visit the address below and enter this code:",
"openToActivate": "Open ${service} to activate",
"copyCode": "Copy activation code",
"waitingForAuthorization": "Waiting for authorization…",
@@ -1849,7 +1884,9 @@
"oauthProxy": {
"title": "Sign in to ${service}",
"body": "Scan this QR code or open the URL on any device.",
"openToSignIn": "Open ${service} to sign in",
"openToSignIn": "Open ${service} to sign in"
},
"pendingAuth": {
"copyUrl": "Copy sign-in URL",
"urlCopied": "URL copied"
},
@@ -1869,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Add Jellyfin server",
"addMediaBrowserTitle": "Add ${product} server",
"serverUrls": "Server URLs",
"serverUrlsHelper": "Multiple URLs allowed, separated by commas.",
"findServer": "Find server",
"searchingLocalServers": "Looking for local Jellyfin servers...",
"localServers": "Local Jellyfin servers",
"searchingLocalMediaBrowserServers": "Looking for local ${product} servers...",
"localMediaBrowserServers": "Local ${product} servers",
"username": "Username",
"password": "Password",
"signIn": "Sign in",
@@ -1886,15 +1923,15 @@
"addPlexTitle": "Sign in with Plex",
"pinExpired": "PIN expired before sign-in. Please try again.",
"failedToRegisterAccount": "Failed to register account: ${error}",
"enterJellyfinUrlError": "Enter your Jellyfin server URL",
"enterMediaBrowserUrlError": "Enter your ${product} server URL",
"addConnectionTitle": "Add connection",
"addConnectionTitleScoped": "Add to ${name}",
"signInWithPlexCard": "Sign in with Plex",
"signInWithPlexCardSubtitle": "Authorize this device. Shared servers are added.",
"signInWithPlexCardSubtitleScoped": "Authorize a Plex account. Home users become profiles.",
"connectToJellyfinCard": "Connect to Jellyfin",
"connectToJellyfinCardSubtitle": "Enter your server URL, username, and password.",
"connectToJellyfinCardSubtitleScoped": "Sign in to a Jellyfin server. Binds to ${name}.",
"connectToMediaBrowserCard": "Connect to ${product}",
"connectToMediaBrowserCardSubtitle": "Enter your server URL, username, and password.",
"connectToMediaBrowserCardSubtitleScoped": "Sign in to your ${product} server. Binds to ${name}.",
"borrowFromAnotherProfile": "Borrow from another profile",
"borrowFromAnotherProfileSubtitle": "Reuse another profile's connection. PIN-protected profiles require a PIN."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Esperando autenticación...\nInicia sesión desde tu navegador.",
"useBrowser": "Usar navegador",
"or": "o",
"connectToJellyfin": "Conectar a Jellyfin",
"connectToMediaBrowser": "Conectar a ${product}",
"useQuickConnect": "Usar Quick Connect",
"quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.",
"quickConnectWaiting": "Esperando aprobación…",
@@ -64,7 +64,20 @@
"loading": "Cargando...",
"fullscreen": "Pantalla completa",
"exitFullscreen": "Salir de pantalla completa",
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir"
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
"ratingSource": {
"critic": "Críticos",
"audience": "Público",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Críticos de Rotten Tomatoes",
"rottenTomatoesAudience": "Público de Rotten Tomatoes"
}
},
"screens": {
"licenses": "Licencias",
@@ -107,6 +120,7 @@
"darkTheme": "Oscuro",
"oledTheme": "OLED",
"libraryDensity": "Densidad de la biblioteca",
"displayScale": "Escala de visualización",
"compact": "Compacto",
"comfortable": "Cómodo",
"tvCornerSpotlightBackdrop": "Imagen destacada en la esquina",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Calidad de música",
"subtitleStyling": "Estilo de subtítulos",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Habilitar registros detallados para solucionar problemas",
"viewLogs": "Ver registros",
"viewLogsDescription": "Ver los registros de la aplicación",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Vaciar caché de imágenes",
"clearImageCacheDescription": "Borra las carátulas y miniaturas guardadas en caché. Las imágenes pueden tardar más en cargar hasta que se descarguen de nuevo.",
"clearImageCacheSuccess": "Caché de imágenes vaciada correctamente",
"resetSettings": "Restablecer configuración",
"resetSettingsDescription": "Restaurar ajustes predeterminados. No se puede deshacer.",
"resetSettingsSuccess": "Configuración restablecida con éxito",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normalizar volumen al mezclar",
"audioDownmixNormalizeDescription": "Reduce la mezcla para evitar saturación. Desactívalo para mantener el volumen original (puede distorsionar escenas fuertes).",
"atmosDiagnostics": "Prueba de salida Atmos",
"atmosDiagnosticsDescription": "Diagnostica la salida Dolby Atmos reproduciendo señales de prueba con el reproductor del sistema",
"atmosTestHlsAtmos": "Flujo Atmos de Apple",
"atmosTestHlsAtmosDescription": "Flujo Dolby Atmos de referencia. El receptor debería mostrar Dolby Atmos.",
"atmosTestHlsControl": "Flujo envolvente de Apple",
"atmosTestHlsControlDescription": "Flujo de control sin Atmos. El receptor debería indicar sonido envolvente sin Atmos.",
"atmosTestRawStream": "Flujo EAC3 sin procesar",
"atmosTestRawStreamDescription": "Transmite el archivo de prueba igual que durante la reproducción de Atmos. Requiere la URL del archivo de prueba.",
"atmosTestRawFile": "Archivo EAC3 sin procesar",
"atmosTestRawFileDescription": "Reproduce el archivo de prueba con longitud conocida. Necesita la URL del archivo de prueba.",
"atmosTestAsbarNative": "Renderizador de búfer de muestras (nativo)",
"atmosTestAsbarNativeDescription": "Envía el audio comprimido intacto del archivo directamente al renderizador del sistema. Necesita la URL del archivo de prueba.",
"atmosTestAsbarGenerated": "Renderizador de búfer de muestras (reconstruido)",
"atmosTestAsbarGeneratedDescription": "Igual, pero con la descripción de audio construida como en la reproducción. Necesita la URL del archivo de prueba.",
"atmosTestSessionMode": "Usar modo de reproducción de películas",
"atmosTestSessionModeDescription": "Desactivado usa el modo que documenta Dolby. Activado usa el modo anterior.",
"atmosTestShowRoutePicker": "Elegir salida AirPlay",
"atmosTestHideRoutePicker": "Ocultar selector de salida AirPlay",
"atmosTestRoutePickerDescription": "Envía la prueba a un receptor AirPlay. Solo AirPlay informa del modo de audio resuelto.",
"atmosTestStop": "Detener prueba",
"atmosTestUrl": "URL del archivo de prueba",
"atmosTestUrlDescription": "URL HTTP de un archivo .ec3 Dolby Atmos sin procesar (p. ej., extraído con ffmpeg)",
"atmosTestUrlMissing": "Configura primero la URL del archivo de prueba",
"atmosTestStatus": "Estado",
"dvConversionMode": "Conversión de Dolby Vision",
"dvConversionModeDescription": "Elige cómo gestiona ExoPlayer los archivos Dolby Vision de perfil 7.",
"dvConversionAuto": "Automático",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Mostrar etiquetas de la barra de navegación",
"showNavBarLabelsDescription": "Mostrar etiquetas de texto bajo los iconos de la barra de navegación",
"startupSection": "Sección de inicio",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Canales favoritos por defecto",
"liveTvDefaultFavoritesDescription": "Mostrar solo canales favoritos al abrir TV en vivo",
"display": "Pantalla",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Información del archivo",
"overview": "",
"overview": "Resumen",
"video": "Video",
"audio": "Audio",
"subtitles": "Subtítulos",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Imágenes incrustadas",
"dataStreams": "Flujos de datos",
"lyrics": "Letras",
"file": "Archivo",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Archivos adjuntos",
"delivery": "Entrega",
"versionCounter": "Versión ${index} de ${count}",
"fileCounter": "Archivo ${index} de ${count}",
"noStreams": "El servidor no informó de ningún flujo para este archivo.",
"copyPath": "Copiar ruta",
"pathCopied": "Ruta del archivo copiada",
"codec": "Códec",
"codecTag": "",
"codecTag": "Etiqueta de códec",
"resolution": "Resolución",
"codedResolution": "",
"codedResolution": "Resolución codificada",
"bitrate": "Tasa de bits",
"frameRate": "Frecuencia de fotogramas",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotación",
"comment": "Comentario",
"audioDescription": "Audiodescripción",
"headerCompression": "Compresión de cabecera",
"sidecarFile": "Archivo auxiliar",
"transportTimestamp": "Marca de tiempo de transporte",
"displayOffset": "Desplazamiento de visualización",
"previewFailureCode": "Código de error de vista previa",
"previewRetries": "Reintentos de vista previa",
"aspectRatio": "Relación de aspecto",
"pixelAspectRatio": "",
"pixelAspectRatio": "Relación de aspecto de píxel",
"profile": "Perfil",
"level": "",
"level": "Nivel",
"bitDepth": "Profundidad de bits",
"pixelFormat": "",
"pixelFormat": "Formato de píxel",
"colorSpace": "Espacio de color",
"colorRange": "Rango de color",
"colorPrimaries": "Primarias de color",
"colorTransfer": "",
"colorTransfer": "Transferencia de color",
"chromaSubsampling": "Submuestreo de croma",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Ubicación de crominancia",
"scanType": "Tipo de escaneo",
"interlaced": "Entrelazado",
"anamorphic": "Anamórfico",
"referenceFrames": "Fotogramas de referencia",
"dynamicRange": "Rango dinámico",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Nivel de Dolby Vision",
"dolbyVisionVersion": "Versión de Dolby Vision",
"dolbyVisionLayers": "Capas de Dolby Vision",
"baseLayerCompatibility": "Compatibilidad de capa base",
"avcBitstream": "Flujo de bits AVC",
"nalLengthSize": "Tamaño de longitud NAL",
"scalingMatrix": "Matriz de escalado personalizada",
"streamIdentifier": "Identificador de flujo",
"streamIndex": "Índice de flujo",
"streamId": "ID de flujo",
"language": "Idioma",
"languageCode": "Código de idioma",
"streamTitle": "Título de la pista",
"channels": "Canales",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Frecuencia de muestreo",
"spatialAudio": "Audio espacial",
"textBased": "Basado en texto",
"subtitleFormat": "Formato de archivo auxiliar",
"provider": "Proveedor",
"matchScore": "Puntuación de coincidencia",
"externalDelivery": "Puede servirse por separado",
"sidecarPath": "Ruta del archivo auxiliar",
"sourceStream": "Copiado de",
"temporary": "Temporal",
"timeBase": "Base de tiempo",
"overallBitrate": "Tasa de bits total",
"path": "Ruta",
"fileName": "",
"fileName": "Nombre de archivo",
"size": "Tamaño",
"totalSize": "",
"totalSize": "Tamaño total",
"container": "Contenedor",
"duration": "Duración",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Miniaturas de vista previa",
"previewIndex": "Índice de vista previa",
"packetLength": "Longitud del paquete",
"filePresent": "Archivo presente",
"fileReadable": "Legible por el servidor",
"streamPath": "Ruta del flujo",
"optimizedForStreaming": "Optimizado para transmisión",
"has64bitOffsets": "Desplazamientos de 64 bits",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protocolo",
"mediaType": "Tipo de medio",
"sourceKind": "Tipo de origen",
"optimizedVersion": "Versión optimizada",
"optimizationTarget": "Destino de optimización",
"deletedAt": "Eliminado",
"remoteSource": "Origen remoto",
"infiniteStream": "Flujo infinito",
"directPlay": "Reproducción directa",
"directStream": "Flujo directo",
"transcoding": "Transcodificación",
"etag": "ETag",
"versionId": "ID de versión",
"fileId": "ID de archivo",
"defaultAudioTrack": "Pista de audio predeterminada",
"defaultSubtitleTrack": "Pista de subtítulos predeterminada",
"subtitlesOff": "Desactivados",
"flagDefault": "Predeterminada",
"flagForced": "Forzada",
"flagSelected": "Seleccionada",
"flagExternal": "Externa",
"flagHearingImpaired": "Para personas con discapacidad auditiva",
"flagDub": "Doblaje",
"flagOriginal": "Original"
},
"mediaMenu": {
"markAsWatched": "Marcar como visto",
@@ -505,9 +502,35 @@
"shufflePlay": "Reproducción aleatoria",
"shuffleNotAvailableOffline": "La reproducción aleatoria no está disponible sin conexión",
"fileInfo": "Información del archivo",
"deleteFromServer": "Eliminar del servidor",
"confirmDelete": "¿Eliminar este medio y sus archivos de tu servidor?",
"deleteEpisodeFromServer": "Eliminar el episodio del servidor",
"deleteSeasonFromServer": "Eliminar la temporada del servidor",
"deleteShowFromServer": "Eliminar la serie del servidor",
"deleteMovieFromServer": "Eliminar la película del servidor",
"deleteEpisodeTitle": "¿Eliminar este episodio?",
"deleteSeasonTitle": "¿Eliminar esta temporada?",
"deleteShowTitle": "¿Eliminar esta serie?",
"deleteMovieTitle": "¿Eliminar esta película?",
"deleteEpisodeConfirm": "Eliminar episodio",
"deleteSeasonConfirm": "Eliminar temporada",
"deleteShowConfirm": "Eliminar serie",
"deleteMovieConfirm": "Eliminar película",
"deleteAnyway": "Eliminar de todos modos",
"confirmDeleteTarget": "¿Eliminar definitivamente ${title} de tu servidor?",
"deleteMultipleWarning": "Esto incluye todos los episodios y sus archivos.",
"deleteEpisodeCountWarning": {
"one": "Esto elimina el único episodio que contiene y su archivo.",
"other": "Esto elimina los ${n} episodios que contiene y sus archivos."
},
"deleteMultiPartWarning": {
"one": "Este elemento se almacena en ${n} archivo, que se eliminará.",
"other": "Este elemento se almacena en ${n} archivos, y todos se eliminarán."
},
"deleteSharedFileHeading": {
"one": "${n} episodio más se almacena en el mismo archivo y también se eliminará:",
"other": "${n} episodios más se almacenan en el mismo archivo y también se eliminarán:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy no pudo comprobar qué archivos se eliminarán, por lo que podría borrar más de lo indicado arriba. Cancela y vuelve a intentarlo, o elimina de todos modos.",
"deleteScopeUnverifiedNoFileInfo": "Tu servidor no proporcionó los detalles del archivo de este elemento, por lo que Plezy no puede comprobar qué archivos se eliminarán. Podría borrar más de lo indicado arriba.",
"mediaDeletedSuccessfully": "Elemento multimedia eliminado con éxito",
"mediaFailedToDelete": "Error al eliminar el elemento multimedia",
"rate": "Calificar",
@@ -583,8 +606,9 @@
"playNext": "Reproducir siguiente",
"playButton": "Reproducir",
"pauseButton": "Pausar",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "En pausa",
"playbackResumed": "Reproduciendo",
"loadingVideo": "Cargando el vídeo",
"showPlaybackControls": "Mostrar controles de reproducción",
"hidePlaybackControls": "Ocultar controles de reproducción",
"seekBackwardButton": "Retroceder ${seconds} segundos",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "No hay capítulos disponibles",
"queue": "Cola",
"noQueueItems": "No hay elementos en la cola",
"noAudioDevicesAvailable": "No hay dispositivos de audio disponibles",
"searchSubtitles": "Buscar subtítulos",
"language": "Idioma",
"noSubtitlesFound": "No se encontraron subtítulos",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Eliminado de Seguir Viendo",
"errorLoading": "Error: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Algunos servidores de medios no se pudieron buscar. Se muestran los resultados disponibles.",
"streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.",
"liveStreamInterrupted": "La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.",
"fileInfoNotAvailable": "Información de archivo no disponible",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Cambiando a reproductor compatible...",
"serverLimitTitle": "Error de reproducción",
"serverLimitBody": "Error del servidor (HTTP 500). Un límite de ancho de banda/transcodificación probablemente rechazó esta sesión. Pide al propietario que lo ajuste.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Archivo no disponible",
"mediaUnreadableBody": "El servidor encontró este elemento pero no pudo leer su archivo (HTTP 404). Es probable que el archivo se haya movido o eliminado, o que su almacenamiento esté sin conexión. Pide al propietario del servidor que revise el archivo y vuelva a escanear la biblioteca.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Registros subidos",
"logsUploadFailed": "Error al subir registros",
"logId": "ID de registro"
"logId": "ID de registro",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Texto",
@@ -718,6 +746,8 @@
"overrideStrip": "Quitar estilos",
"positionTop": "Arriba",
"positionBottom": "Abajo",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Negrita",
"italic": "Cursiva",
"renderResolution": "Resolución de renderizado",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Sin conexiones — añade una para usar este perfil.",
"noConnections": "Sin conexiones",
"plexHomeAccount": "Cuenta Plex Home",
"plexAccountChip": "Cuenta Plex: ${account}",
"plexAccountUserChip": "${user} a través de ${account}",
"connectionDefault": "Predeterminada",
"connectionAs": "como ${displayName}",
"makeDefault": "Establecer como predeterminada",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Sesión caducada para ${name}",
"sessionExpiredMany": "Sesión caducada para ${count} servidores",
"signInAgain": "Iniciar sesión de nuevo",
"editJellyfinTitle": "Editar conexión de Jellyfin",
"editJellyfinIntro": "Añade o elimina direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia."
"editMediaBrowserTitle": "Editar conexión de ${product}",
"editMediaBrowserIntro": "Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia."
},
"discover": {
"title": "Descubrir",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Error en la búsqueda: ${error}",
"searchUnavailable": "",
"searchUnavailable": "La búsqueda no pudo alcanzar ningún servidor de medios.",
"connectionTimeout": "Tiempo de conexión agotado al cargar ${context}",
"connectionFailed": "No se puede conectar al servidor multimedia",
"unableToLoad": "No se pudo cargar ${context}. Inténtalo de nuevo.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Subir registros"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy no pudo iniciarse",
"failedBody": "Algo salió mal durante el inicio. Los detalles de abajo indican qué ha fallado.",
"failedBodyRepairable": "El archivo de ajustes guardados de Plezy está dañado y debe reconstruirse antes de que Plezy pueda iniciarse. Reintentar no servirá: elige Reparar almacenamiento.",
"phaseLabel": "Paso",
"showDetails": "Mostrar detalles",
"hideDetails": "Ocultar detalles",
"copyDetails": "Copiar detalles",
"detailsCopied": "Detalles copiados al portapapeles",
"uploadDetails": "Subir detalles",
"repairStorage": "Reparar almacenamiento",
"repairTitle": "¿Reparar los datos guardados?",
"repairBodyCommon": "El archivo de ajustes de Plezy está dañado y no puede leerse. La reparación restablece todos los ajustes a sus valores predeterminados.",
"repairBodyOneCredential": "Un inicio de sesión guardado está dañado y no puede leerse. La reparación elimina solo ese; el resto de tus ajustes se mantienen intactos.",
"repairBodySignInsKept": "Tus servidores y perfiles deberían permanecer con la sesión iniciada.",
"repairBodySignInsLost": "La clave que protege tus inicios de sesión guardados no se puede recuperar de este archivo, por lo que tendrás que iniciar sesión de nuevo en cada servidor y perfil. Nada en tu servidor de medios se ve afectado.",
"repairBodySessionsUncertain": "Los rastreadores (MAL, AniList, Simkl, Trakt) y Seerr se guardan por separado y pueden sobrevivir o no. Plezy te dirá exactamente qué ha conservado.",
"repairConfirm": "Reparar",
"repairSucceeded": "Almacenamiento reparado",
"repairNeedsRestart": "Almacenamiento reparado: es necesario reiniciar",
"restartRequiredBody": "Tus datos se repararon, pero Plezy debe iniciarse de nuevo antes de poder usarlos. Cierra Plezy y ábrelo otra vez.",
"quitPlezy": "Salir de Plezy",
"repairFailed": "Error en la reparación",
"repairKeptSignIns": "Tus servidores y perfiles siguen con la sesión iniciada.",
"repairLostSignIns": "La clave que protege tus inicios de sesión guardados no se pudo recuperar. Tendrás que iniciar sesión de nuevo en cada servidor y perfil.",
"repairLostSessions": "Se perdió al menos una conexión de rastreador o de Seerr y hay que volver a conectarla.",
"backupTitle": "Se ha guardado una copia del archivo dañado",
"backupWarning": "Contiene tus credenciales de inicio de sesión. No la subas ni la compartas.",
"deleteBackup": "Eliminar copia",
"backupDeleted": "Copia eliminada.",
"previousFailureTitle": "Plezy no se inició la última vez"
},
"licenses": {
"relatedPackages": "Paquetes relacionados",
@@ -1043,7 +1080,10 @@
"characters": "Personajes",
"addToWatchlist": "Añadir a la lista de seguimiento",
"removeFromWatchlist": "Quitar de la lista de seguimiento",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "No se pudo actualizar la lista de seguimiento",
"watchlistNoMatch": "",
"notInLibrary": "No está en tu biblioteca",
"inTheseLibraries": "En estas bibliotecas",
"checkingLibrary": "Comprobando tu biblioteca...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Busca películas y series en ${source}.",
"searchFailed": "La búsqueda falló. Comprueba tu conexión e inténtalo de nuevo.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} popular",
"rankAiring": "#${n} en emisión",
"rankRated": "#${n} puntuado",
"rankFavorited": "#${n} favorito",
"rankTrending": "#${n} en tendencia",
"rankSeasonal": "#${n} en ${season}",
"watchingNow": "${n} viendo",
"available": "Disponible",
"partiallyAvailable": "Parcialmente disponible",
"availableIn4k": "Disponible en 4K",
"requested": "Solicitado",
"pendingApproval": "Pendiente de aprobación",
"processing": "Procesando",
"declined": "Rechazado",
"requestFailed": "La solicitud falló",
"requested4k": "Solicitado en 4K",
"seasonsAvailable": "${available}/${total} temporadas",
"nextEpisodeIn": "Ep. ${episode} en ${duration}",
"nextAiringIn": "Siguiente en ${duration}",
"episodesShort": "${n} eps",
"minutesPerEpisode": "${n} min/ep",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} registrados",
"viewersDay": "${n} vistos hoy",
"viewersWeek": "${n} vistos esta semana",
"viewersMonth": "${n} vistos este mes",
"viewersYear": "${n} vistos este año",
"viewersAllTime": "${n} espectadores",
"planning": "${n} tienen previsto verlo",
"favorited": "${n} favoritos",
"dropRate": "${percent} lo abandonaron",
"comments": {
"one": "",
"other": ""
"one": "${n} comentario",
"other": "${n} comentarios"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} votos",
"watching": "${n} lo están viendo",
"completed": "${n} lo completaron",
"onHold": "${n} en pausa",
"dropped": "${n} lo abandonaron"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Invierno",
"spring": "Primavera",
"summer": "Verano",
"fall": "Otoño",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "Corto de TV",
"movie": "Película",
"special": "Especial",
"ova": "OVA",
"ona": "ONA",
"music": "Música",
"other": "Otro"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Original",
"manga": "Manga",
"lightNovel": "Novela ligera",
"novel": "Novela",
"visualNovel": "Novela visual",
"game": "Juego",
"webComic": "Cómic web",
"musicRelease": "Música",
"otherMedia": "Otro"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Director",
"writer": "Guionista",
"producer": "Productor",
"creator": "Creador",
"composer": "Compositor"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Precuela",
"sequel": "Secuela",
"sideStory": "Historia paralela",
"spinOff": "Spin-off",
"alternativeVersion": "Versión alternativa",
"summary": "Resumen",
"parentStory": "Historia principal",
"adaptation": "Adaptación",
"other": "Relacionado"
},
"broadcast": "Se emite los ${day} a las ${time}",
"broadcastWithZone": "Se emite los ${day} a las ${time} ${timezone}",
"detail": {
"originalTitle": "Título original",
"alsoKnownAs": "También conocido como",
"studios": "Estudios",
"country": "País",
"language": "Idioma",
"released": "Estrenada",
"physicalRelease": "En disco",
"ended": "Finalizada",
"addedOn": "Añadida el ${date}",
"yourRating": "Tu valoración",
"budget": "Presupuesto",
"revenue": "Taquilla",
"contentAdvisory": "Orientación por edades",
"tags": "Etiquetas",
"revealSpoilerTags": "Mostrar etiquetas de spoilers",
"links": "Enlaces",
"watchOn": "Ver en",
"watchTrailer": "Ver tráiler",
"openOn": "Abrir en ${site}",
"crew": "Equipo",
"ratings": "Valoraciones",
"schedule": "Programación",
"recommendedByUsers": {
"one": "Recomendada por ${n} usuario",
"other": "Recomendada por ${n} usuarios"
},
"recommendedBy": "Recomendada por ${who}",
"favoritedBy": "Marcada como favorita por ${who}",
"unairedEpisodes": "${n} sin estrenar",
"recommendedByPercent": "Recomendada por el ${percent} de los espectadores",
"relatedTitles": "Títulos relacionados",
"background": "Antecedentes"
},
"totalResults": {
"one": "${n} resultado",
"other": "${n} resultados"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Ver canal",
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.",
"favoritesUpdateFailed": "No se pudieron actualizar los favoritos. Comprueba tu conexión e inténtalo de nuevo.",
"joinSession": "Unirse a sesión en curso",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Editar regla de sincronización",
"removeSyncRule": "Eliminar regla de sincronización",
"removeSyncRuleConfirm": "¿Dejar de sincronizar \"${title}\"? Los episodios descargados se conservarán.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "¿Dejar de sincronizar \"${title}\"?",
"deleteSyncRuleDownloads": "Eliminar también las descargas asociadas",
"deleteSyncRuleDownloadsDescription": "Se conservarán las descargas usadas por otra regla de sincronización o perfil.",
"syncRuleCreated": "Regla de sincronización creada — se conservarán ${count} episodios no vistos",
"syncRuleUpdated": "Regla de sincronización actualizada",
"syncRuleRemoved": "Regla de sincronización eliminada",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Regla de sincronización y descargas asociadas eliminadas",
"syncRuleCleanupBusy": "Las reglas de sincronización se están actualizando. Inténtalo de nuevo en un momento.",
"syncRuleCleanupUnavailable": "No se pudieron identificar las descargas asociadas de forma segura. Vuelve a conectar el servidor e inténtalo de nuevo, o elimina la regla sin borrar las descargas.",
"syncedNewEpisodes": "${count} nuevos episodios sincronizados para ${title}",
"activeSyncRules": "Reglas de sincronización",
"noSyncRules": "Sin reglas de sincronización",
@@ -1614,9 +1644,15 @@
"audioSync": "Sincronización de audio",
"subtitleSync": "Sincronización de subtítulos",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento",
"audioPassthrough": "Transferencia directa de audio",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Envolvente",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Activar Plezy en ${service}",
"body": "Visita ${url} e introduce este código:",
"instructions": "",
"openToActivate": "Abrir ${service} para activar",
"copyCode": "Copiar código de activación",
"waitingForAuthorization": "Esperando autorización…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Inicia sesión en ${service}",
"body": "Escanea este código QR o abre la URL en cualquier dispositivo.",
"openToSignIn": "Abrir ${service} para iniciar sesión",
"openToSignIn": "Abrir ${service} para iniciar sesión"
},
"pendingAuth": {
"copyUrl": "Copiar URL de inicio de sesión",
"urlCopied": "URL copiada"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Añadir servidor Jellyfin",
"addMediaBrowserTitle": "Añadir servidor ${product}",
"serverUrls": "Direcciones URL del servidor",
"serverUrlsHelper": "Se permiten varias URL, separadas por comas.",
"findServer": "Buscar servidor",
"searchingLocalServers": "Buscando servidores Jellyfin locales...",
"localServers": "Servidores Jellyfin locales",
"searchingLocalMediaBrowserServers": "Buscando servidores ${product} locales...",
"localMediaBrowserServers": "Servidores ${product} locales",
"username": "Usuario",
"password": "Contraseña",
"signIn": "Iniciar sesión",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Iniciar sesión con Plex",
"pinExpired": "El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.",
"failedToRegisterAccount": "No se pudo registrar la cuenta: ${error}",
"enterJellyfinUrlError": "Introduce la URL de tu servidor Jellyfin",
"enterMediaBrowserUrlError": "Introduce la URL de tu servidor ${product}",
"addConnectionTitle": "Añadir conexión",
"addConnectionTitleScoped": "Añadir a ${name}",
"signInWithPlexCard": "Iniciar sesión con Plex",
"signInWithPlexCardSubtitle": "Autoriza este dispositivo. Se añaden servidores compartidos.",
"signInWithPlexCardSubtitleScoped": "Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.",
"connectToJellyfinCard": "Conectar a Jellyfin",
"connectToJellyfinCardSubtitle": "Introduce la URL del servidor, usuario y contraseña.",
"connectToJellyfinCardSubtitleScoped": "Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.",
"connectToMediaBrowserCard": "Conectar a ${product}",
"connectToMediaBrowserCardSubtitle": "Introduce la URL de tu servidor, el nombre de usuario y la contraseña.",
"connectToMediaBrowserCardSubtitleScoped": "Inicia sesión en tu servidor ${product}. Se vincula a ${name}.",
"borrowFromAnotherProfile": "Tomar prestado de otro perfil",
"borrowFromAnotherProfileSubtitle": "Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN."
}
+340 -301
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "En attente d'authentification...\nConnectez-vous depuis votre navigateur.",
"useBrowser": "Utiliser le navigateur",
"or": "ou",
"connectToJellyfin": "Se connecter à Jellyfin",
"connectToMediaBrowser": "Se connecter à ${product}",
"useQuickConnect": "Utiliser Quick Connect",
"quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.",
"quickConnectWaiting": "En attente d'approbation…",
@@ -45,7 +45,7 @@
"pause": "Pause",
"resume": "Reprendre",
"error": "Erreur",
"search": "Rechercher",
"search": "Recherche",
"home": "Accueil",
"back": "Retour",
"settings": "Paramètres",
@@ -64,7 +64,20 @@
"loading": "Chargement...",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter le plein écran",
"pressBackAgainToExit": "Appuyez à nouveau sur retour pour quitter"
"pressBackAgainToExit": "Appuyez à nouveau sur retour pour quitter",
"ratingSource": {
"critic": "Critiques",
"audience": "Public",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Critiques Rotten Tomatoes",
"rottenTomatoesAudience": "Public Rotten Tomatoes"
}
},
"screens": {
"licenses": "Licences",
@@ -107,6 +120,7 @@
"darkTheme": "Sombre",
"oledTheme": "OLED",
"libraryDensity": "Densité des bibliothèques",
"displayScale": "Échelle d'affichage",
"compact": "Compact",
"comfortable": "Confortable",
"tvCornerSpotlightBackdrop": "Illustration en vedette dans le coin",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Qualité de la musique",
"subtitleStyling": "Style des sous-titres",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Activer la journalisation détaillée pour le dépannage",
"viewLogs": "Voir les journaux",
"viewLogsDescription": "Voir les journaux de l’application",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Vider le cache d'images",
"clearImageCacheDescription": "Vide les affiches et vignettes en cache. Les images peuvent se charger plus lentement jusqu'à leur prochain téléchargement.",
"clearImageCacheSuccess": "Cache d'images vidé avec succès",
"resetSettings": "Réinitialiser les paramètres",
"resetSettingsDescription": "Restaurer les paramètres par défaut. Action irréversible.",
"resetSettingsSuccess": "Réinitialisation des paramètres réussie",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normaliser le volume lors de la conversion en stéréo",
"audioDownmixNormalizeDescription": "Atténuer le mixage pour éviter la saturation. Désactivez cette option pour conserver le volume d’origine, au risque de déformer les scènes bruyantes.",
"atmosDiagnostics": "Test de sortie Atmos",
"atmosDiagnosticsDescription": "Diagnostiquer la sortie Dolby Atmos en lisant des signaux de test via le lecteur système",
"atmosTestHlsAtmos": "Flux Atmos d'Apple",
"atmosTestHlsAtmosDescription": "Flux Dolby Atmos réputé fiable. L'ampli devrait afficher Dolby Atmos.",
"atmosTestHlsControl": "Flux surround d'Apple",
"atmosTestHlsControlDescription": "Flux témoin sans Atmos. L'ampli devrait afficher du surround sans Atmos.",
"atmosTestRawStream": "Flux EAC3 brut",
"atmosTestRawStreamDescription": "Diffuse le fichier de test exactement comme la lecture Atmos du lecteur. Nécessite l'URL du fichier de test.",
"atmosTestRawFile": "Fichier EAC3 brut",
"atmosTestRawFileDescription": "Lit le fichier de test avec une longueur connue. Nécessite l'URL du fichier de test.",
"atmosTestAsbarNative": "Moteur de rendu à tampon d'échantillons (natif)",
"atmosTestAsbarNativeDescription": "Transmet l'audio compressé intact du fichier directement au moteur de rendu du système. Nécessite l'URL du fichier de test.",
"atmosTestAsbarGenerated": "Moteur de rendu à tampon d'échantillons (reconstruit)",
"atmosTestAsbarGeneratedDescription": "Identique, mais avec la description audio reconstruite comme à la lecture. Nécessite l'URL du fichier de test.",
"atmosTestSessionMode": "Utiliser le mode lecture de films",
"atmosTestSessionModeDescription": "Désactivé utilise le mode documenté par Dolby. Activé utilise le mode précédent.",
"atmosTestShowRoutePicker": "Choisir la sortie AirPlay",
"atmosTestHideRoutePicker": "Masquer le sélecteur AirPlay",
"atmosTestRoutePickerDescription": "Envoie le test vers un récepteur AirPlay. Seul AirPlay indique le mode audio retenu.",
"atmosTestStop": "Arrêter le test",
"atmosTestUrl": "URL du fichier de test",
"atmosTestUrlDescription": "URL HTTP d'un fichier .ec3 Dolby Atmos brut (extrait par ex. avec ffmpeg)",
"atmosTestUrlMissing": "Définissez d'abord l'URL du fichier de test",
"atmosTestStatus": "État",
"dvConversionMode": "Conversion Dolby Vision",
"dvConversionModeDescription": "Choisir comment ExoPlayer gère les fichiers Dolby Vision de profil 7.",
"dvConversionAuto": "Auto",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Afficher les libellés de la barre de navigation",
"showNavBarLabelsDescription": "Afficher les libellés sous les icônes de la barre de navigation",
"startupSection": "Section de démarrage",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Chaînes favorites par défaut",
"liveTvDefaultFavoritesDescription": "Afficher uniquement les chaînes favorites à l'ouverture de la TV en direct",
"display": "Affichage",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Informations sur le fichier",
"overview": "",
"overview": "Aperçu",
"video": "Vidéo",
"audio": "Audio",
"subtitles": "Sous-titres",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Images intégrées",
"dataStreams": "Flux de données",
"lyrics": "Paroles",
"file": "Fichier",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Pièces jointes",
"delivery": "Diffusion",
"versionCounter": "Version ${index} sur ${count}",
"fileCounter": "Fichier ${index} sur ${count}",
"noStreams": "Le serveur n'a signalé aucun flux pour ce fichier.",
"copyPath": "Copier le chemin",
"pathCopied": "Chemin du fichier copié",
"codec": "Codec",
"codecTag": "",
"codecTag": "Balise de codec",
"resolution": "Résolution",
"codedResolution": "",
"codedResolution": "Résolution codée",
"bitrate": "Débit",
"frameRate": "Fréquence d'images",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotation",
"comment": "Commentaire",
"audioDescription": "Audiodescription",
"headerCompression": "Compression d'en-tête",
"sidecarFile": "Fichier annexe",
"transportTimestamp": "Horodatage du transport",
"displayOffset": "Décalage d'affichage",
"previewFailureCode": "Code d'échec de l'aperçu",
"previewRetries": "Tentatives d'aperçu",
"aspectRatio": "Format d'image",
"pixelAspectRatio": "",
"pixelAspectRatio": "Rapport d'aspect des pixels",
"profile": "Profil",
"level": "",
"level": "Niveau",
"bitDepth": "Profondeur de bits",
"pixelFormat": "",
"pixelFormat": "Format de pixels",
"colorSpace": "Espace colorimétrique",
"colorRange": "Gamme de couleurs",
"colorPrimaries": "Couleurs primaires",
"colorTransfer": "",
"colorTransfer": "Transfert de couleur",
"chromaSubsampling": "Sous-échantillonnage chromatique",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Position du chroma",
"scanType": "Type de balayage",
"interlaced": "Entrelacé",
"anamorphic": "Anamorphique",
"referenceFrames": "Images de référence",
"dynamicRange": "Plage dynamique",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Niveau Dolby Vision",
"dolbyVisionVersion": "Version Dolby Vision",
"dolbyVisionLayers": "Couches Dolby Vision",
"baseLayerCompatibility": "Compatibilité de la couche de base",
"avcBitstream": "Flux binaire AVC",
"nalLengthSize": "Taille de longueur NAL",
"scalingMatrix": "Matrice de mise à l'échelle personnalisée",
"streamIdentifier": "Identifiant de flux",
"streamIndex": "Index de flux",
"streamId": "ID de flux",
"language": "Langue",
"languageCode": "Code de langue",
"streamTitle": "Titre de la piste",
"channels": "Canaux",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Fréquence d'échantillonnage",
"spatialAudio": "Audio spatial",
"textBased": "Basé sur du texte",
"subtitleFormat": "Format annexe",
"provider": "Fournisseur",
"matchScore": "Score de correspondance",
"externalDelivery": "Peut être diffusé séparément",
"sidecarPath": "Chemin du fichier annexe",
"sourceStream": "Copié depuis",
"temporary": "Temporaire",
"timeBase": "Base de temps",
"overallBitrate": "Débit global",
"path": "Chemin",
"fileName": "",
"fileName": "Nom du fichier",
"size": "Taille",
"totalSize": "",
"totalSize": "Taille totale",
"container": "Conteneur",
"duration": "Durée",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Vignettes d'aperçu",
"previewIndex": "Index d'aperçu",
"packetLength": "Longueur du paquet",
"filePresent": "Fichier présent",
"fileReadable": "Lisible par le serveur",
"streamPath": "Chemin du flux",
"optimizedForStreaming": "Optimisé pour le streaming",
"has64bitOffsets": "Décalages 64 bits",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protocole",
"mediaType": "Type de média",
"sourceKind": "Type de source",
"optimizedVersion": "Version optimisée",
"optimizationTarget": "Cible d'optimisation",
"deletedAt": "Supprimé",
"remoteSource": "Source distante",
"infiniteStream": "Flux infini",
"directPlay": "Lecture directe",
"directStream": "Flux direct",
"transcoding": "Transcodage",
"etag": "ETag",
"versionId": "ID de version",
"fileId": "ID de fichier",
"defaultAudioTrack": "Piste audio par défaut",
"defaultSubtitleTrack": "Piste de sous-titres par défaut",
"subtitlesOff": "Désactivés",
"flagDefault": "Par défaut",
"flagForced": "Forcé",
"flagSelected": "Sélectionné",
"flagExternal": "Externe",
"flagHearingImpaired": "Sourds et malentendants",
"flagDub": "Doublage",
"flagOriginal": "Originale"
},
"mediaMenu": {
"markAsWatched": "Marquer comme vu",
@@ -505,9 +502,35 @@
"shufflePlay": "Lecture aléatoire",
"shuffleNotAvailableOffline": "La lecture aléatoire n’est pas disponible hors ligne",
"fileInfo": "Informations sur le fichier",
"deleteFromServer": "Supprimer du serveur",
"confirmDelete": "Supprimer ce média et ses fichiers de votre serveur ?",
"deleteEpisodeFromServer": "Supprimer l'épisode du serveur",
"deleteSeasonFromServer": "Supprimer la saison du serveur",
"deleteShowFromServer": "Supprimer la série du serveur",
"deleteMovieFromServer": "Supprimer le film du serveur",
"deleteEpisodeTitle": "Supprimer cet épisode ?",
"deleteSeasonTitle": "Supprimer cette saison ?",
"deleteShowTitle": "Supprimer cette série ?",
"deleteMovieTitle": "Supprimer ce film ?",
"deleteEpisodeConfirm": "Supprimer l'épisode",
"deleteSeasonConfirm": "Supprimer la saison",
"deleteShowConfirm": "Supprimer la série",
"deleteMovieConfirm": "Supprimer le film",
"deleteAnyway": "Supprimer quand même",
"confirmDeleteTarget": "Supprimer définitivement ${title} de votre serveur ?",
"deleteMultipleWarning": "Cela inclut tous les épisodes et leurs fichiers.",
"deleteEpisodeCountWarning": {
"one": "Cela supprime ${n} épisode qu'elle contient, ainsi que son fichier.",
"other": "Cela supprime les ${n} épisodes qu'elle contient, ainsi que leurs fichiers."
},
"deleteMultiPartWarning": {
"one": "Cet élément est stocké sous forme de ${n} fichier, qui sera supprimé.",
"other": "Cet élément est stocké sur ${n} fichiers, et ils seront tous supprimés."
},
"deleteSharedFileHeading": {
"one": "${n} autre épisode est stocké dans le même fichier et sera également supprimé :",
"other": "${n} autres épisodes sont stockés dans le même fichier et seront également supprimés :"
},
"deleteScopeUnverifiedProbeFailed": "Plezy n'a pas pu vérifier quels fichiers seront supprimés ; il risque donc d'en supprimer plus que l'élément nommé ci-dessus. Annulez et réessayez, ou supprimez quand même.",
"deleteScopeUnverifiedNoFileInfo": "Votre serveur n'a pas fourni les détails du fichier pour cet élément, Plezy ne peut donc pas vérifier quels fichiers seront supprimés. Il risque d'en supprimer plus que l'élément nommé ci-dessus.",
"mediaDeletedSuccessfully": "Élément média supprimé avec succès",
"mediaFailedToDelete": "Échec de la suppression de l'élément média",
"rate": "Noter",
@@ -583,8 +606,9 @@
"playNext": "Lire l'épisode suivant",
"playButton": "Lire",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "En pause",
"playbackResumed": "En lecture",
"loadingVideo": "Chargement de la vidéo",
"showPlaybackControls": "Afficher les commandes de lecture",
"hidePlaybackControls": "Masquer les commandes de lecture",
"seekBackwardButton": "Reculer de ${seconds} secondes",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Aucun chapitre disponible",
"queue": "File d'attente",
"noQueueItems": "Aucun élément dans la file d'attente",
"noAudioDevicesAvailable": "Aucun appareil audio disponible",
"searchSubtitles": "Rechercher des sous-titres",
"language": "Langue",
"noSubtitlesFound": "Aucun sous-titre trouvé",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Supprimé de « Continuer à regarder »",
"errorLoading": "Erreur : ${error}",
"searchPartialResults": "",
"searchPartialResults": "Certains serveurs multimédia n'ont pas pu être interrogés. Affichage des résultats disponibles.",
"streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.",
"liveStreamInterrupted": "Le direct a été interrompu. Appuyez sur Lecture pour réessayer.",
"fileInfoNotAvailable": "Informations sur le fichier non disponibles",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Passage au lecteur compatible...",
"serverLimitTitle": "Échec de la lecture",
"serverLimitBody": "Erreur serveur (HTTP 500). Une limite de bande passante/transcodage a probablement rejeté cette session. Demandez au propriétaire de l'ajuster.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Fichier indisponible",
"mediaUnreadableBody": "Le serveur a trouvé cet élément mais n'a pas pu lire son fichier (HTTP 404). Le fichier a probablement été déplacé, supprimé, ou son stockage est hors ligne. Demandez au propriétaire du serveur de vérifier le fichier et de relancer l'analyse de la bibliothèque.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Journaux envoyés",
"logsUploadFailed": "Échec de l’envoi des journaux",
"logId": "Identifiant du journal"
"logId": "Identifiant du journal",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Texte",
@@ -718,6 +746,8 @@
"overrideStrip": "Supprimer le style",
"positionTop": "Haut",
"positionBottom": "Bas",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Gras",
"italic": "Italique",
"renderResolution": "Résolution de rendu",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Aucune connexion — ajoutez-en une pour utiliser ce profil.",
"noConnections": "Aucune connexion",
"plexHomeAccount": "Compte Plex Home",
"plexAccountChip": "Compte Plex : ${account}",
"plexAccountUserChip": "${user} via ${account}",
"connectionDefault": "Par défaut",
"connectionAs": "en tant que ${displayName}",
"makeDefault": "Définir par défaut",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Session expirée pour ${name}",
"sessionExpiredMany": "Session expirée pour ${count} serveurs",
"signInAgain": "Se reconnecter",
"editJellyfinTitle": "Modifier la connexion Jellyfin",
"editJellyfinIntro": "Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l'URL joignable avec la latence la plus faible."
"editMediaBrowserTitle": "Modifier la connexion ${product}",
"editMediaBrowserIntro": "Ajoutez ou retirez des URL pour ${serverName}. Plezy utilisera l'URL joignable avec la latence la plus faible."
},
"discover": {
"title": "Découvrir",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Échec de la recherche : ${error}",
"searchUnavailable": "",
"searchUnavailable": "La recherche n'a pu atteindre aucun serveur multimédia.",
"connectionTimeout": "Délai d'attente de connexion dépassé pendant le chargement ${context}",
"connectionFailed": "Impossible de se connecter au serveur multimédia",
"unableToLoad": "Impossible de charger ${context}. Réessayez.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Envoyer les journaux"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy n'a pas pu démarrer",
"failedBody": "Une erreur s'est produite au démarrage. Les détails ci-dessous identifient la cause.",
"failedBodyRepairable": "Le fichier de paramètres enregistré de Plezy est endommagé et doit être reconstruit avant que Plezy ne puisse démarrer. Réessayer ne servira à rien — choisissez Réparer le stockage.",
"phaseLabel": "Étape",
"showDetails": "Afficher les détails",
"hideDetails": "Masquer les détails",
"copyDetails": "Copier les détails",
"detailsCopied": "Détails copiés dans le presse-papiers",
"uploadDetails": "Téléverser les détails",
"repairStorage": "Réparer le stockage",
"repairTitle": "Réparer les données stockées ?",
"repairBodyCommon": "Le fichier de paramètres de Plezy est endommagé et illisible. La réparation rétablit tous les paramètres par défaut.",
"repairBodyOneCredential": "Une connexion enregistrée est endommagée et illisible. La réparation ne supprime que celle-ci ; vos autres paramètres sont laissés intacts.",
"repairBodySignInsKept": "Vos serveurs et profils devraient rester connectés.",
"repairBodySignInsLost": "La clé qui protège vos connexions enregistrées ne peut pas être récupérée à partir de ce fichier ; vous devrez donc vous reconnecter à chaque serveur et profil. Rien sur votre serveur multimédia n'est affecté.",
"repairBodySessionsUncertain": "Les trackers (MAL, AniList, Simkl, Trakt) et Seerr sont stockés séparément et peuvent ou non être conservés. Plezy vous indiquera exactement ce qu'il a gardé.",
"repairConfirm": "Réparer",
"repairSucceeded": "Stockage réparé",
"repairNeedsRestart": "Stockage réparé — redémarrage requis",
"restartRequiredBody": "Vos données ont été réparées, mais Plezy doit redémarrer pour pouvoir les utiliser. Fermez Plezy puis rouvrez-le.",
"quitPlezy": "Quitter Plezy",
"repairFailed": "Échec de la réparation",
"repairKeptSignIns": "Vos serveurs et profils sont toujours connectés.",
"repairLostSignIns": "La clé qui protégeait vos connexions enregistrées n'a pas pu être récupérée. Vous devrez vous reconnecter à chaque serveur et profil.",
"repairLostSessions": "Au moins une connexion à un tracker ou à Seerr a été perdue et devra être rétablie.",
"backupTitle": "Une copie du fichier endommagé a été conservée",
"backupWarning": "Elle contient vos identifiants de connexion. Ne la téléversez pas et ne la partagez pas.",
"deleteBackup": "Supprimer la copie",
"backupDeleted": "Copie supprimée.",
"previousFailureTitle": "Plezy n'a pas réussi à démarrer la dernière fois"
},
"licenses": {
"relatedPackages": "Paquets associés",
@@ -1005,8 +1042,8 @@
"licensesCount": "${count} licences"
},
"navigation": {
"libraries": "Bibliothèques",
"downloads": "Téléchargements",
"libraries": "Bibliothèque",
"downloads": "Téléchargement",
"liveTv": "TV en direct",
"explore": "Explorer"
},
@@ -1043,7 +1080,10 @@
"characters": "Personnages",
"addToWatchlist": "Ajouter à la liste de suivi",
"removeFromWatchlist": "Retirer de la liste de suivi",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Impossible de mettre à jour la liste de suivi",
"watchlistNoMatch": "",
"notInLibrary": "Absent de votre bibliothèque",
"inTheseLibraries": "Dans ces bibliothèques",
"checkingLibrary": "Vérification de votre bibliothèque...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Recherchez des films et des séries sur ${source}.",
"searchFailed": "Échec de la recherche. Vérifiez votre connexion et réessayez.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} populaire",
"rankAiring": "#${n} en diffusion",
"rankRated": "#${n} mieux noté",
"rankFavorited": "#${n} favoris",
"rankTrending": "#${n} tendance",
"rankSeasonal": "#${n} de ${season}",
"watchingNow": "${n} regardent",
"available": "Disponible",
"partiallyAvailable": "Partiellement disponible",
"availableIn4k": "4K disponible",
"requested": "Demandé",
"pendingApproval": "En attente d'approbation",
"processing": "En cours de traitement",
"declined": "Refusé",
"requestFailed": "Échec de la demande",
"requested4k": "4K demandé",
"seasonsAvailable": "${available}/${total} saisons",
"nextEpisodeIn": "Ép ${episode} dans ${duration}",
"nextAiringIn": "Prochain dans ${duration}",
"episodesShort": "${n} ép",
"minutesPerEpisode": "${n} min/ép",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} répertoriés",
"viewersDay": "${n} vus aujourd'hui",
"viewersWeek": "${n} vus cette semaine",
"viewersMonth": "${n} vus ce mois-ci",
"viewersYear": "${n} vus cette année",
"viewersAllTime": "${n} spectateurs",
"planning": "${n} prévoient de le regarder",
"favorited": "${n} favoris",
"dropRate": "${percent} l'ont abandonné",
"comments": {
"one": "",
"other": ""
"one": "${n} commentaire",
"other": "${n} commentaires"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} votes",
"watching": "${n} le regardent",
"completed": "${n} l'ont terminé",
"onHold": "${n} en pause",
"dropped": "${n} ont abandonné"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Hiver",
"spring": "Printemps",
"summer": "Été",
"fall": "Automne",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "Court TV",
"movie": "Film",
"special": "Spécial",
"ova": "OVA",
"ona": "ONA",
"music": "Musique",
"other": "Autre"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Original",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Roman",
"visualNovel": "Visual novel",
"game": "Jeu",
"webComic": "Webcomic",
"musicRelease": "Musique",
"otherMedia": "Autre"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Réalisateur",
"writer": "Scénariste",
"producer": "Producteur",
"creator": "Créateur",
"composer": "Compositeur"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Préquelle",
"sequel": "Suite",
"sideStory": "Histoire parallèle",
"spinOff": "Spin-off",
"alternativeVersion": "Version alternative",
"summary": "Résumé",
"parentStory": "Histoire principale",
"adaptation": "Adaptation",
"other": "Lié"
},
"broadcast": "Diffusé le ${day} à ${time}",
"broadcastWithZone": "Diffusé le ${day} à ${time} ${timezone}",
"detail": {
"originalTitle": "Titre original",
"alsoKnownAs": "Également connu sous",
"studios": "Studios",
"country": "Pays",
"language": "Langue",
"released": "Sorti",
"physicalRelease": "En disque",
"ended": "Terminé",
"addedOn": "Ajouté le ${date}",
"yourRating": "Votre note",
"budget": "Budget",
"revenue": "Box-office",
"contentAdvisory": "Recommandation d'âge",
"tags": "Tags",
"revealSpoilerTags": "Afficher les tags spoiler",
"links": "Liens",
"watchOn": "Regarder sur",
"watchTrailer": "Regarder la bande-annonce",
"openOn": "Ouvrir sur ${site}",
"crew": "Équipe",
"ratings": "Notes",
"schedule": "Programme",
"recommendedByUsers": {
"one": "Recommandé par ${n} utilisateur",
"other": "Recommandé par ${n} utilisateurs"
},
"recommendedBy": "Recommandé par ${who}",
"favoritedBy": "Ajouté aux favoris par ${who}",
"unairedEpisodes": "${n} pas encore diffusés",
"recommendedByPercent": "Recommandé par ${percent} des spectateurs",
"relatedTitles": "Titres liés",
"background": "Contexte"
},
"totalResults": {
"one": "${n} résultat",
"other": "${n} résultats"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Regarder la chaîne",
"favorites": "Favoris",
"reorderFavorites": "Réorganiser les favoris",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Impossible de charger les favoris. Vérifiez votre connexion et réessayez.",
"favoritesUpdateFailed": "Impossible de mettre à jour les favoris. Vérifiez votre connexion et réessayez.",
"joinSession": "Rejoindre la session en cours",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Modifier la règle de synchronisation",
"removeSyncRule": "Supprimer la règle de synchronisation",
"removeSyncRuleConfirm": "Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Arrêter la synchronisation de « ${title} » ?",
"deleteSyncRuleDownloads": "Supprimer aussi les téléchargements associés",
"deleteSyncRuleDownloadsDescription": "Les téléchargements utilisés par une autre règle de synchronisation ou un autre profil seront conservés.",
"syncRuleCreated": "Règle de synchronisation créée — ${count} épisodes non vus conservés",
"syncRuleUpdated": "Règle de synchronisation mise à jour",
"syncRuleRemoved": "Règle de synchronisation supprimée",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Règle de synchronisation et téléchargements associés supprimés",
"syncRuleCleanupBusy": "Les règles de synchronisation sont en cours de mise à jour. Réessayez dans un instant.",
"syncRuleCleanupUnavailable": "Les téléchargements associés n'ont pas pu être identifiés en toute sécurité. Reconnectez le serveur et réessayez, ou retirez la règle sans supprimer les téléchargements.",
"syncedNewEpisodes": "${count} nouveaux épisodes synchronisés pour ${title}",
"activeSyncRules": "Règles de synchronisation",
"noSyncRules": "Aucune règle de synchronisation",
@@ -1614,9 +1644,15 @@
"audioSync": "Synchronisation audio",
"subtitleSync": "Synchronisation des sous-titres",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Sortie audio",
"performanceOverlay": "Données de performance",
"audioPassthrough": "Transmission audio directe",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Activer Plezy sur ${service}",
"body": "Rendez-vous sur ${url} et entrez ce code :",
"instructions": "",
"openToActivate": "Ouvrir ${service} pour activer",
"copyCode": "Copier le code d'activation",
"waitingForAuthorization": "En attente d'autorisation…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Se connecter à ${service}",
"body": "Scannez ce code QR ou ouvrez l'URL sur n'importe quel appareil.",
"openToSignIn": "Ouvrir ${service} pour se connecter",
"openToSignIn": "Ouvrir ${service} pour se connecter"
},
"pendingAuth": {
"copyUrl": "Copier l'URL de connexion",
"urlCopied": "URL copiée"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Ajouter un serveur Jellyfin",
"addMediaBrowserTitle": "Ajouter un serveur ${product}",
"serverUrls": "URL du serveur",
"serverUrlsHelper": "Plusieurs URL possibles, séparées par des virgules.",
"findServer": "Rechercher un serveur",
"searchingLocalServers": "Recherche de serveurs Jellyfin locaux...",
"localServers": "Serveurs Jellyfin locaux",
"searchingLocalMediaBrowserServers": "Recherche de serveurs ${product} locaux...",
"localMediaBrowserServers": "Serveurs ${product} locaux",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"signIn": "Se connecter",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Se connecter avec Plex",
"pinExpired": "Le PIN a expiré avant la connexion. Veuillez réessayer.",
"failedToRegisterAccount": "Échec de l'enregistrement du compte : ${error}",
"enterJellyfinUrlError": "Saisissez l'URL de votre serveur Jellyfin",
"enterMediaBrowserUrlError": "Saisissez l'URL de votre serveur ${product}",
"addConnectionTitle": "Ajouter une connexion",
"addConnectionTitleScoped": "Ajouter à ${name}",
"signInWithPlexCard": "Se connecter avec Plex",
"signInWithPlexCardSubtitle": "Autorisez cet appareil. Les serveurs partagés sont ajoutés.",
"signInWithPlexCardSubtitleScoped": "Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.",
"connectToJellyfinCard": "Se connecter à Jellyfin",
"connectToJellyfinCardSubtitle": "Saisissez l'URL du serveur, le nom d'utilisateur et le mot de passe.",
"connectToJellyfinCardSubtitleScoped": "Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.",
"connectToMediaBrowserCard": "Se connecter à ${product}",
"connectToMediaBrowserCardSubtitle": "Saisissez l'URL de votre serveur, votre nom d'utilisateur et votre mot de passe.",
"connectToMediaBrowserCardSubtitleScoped": "Connectez-vous à votre serveur ${product}. Sera associé à ${name}.",
"borrowFromAnotherProfile": "Emprunter à un autre profil",
"borrowFromAnotherProfileSubtitle": "Réutiliser la connexion d'un autre profil. Les profils protégés par PIN exigent un PIN."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.",
"useBrowser": "Böngésző használata",
"or": "vagy",
"connectToJellyfin": "Csatlakozás Jellyfinhez",
"connectToMediaBrowser": "Csatlakozás a(z) ${product} szolgáltatáshoz",
"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…",
@@ -64,7 +64,20 @@
"loading": "Betöltés...",
"fullscreen": "Teljes képernyő",
"exitFullscreen": "Kilépés a teljes képernyőből",
"pressBackAgainToExit": "A kilépéshez nyomd meg újra a Vissza gombot"
"pressBackAgainToExit": "A kilépéshez nyomd meg újra a Vissza gombot",
"ratingSource": {
"critic": "Kritikusok",
"audience": "Közönség",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes kritikusok",
"rottenTomatoesAudience": "Rotten Tomatoes közönség"
}
},
"screens": {
"licenses": "Licencek",
@@ -107,6 +120,7 @@
"darkTheme": "Sötét",
"oledTheme": "OLED",
"libraryDensity": "Könyvtársűrűség",
"displayScale": "Kijelző méretezése",
"compact": "Kompakt",
"comfortable": "Kényelmes",
"tvCornerSpotlightBackdrop": "Sarokban megjelenő kiemelt háttérkép",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Zene minősége",
"subtitleStyling": "Feliratok stílusa",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Részletes naplózás engedélyezése a hibaelhárításhoz",
"viewLogs": "Naplók megtekintése",
"viewLogsDescription": "Alkalmazásnaplók megtekintése",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Képgyorsítótár törlése",
"clearImageCacheDescription": "Törli a gyorsítótárazott borítókat és miniatűröket. A képek lassabban tölthetők be, amíg újra le nem töltődnek.",
"clearImageCacheSuccess": "A képgyorsítótár sikeresen törölve",
"resetSettings": "Beállítások visszaállítása",
"resetSettingsDescription": "Az alapértelmezett beállítások visszaállítása. Ez a művelet nem vonható vissza.",
"resetSettingsSuccess": "A beállítások sikeresen visszaállítva",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Hangerő normalizálása lekeveréskor",
"audioDownmixNormalizeDescription": "A keverés szintjének csökkentése a torzítás elkerülésére. Kapcsold ki az eredeti hangerő megőrzéséhez (a hangos jelenetek torzíthatnak).",
"atmosDiagnostics": "Atmos kimeneti teszt",
"atmosDiagnosticsDescription": "Dolby Atmos kimenet diagnosztizálása tesztjelek lejátszásával a rendszerlejátszón keresztül",
"atmosTestHlsAtmos": "Apple Atmos folyam",
"atmosTestHlsAtmosDescription": "Igazoltan működő Dolby Atmos-adatfolyam. Az erősítő kijelzőjén a Dolby Atmos formátumnak kell megjelennie.",
"atmosTestHlsControl": "Apple térhangzású folyam",
"atmosTestHlsControlDescription": "Atmos nélküli ellenőrző adatfolyam. Az erősítő kijelzőjén térhangzásnak kell megjelennie, Atmos nélkül.",
"atmosTestRawStream": "Nyers EAC3 folyam",
"atmosTestRawStreamDescription": "A tesztfájlt pontosan úgy közvetíti, mint a lejátszón belüli Atmos lejátszás. Szükséges a tesztfájl URL-je.",
"atmosTestRawFile": "Nyers EAC3 fájl",
"atmosTestRawFileDescription": "Ismert hosszúságú tesztfájlt játszik le. Szükséges a tesztfájl URL-je.",
"atmosTestAsbarNative": "Mintapuffer-megjelenítő (natív)",
"atmosTestAsbarNativeDescription": "A fájl érintetlen tömörített hangját közvetlenül a rendszer megjelenítőjének adja. Szükséges a tesztfájl URL-je.",
"atmosTestAsbarGenerated": "Mintapuffer-megjelenítő (újraépített)",
"atmosTestAsbarGeneratedDescription": "Ugyanaz, de a lejátszás módján felépített hangleírással. Szükséges a tesztfájl URL-je.",
"atmosTestSessionMode": "Filmlejátszási mód használata",
"atmosTestSessionModeDescription": "Kikapcsolva a Dolby által dokumentált módot használja. Bekapcsolva a korábbi módot.",
"atmosTestShowRoutePicker": "AirPlay kimenet választása",
"atmosTestHideRoutePicker": "AirPlay kimenetválasztó elrejtése",
"atmosTestRoutePickerDescription": "Elküldi a tesztet egy AirPlay vevőnek. Csak az AirPlay jelzi a feloldott hangmódot.",
"atmosTestStop": "Teszt leállítása",
"atmosTestUrl": "Tesztfájl URL-je",
"atmosTestUrlDescription": "Nyers .ec3 Dolby Atmos fájl HTTP URL-je (pl. ffmpeg-gel kinyerve)",
"atmosTestUrlMissing": "Először állítsd be a tesztfájl URL-jét",
"atmosTestStatus": "Állapot",
"dvConversionMode": "Dolby Vision-átalakítás",
"dvConversionModeDescription": "Válaszd ki, hogyan kezelje az ExoPlayer a Dolby Vision Profile 7 fájlokat.",
"dvConversionAuto": "Automatikus",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Navigációs sáv címkéinek megjelenítése",
"showNavBarLabelsDescription": "Szöveges címkék megjelenítése a navigációs sáv ikonjai alatt",
"startupSection": "Indítási oldal",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Kedvenc csatornák alapértelmezés szerint",
"liveTvDefaultFavoritesDescription": "Csak a kedvenc csatornák megjelenítése az Élő TV megnyitásakor",
"display": "Kijelző",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Fájlinformáció",
"overview": "",
"overview": "Áttekintés",
"video": "Videó",
"audio": "Hang",
"subtitles": "Feliratok",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Beágyazott képek",
"dataStreams": "Adatfolyamok",
"lyrics": "Dalszövegek",
"file": "Fájl",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Mellékletek",
"delivery": "Kézbesítés",
"versionCounter": "${index}. verzió a(z) ${count} közül",
"fileCounter": "${index}. fájl a(z) ${count} közül",
"noStreams": "A szerver nem jelentett adatfolyamot ehhez a fájlhoz.",
"copyPath": "Útvonal másolása",
"pathCopied": "Fájlútvonal kimásolva",
"codec": "Kodek",
"codecTag": "",
"codecTag": "Kodekcímke",
"resolution": "Felbontás",
"codedResolution": "",
"codedResolution": "Kódolt felbontás",
"bitrate": "Bitráta",
"frameRate": "Képkockasebesség",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Forgatás",
"comment": "Megjegyzés",
"audioDescription": "Hangleírás",
"headerCompression": "Fejléc-tömörítés",
"sidecarFile": "Kísérő fájl",
"transportTimestamp": "Szállítási időbélyeg",
"displayOffset": "Megjelenítési eltolás",
"previewFailureCode": "Előnézeti hibakód",
"previewRetries": "Előnézeti újrapróbálkozások",
"aspectRatio": "Méretarány",
"pixelAspectRatio": "",
"pixelAspectRatio": "Pixel képarány",
"profile": "Profil",
"level": "",
"level": "Szint",
"bitDepth": "Bitmélység",
"pixelFormat": "",
"pixelFormat": "Pixelformátum",
"colorSpace": "Színtér",
"colorRange": "Színtartomány",
"colorPrimaries": "Elsődleges színek",
"colorTransfer": "",
"colorTransfer": "Színátvitel",
"chromaSubsampling": "Krominancia-alulmintavételezés",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Kroma elhelyezkedés",
"scanType": "Pásztázási típus",
"interlaced": "Váltott soros",
"anamorphic": "Anamorf",
"referenceFrames": "Referencia képkockák",
"dynamicRange": "Dinamikatartomány",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision szint",
"dolbyVisionVersion": "Dolby Vision verzió",
"dolbyVisionLayers": "Dolby Vision rétegek",
"baseLayerCompatibility": "Alapréteg-kompatibilitás",
"avcBitstream": "AVC bitfolyam",
"nalLengthSize": "NAL hosszméret",
"scalingMatrix": "Egyéni méretezési mátrix",
"streamIdentifier": "Adatfolyam-azonosító",
"streamIndex": "Adatfolyam-index",
"streamId": "Adatfolyam azonosítója",
"language": "Nyelv",
"languageCode": "Nyelvkód",
"streamTitle": "Sáv címe",
"channels": "Csatornák",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Mintavételi frekvencia",
"spatialAudio": "Térbeli hang",
"textBased": "Szövegalapú",
"subtitleFormat": "Kísérő formátum",
"provider": "Szolgáltató",
"matchScore": "Egyezési pontszám",
"externalDelivery": "Külön is kiszolgálható",
"sidecarPath": "Kísérő fájl útvonala",
"sourceStream": "Másolva innen",
"temporary": "Ideiglenes",
"timeBase": "Időalap",
"overallBitrate": "Összesített bitráta",
"path": "Elérési út",
"fileName": "",
"fileName": "Fájlnév",
"size": "Méret",
"totalSize": "",
"totalSize": "Teljes méret",
"container": "Konténer",
"duration": "Időtartam",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Előnézeti miniatűrök",
"previewIndex": "Előnézeti index",
"packetLength": "Csomaghossz",
"filePresent": "Fájl jelen van",
"fileReadable": "A szerver számára olvasható",
"streamPath": "Adatfolyam útvonala",
"optimizedForStreaming": "Adatfolyam-továbbításra optimalizálva",
"has64bitOffsets": "64 bites eltolások",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protokoll",
"mediaType": "Médiatípus",
"sourceKind": "Forrás típusa",
"optimizedVersion": "Optimalizált verzió",
"optimizationTarget": "Optimalizálási cél",
"deletedAt": "Törölve",
"remoteSource": "Távoli forrás",
"infiniteStream": "Végtelen adatfolyam",
"directPlay": "Közvetlen lejátszás",
"directStream": "Közvetlen adatfolyam",
"transcoding": "Átkódolás",
"etag": "ETag",
"versionId": "Verzióazonosító",
"fileId": "Fájlazonosító",
"defaultAudioTrack": "Alapértelmezett hangsáv",
"defaultSubtitleTrack": "Alapértelmezett feliratsáv",
"subtitlesOff": "Ki",
"flagDefault": "Alapértelmezett",
"flagForced": "Kényszerített",
"flagSelected": "Kiválasztva",
"flagExternal": "Külső",
"flagHearingImpaired": "Nagyothallóknak",
"flagDub": "Szinkron",
"flagOriginal": "Eredeti"
},
"mediaMenu": {
"markAsWatched": "Megjelölés megtekintettként",
@@ -505,9 +502,35 @@
"shufflePlay": "Véletlenszerű lejátszás",
"shuffleNotAvailableOffline": "A véletlenszerű lejátszás nem érhető el offline",
"fileInfo": "Fájlinformáció",
"deleteFromServer": "Törlés a szerverről",
"confirmDelete": "Törlöd ezt a médiát és a fájljait a szerveredről?",
"deleteEpisodeFromServer": "Epizód törlése a szerverről",
"deleteSeasonFromServer": "Évad törlése a szerverről",
"deleteShowFromServer": "Sorozat törlése a szerverről",
"deleteMovieFromServer": "Film törlése a szerverről",
"deleteEpisodeTitle": "Töröljük ezt az epizódot?",
"deleteSeasonTitle": "Töröljük ezt az évadot?",
"deleteShowTitle": "Töröljük ezt a sorozatot?",
"deleteMovieTitle": "Töröljük ezt a filmet?",
"deleteEpisodeConfirm": "Epizód törlése",
"deleteSeasonConfirm": "Évad törlése",
"deleteShowConfirm": "Sorozat törlése",
"deleteMovieConfirm": "Film törlése",
"deleteAnyway": "Törlés mindenképp",
"confirmDeleteTarget": "Véglegesen töröljük a(z) ${title} elemet a szerverről?",
"deleteMultipleWarning": "Ez magában foglalja az összes epizódot és azok fájljait.",
"deleteEpisodeCountWarning": {
"one": "Ez törli az összes (${n}) epizódot benne és a hozzá tartozó fájlt.",
"other": "Ez törli az összes (${n}) epizódot benne és a fájljaikat."
},
"deleteMultiPartWarning": {
"one": "Ez az elem ${n} fájlban van tárolva, amely törlésre kerül.",
"other": "Ez az elem ${n} fájlban van tárolva, és mindegyik törlésre kerül."
},
"deleteSharedFileHeading": {
"one": "További ${n} epizód is ugyanabban a fájlban van, és szintén törlésre kerül:",
"other": "További ${n} epizód is ugyanabban a fájlban van, és szintén törlésre kerül:"
},
"deleteScopeUnverifiedProbeFailed": "A Plezy nem tudta ellenőrizni, mely fájlokat távolítja el ez, ezért a fent nevezett elemnél többet is törölhet. Szakítsa meg és próbálja újra, vagy töröljön mindenképp.",
"deleteScopeUnverifiedNoFileInfo": "A szerver nem szolgáltatott fájladatokat ehhez az elemhez, ezért a Plezy nem tudja ellenőrizni, mely fájlokat távolít el. A fent nevezett elemnél többet is törölhet.",
"mediaDeletedSuccessfully": "Médiaelem sikeresen törölve",
"mediaFailedToDelete": "Nem sikerült a médiaelem törlése",
"rate": "Értékelés",
@@ -583,8 +606,9 @@
"playNext": "Következő lejátszása",
"playButton": "Lejátszás",
"pauseButton": "Szünet",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Szüneteltetve",
"playbackResumed": "Lejátszás",
"loadingVideo": "Videó betöltése",
"showPlaybackControls": "Lejátszásvezérlők megjelenítése",
"hidePlaybackControls": "Lejátszásvezérlők elrejtése",
"seekBackwardButton": "Tekerés hátra ${seconds} másodperccel",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Nincsenek elérhető fejezetek",
"queue": "Lejátszási sor",
"noQueueItems": "Nincsenek elemek a sorban",
"noAudioDevicesAvailable": "Nincsenek elérhető audioeszközök",
"searchSubtitles": "Feliratok keresése",
"language": "Nyelv",
"noSubtitlesFound": "Nem találhatók feliratok",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Eltávolítva a folytatásból",
"errorLoading": "Hiba: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Néhány médiaszervert nem sikerült keresni. Az elérhető találatok megjelenítése.",
"streamInterrupted": "Az adatfolyam megszakadt. Az újrapróbálkozáshoz indítsd el a lejátszást, vagy tekerj másik pozícióra.",
"liveStreamInterrupted": "Az élő adás megszakadt. Nyomd meg a lejátszást az újrapróbálkozáshoz.",
"fileInfoNotAvailable": "A fájlinformáció nem érhető el",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Váltás kompatibilis lejátszóra...",
"serverLimitTitle": "A lejátszás nem sikerült",
"serverLimitBody": "Szerverhiba (HTTP 500). A munkamenetet valószínűleg egy sávszélességi vagy átkódolási korlát utasította el. Kérd meg a tulajdonost a korlát módosítására.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "A fájl nem érhető el",
"mediaUnreadableBody": "A szerver megtalálta ezt az elemet, de nem tudta beolvasni a fájlját (HTTP 404). A fájl valószínűleg áthelyezésre vagy törlésre került, vagy a tárhelye offline. Kérje meg a szerver tulajdonosát, hogy ellenőrizze a fájlt, és vizsgálja újra a könyvtárat.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Naplók feltöltve",
"logsUploadFailed": "Nem sikerült a naplók feltöltése",
"logId": "Naplóazonosító"
"logId": "Naplóazonosító",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Szöveg",
@@ -718,6 +746,8 @@
"overrideStrip": "Stílus eltávolítása",
"positionTop": "Fent",
"positionBottom": "Lent",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Félkövér",
"italic": "Dőlt",
"renderResolution": "Renderelési felbontás",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Nincsenek kapcsolatok — adj hozzá egyet a profil használatához.",
"noConnections": "Nincsenek kapcsolatok",
"plexHomeAccount": "Plex Home-fiók",
"plexAccountChip": "Plex-fiók: ${account}",
"plexAccountUserChip": "${account} fiókján keresztül: ${user}",
"connectionDefault": "Alapértelmezett",
"connectionAs": "mint ${displayName}",
"makeDefault": "Beállítás alapértelmezettként",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "A(z) ${name} munkamenete lejárt",
"sessionExpiredMany": "${count} szerver munkamenete lejárt",
"signInAgain": "Bejelentkezés újra",
"editJellyfinTitle": "Jellyfin kapcsolat szerkesztése",
"editJellyfinIntro": "URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni."
"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."
},
"discover": {
"title": "Felfedezés",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Keresés sikertelen: ${error}",
"searchUnavailable": "",
"searchUnavailable": "A keresés egyetlen médiaszervert sem tudott elérni.",
"connectionTimeout": "Hálózati időtúllépés a következő betöltésekor: ${context}",
"connectionFailed": "Nem sikerült csatlakozni a médiaszerverhez",
"unableToLoad": "Nem sikerült betölteni a következőt: ${context}. Próbáld újra.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Naplók feltöltése"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "A Plezy nem tudott elindulni",
"failedBody": "Valami hiba történt az indítás során. Az alábbi részletek mutatják, mi hibázott.",
"failedBodyRepairable": "A Plezy mentett beállításfájlja sérült, és újra kell építeni, mielőtt a Plezy elindulhatna. Az újrapróbálkozás nem segít – válassza a Tárhely javítása lehetőséget.",
"phaseLabel": "Lépés",
"showDetails": "Részletek megjelenítése",
"hideDetails": "Részletek elrejtése",
"copyDetails": "Részletek másolása",
"detailsCopied": "A részletek a vágólapra kerültek",
"uploadDetails": "Részletek feltöltése",
"repairStorage": "Tárhely javítása",
"repairTitle": "Javítsuk a tárolt adatokat?",
"repairBodyCommon": "A Plezy beállításfájlja sérült, és nem olvasható. A javítás minden beállítást visszaállít az alapértelmezettre.",
"repairBodyOneCredential": "Egy mentett bejelentkezés sérült, és nem olvasható. A javítás csak azt távolítja el; a többi beállítása érintetlen marad.",
"repairBodySignInsKept": "A szervereinek és profiljainak bejelentkezve kell maradniuk.",
"repairBodySignInsLost": "A mentett bejelentkezéseket védő kulcs nem nyerhető vissza ebből a fájlból, ezért minden szerverre és profilba újra be kell jelentkeznie. A médiaszerveren semmi nem érintett.",
"repairBodySessionsUncertain": "A nyomkövetők (MAL, AniList, Simkl, Trakt) és a Seerr külön vannak tárolva, és megmaradhatnak vagy nem. A Plezy pontosan megmondja, mit őrzött meg.",
"repairConfirm": "Javítás",
"repairSucceeded": "A tárhely javítva",
"repairNeedsRestart": "A tárhely javítva – újraindítás szükséges",
"restartRequiredBody": "Az adatait kijavítottuk, de a Plezy-nek frissen kell elindulnia, mielőtt használhatná. Zárja be a Plezy-t, és nyissa meg újra.",
"quitPlezy": "Kilépés a Plezy-ből",
"repairFailed": "A javítás nem sikerült",
"repairKeptSignIns": "A szerverei és profiljai továbbra is be vannak jelentkezve.",
"repairLostSignIns": "A mentett bejelentkezéseket védő kulcs nem volt visszanyerhető. Minden szerverre és profilba újra be kell jelentkeznie.",
"repairLostSessions": "Legalább egy nyomkövető- vagy Seerr-kapcsolat elveszett, és újra kell csatlakoztatni.",
"backupTitle": "A sérült fájlról másolat készült",
"backupWarning": "Bejelentkezési adatait tartalmazza. Ne töltse fel és ne ossza meg.",
"deleteBackup": "Másolat törlése",
"backupDeleted": "A másolat törölve.",
"previousFailureTitle": "A Plezy legutóbb nem tudott elindulni"
},
"licenses": {
"relatedPackages": "Kapcsolódó csomagok",
@@ -1043,7 +1080,10 @@
"characters": "Karakterek",
"addToWatchlist": "Hozzáadás a Néznivalókhoz",
"removeFromWatchlist": "Eltávolítás a Néznivalókból",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Nem sikerült a Néznivalók frissítése",
"watchlistNoMatch": "",
"notInLibrary": "Nincs a könyvtáradban",
"inTheseLibraries": "Ezekben a könyvtárakban",
"checkingLibrary": "Könyvtár ellenőrzése...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Filmek és sorozatok keresése a következőn: ${source}.",
"searchFailed": "A keresés nem sikerült. Ellenőrizd a kapcsolatot és próbáld újra.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"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",
"available": "Elérhető",
"partiallyAvailable": "Részben elérhető",
"availableIn4k": "4K elérhető",
"requested": "Kérve",
"pendingApproval": "Jóváhagyásra vár",
"processing": "Feldolgozás alatt",
"declined": "Elutasítva",
"requestFailed": "A kérés nem sikerült",
"requested4k": "4K kérve",
"seasonsAvailable": "${available}/${total} évad",
"nextEpisodeIn": "${episode}. ep. ${duration} múlva",
"nextAiringIn": "Következő ${duration} múlva",
"episodesShort": "${n} ep.",
"minutesPerEpisode": "${n} perc/ep.",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} listázva",
"viewersDay": "${n} nézte ma",
"viewersWeek": "${n} nézte ezen a héten",
"viewersMonth": "${n} nézte ebben a hónapban",
"viewersYear": "${n} nézte ebben az évben",
"viewersAllTime": "${n} néző",
"planning": "${n} tervezi megnézni",
"favorited": "${n} kedvenc",
"dropRate": "${percent} felhagyott vele",
"comments": {
"one": "",
"other": ""
"one": "${n} hozzászólás",
"other": "${n} hozzászólás"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} szavazat",
"watching": "${n} nézi",
"completed": "${n} befejezte",
"onHold": "${n} szünetelteti",
"dropped": "${n} felhagyott vele"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Tél",
"spring": "Tavasz",
"summer": "Nyár",
"fall": "Ősz",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV rövid",
"movie": "Film",
"special": "Különkiadás",
"ova": "OVA",
"ona": "ONA",
"music": "Zene",
"other": "Egyéb"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Eredeti",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Regény",
"visualNovel": "Visual novel",
"game": "Játék",
"webComic": "Webképregény",
"musicRelease": "Zene",
"otherMedia": "Egyéb"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Rendező",
"writer": "Író",
"producer": "Producer",
"creator": "Alkotó",
"composer": "Zeneszerző"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Előzmény",
"sequel": "Folytatás",
"sideStory": "Melléktörténet",
"spinOff": "Spin-off",
"alternativeVersion": "Alternatív verzió",
"summary": "Összefoglaló",
"parentStory": "Fő történet",
"adaptation": "Adaptáció",
"other": "Kapcsolódó"
},
"broadcast": "Adásban: ${day}, ${time} órakor",
"broadcastWithZone": "Adásban: ${day}, ${time} ${timezone}",
"detail": {
"originalTitle": "Eredeti cím",
"alsoKnownAs": "Más néven",
"studios": "Stúdiók",
"country": "Ország",
"language": "Nyelv",
"released": "Megjelent",
"physicalRelease": "Lemezes",
"ended": "Befejeződött",
"addedOn": "Hozzáadva: ${date}",
"yourRating": "Az Ön értékelése",
"budget": "Költségvetés",
"revenue": "Bevétel",
"contentAdvisory": "Korhatár",
"tags": "Címkék",
"revealSpoilerTags": "Spoiler címkék megjelenítése",
"links": "Hivatkozások",
"watchOn": "Megnézhető itt",
"watchTrailer": "Előzetes megtekintése",
"openOn": "Megnyitás itt: ${site}",
"crew": "Stáb",
"ratings": "Értékelések",
"schedule": "Műsorrend",
"recommendedByUsers": {
"one": "${n} felhasználó ajánlja",
"other": "${n} felhasználó ajánlja"
},
"recommendedBy": "Ajánlotta: ${who}",
"favoritedBy": "Kedvencek közt: ${who}",
"unairedEpisodes": "${n} még nem került adásba",
"recommendedByPercent": "A nézők ${percent}-a ajánlja",
"relatedTitles": "Kapcsolódó címek",
"background": "Háttér"
},
"totalResults": {
"one": "${n} találat",
"other": "${n} találat"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Csatorna nézése",
"favorites": "Kedvencek",
"reorderFavorites": "Kedvencek rendezése",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Nem sikerült a kedvencek betöltése. Ellenőrizd a kapcsolatot és próbáld újra.",
"favoritesUpdateFailed": "Nem sikerült frissíteni a kedvenceket. Ellenőrizd a kapcsolatot, majd próbáld újra.",
"joinSession": "Csatlakozás a folyamatban lévő adáshoz",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Szinkronizálási szabály szerkesztése",
"removeSyncRule": "Szinkronizálási szabály eltávolítása",
"removeSyncRuleConfirm": "Leállítod a(z) \"${title}\" szinkronizálását? A letöltött epizódok megmaradnak.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Leállítjuk a(z) \"${title}\" szinkronizálását?",
"deleteSyncRuleDownloads": "A kapcsolódó letöltések törlése is",
"deleteSyncRuleDownloadsDescription": "A másik szinkronszabály vagy profil által használt letöltések megmaradnak.",
"syncRuleCreated": "Szinkronizálási szabály létrehozva — ${count} nem látott epizód megtartása",
"syncRuleUpdated": "Szinkronizálási szabály frissítve",
"syncRuleRemoved": "Szinkronizálási szabály eltávolítva",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "A szinkronszabály és a kapcsolódó letöltések eltávolítva",
"syncRuleCleanupBusy": "A szinkronszabályok éppen frissülnek. Próbálja újra egy pillanat múlva.",
"syncRuleCleanupUnavailable": "A kapcsolódó letöltések nem azonosíthatók biztonságosan. Csatlakoztassa újra a szervert, és próbálja újra, vagy távolítsa el a szabályt a letöltések törlése nélkül.",
"syncedNewEpisodes": "${count} új epizód szinkronizálva a következőhöz: ${title}",
"activeSyncRules": "Szinkronizálási szabályok",
"noSyncRules": "Nincsenek szinkronizálási szabályok",
@@ -1614,9 +1644,15 @@
"audioSync": "Hang szinkronizálása",
"subtitleSync": "Felirat szinkronizálása",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Hangkimenet",
"performanceOverlay": "Teljesítményadatok",
"audioPassthrough": "Hangtovábbítás (passthrough)",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Térhatású",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Plezy aktiválása a következőn: ${service}",
"body": "Nyisd meg a(z) ${url} oldalt és add meg ezt a kódot:",
"instructions": "",
"openToActivate": "Nyisd meg a(z) ${service} oldalt az aktiváláshoz",
"copyCode": "Aktiválási kód másolása",
"waitingForAuthorization": "Várakozás az engedélyezésre…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Bejelentkezés ide: ${service}",
"body": "Olvasd be ezt a QR-kódot vagy nyisd meg az URL-t bármelyik eszközön.",
"openToSignIn": "Nyisd meg a(z) ${service} oldalt a bejelentkezéshez",
"openToSignIn": "Nyisd meg a(z) ${service} oldalt a bejelentkezéshez"
},
"pendingAuth": {
"copyUrl": "Bejelentkezési URL másolása",
"urlCopied": "URL másolva"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfin szerver hozzáadása",
"addMediaBrowserTitle": "${product} szerver hozzáadása",
"serverUrls": "Szerver URL-címei",
"serverUrlsHelper": "Több URL is megadható, vesszővel elválasztva.",
"findServer": "Szerver keresése",
"searchingLocalServers": "Helyi Jellyfin-szerverek keresése...",
"localServers": "Helyi Jellyfin-szerverek",
"searchingLocalMediaBrowserServers": "Helyi ${product} szerverek keresése...",
"localMediaBrowserServers": "Helyi ${product} szerverek",
"username": "Felhasználónév",
"password": "Jelszó",
"signIn": "Bejelentkezés",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Bejelentkezés Plexszel",
"pinExpired": "A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.",
"failedToRegisterAccount": "Nem sikerült a fiók regisztrálása: ${error}",
"enterJellyfinUrlError": "Add meg a Jellyfin szervered URL-jét",
"enterMediaBrowserUrlError": "Adja meg a(z) ${product} szerver URL-jét",
"addConnectionTitle": "Kapcsolat hozzáadása",
"addConnectionTitleScoped": "Hozzáadás a következőhöz: ${name}",
"signInWithPlexCard": "Bejelentkezés Plexszel",
"signInWithPlexCardSubtitle": "Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.",
"signInWithPlexCardSubtitleScoped": "Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.",
"connectToJellyfinCard": "Csatlakozás Jellyfinhez",
"connectToJellyfinCardSubtitle": "Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.",
"connectToJellyfinCardSubtitleScoped": "Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.",
"connectToMediaBrowserCard": "Csatlakozás a(z) ${product} szolgáltatáshoz",
"connectToMediaBrowserCardSubtitle": "Adja meg a szerver URL-jét, felhasználónevét és jelszavát.",
"connectToMediaBrowserCardSubtitleScoped": "Jelentkezzen be a(z) ${product} szerverére. A(z) ${name} szolgáltatáshoz kötődik.",
"borrowFromAnotherProfile": "Kapcsolat használata másik profilból",
"borrowFromAnotherProfileSubtitle": "Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges."
}
+338 -299
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "In attesa di autenticazione...\nAccedi dal browser.",
"useBrowser": "Usa il browser",
"or": "o",
"connectToJellyfin": "Connettiti a Jellyfin",
"connectToMediaBrowser": "Connettiti a ${product}",
"useQuickConnect": "Usa Quick Connect",
"quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.",
"quickConnectWaiting": "In attesa di approvazione…",
@@ -64,7 +64,20 @@
"loading": "Caricamento...",
"fullscreen": "Schermo intero",
"exitFullscreen": "Esci dalla modalità a schermo intero",
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire"
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
"ratingSource": {
"critic": "Critici",
"audience": "Pubblico",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Critici di Rotten Tomatoes",
"rottenTomatoesAudience": "Pubblico di Rotten Tomatoes"
}
},
"screens": {
"licenses": "Licenze",
@@ -107,6 +120,7 @@
"darkTheme": "Scuro",
"oledTheme": "OLED",
"libraryDensity": "Densità della libreria",
"displayScale": "Scala di visualizzazione",
"compact": "Compatta",
"comfortable": "Comoda",
"tvCornerSpotlightBackdrop": "Sfondo in evidenza nell'angolo",
@@ -156,6 +170,11 @@
"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",
"musicQualityTitle": "Qualità musicale",
"subtitleStyling": "Stile sottotitoli",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Abilita una registrazione dettagliata per la risoluzione dei problemi",
"viewLogs": "Visualizza i log",
"viewLogsDescription": "Visualizza i log dell'applicazione",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Svuota cache immagini",
"clearImageCacheDescription": "Svuota le immagini e le miniature memorizzate nella cache. Le immagini potrebbero caricarsi più lentamente finché non vengono scaricate di nuovo.",
"clearImageCacheSuccess": "Cache immagini svuotata correttamente",
"resetSettings": "Ripristina impostazioni",
"resetSettingsDescription": "Ripristina le impostazioni predefinite. Questa operazione non può essere annullata.",
"resetSettingsSuccess": "Impostazioni ripristinate correttamente",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normalizza il volume durante il downmix",
"audioDownmixNormalizeDescription": "Riduce il volume del mix per evitare il clipping. Disattiva questa opzione per mantenere il volume originale (le scene più rumorose potrebbero risultare distorte).",
"atmosDiagnostics": "Test uscita Atmos",
"atmosDiagnosticsDescription": "Diagnostica l'uscita Dolby Atmos riproducendo segnali di prova con il lettore di sistema",
"atmosTestHlsAtmos": "Stream Atmos di Apple",
"atmosTestHlsAtmosDescription": "Stream Dolby Atmos di riferimento. Il ricevitore dovrebbe mostrare Dolby Atmos.",
"atmosTestHlsControl": "Stream surround di Apple",
"atmosTestHlsControlDescription": "Stream di controllo senza Atmos. Il ricevitore dovrebbe mostrare surround senza Atmos.",
"atmosTestRawStream": "Stream EAC3 grezzo",
"atmosTestRawStreamDescription": "Trasmette il file di prova esattamente come la riproduzione Atmos del lettore. Richiede l'URL del file di prova.",
"atmosTestRawFile": "File EAC3 grezzo",
"atmosTestRawFileDescription": "Riproduce il file di prova con lunghezza nota. Richiede l'URL del file di prova.",
"atmosTestAsbarNative": "Renderer con buffer di campioni (nativo)",
"atmosTestAsbarNativeDescription": "Invia l'audio compresso intatto del file direttamente al renderer di sistema. Richiede l'URL del file di test.",
"atmosTestAsbarGenerated": "Renderer con buffer di campioni (ricostruito)",
"atmosTestAsbarGeneratedDescription": "Come sopra, ma con la descrizione audio costruita come nella riproduzione. Richiede l'URL del file di test.",
"atmosTestSessionMode": "Usa la modalità riproduzione film",
"atmosTestSessionModeDescription": "Disattivato usa la modalità documentata da Dolby. Attivato usa la modalità precedente.",
"atmosTestShowRoutePicker": "Scegli uscita AirPlay",
"atmosTestHideRoutePicker": "Nascondi selettore uscita AirPlay",
"atmosTestRoutePickerDescription": "Invia il test a un ricevitore AirPlay. Solo AirPlay riporta la modalità audio risolta.",
"atmosTestStop": "Interrompi test",
"atmosTestUrl": "URL del file di prova",
"atmosTestUrlDescription": "URL HTTP di un file .ec3 Dolby Atmos grezzo (ad es. estratto con ffmpeg)",
"atmosTestUrlMissing": "Imposta prima l'URL del file di prova",
"atmosTestStatus": "Stato",
"dvConversionMode": "Conversione Dolby Vision",
"dvConversionModeDescription": "Scegli come ExoPlayer gestisce i file Dolby Vision con profilo 7.",
"dvConversionAuto": "Auto",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Mostra le etichette della barra di navigazione",
"showNavBarLabelsDescription": "Mostra le etichette di testo sotto le icone della barra di navigazione",
"startupSection": "Sezione di avvio",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Apri sui canali preferiti",
"liveTvDefaultFavoritesDescription": "Mostra solo i canali preferiti quando apri la TV in diretta",
"display": "Schermo",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Info sul file",
"overview": "",
"overview": "Panoramica",
"video": "Video",
"audio": "Audio",
"subtitles": "Sottotitoli",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Immagini incorporate",
"dataStreams": "Flussi di dati",
"lyrics": "Testi",
"file": "File",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Allegati",
"delivery": "Distribuzione",
"versionCounter": "Versione ${index} di ${count}",
"fileCounter": "File ${index} di ${count}",
"noStreams": "Il server non ha segnalato flussi per questo file.",
"copyPath": "Copia percorso",
"pathCopied": "Percorso del file copiato",
"codec": "Codec",
"codecTag": "",
"codecTag": "Tag codec",
"resolution": "Risoluzione",
"codedResolution": "",
"codedResolution": "Risoluzione codificata",
"bitrate": "Bitrate",
"frameRate": "Frequenza fotogrammi",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotazione",
"comment": "Commento",
"audioDescription": "Descrizione audio",
"headerCompression": "Compressione header",
"sidecarFile": "File sidecar",
"transportTimestamp": "Timestamp di trasporto",
"displayOffset": "Offset di visualizzazione",
"previewFailureCode": "Codice errore anteprima",
"previewRetries": "Riprova anteprima",
"aspectRatio": "Proporzioni",
"pixelAspectRatio": "",
"pixelAspectRatio": "Aspect ratio pixel",
"profile": "Profilo",
"level": "",
"level": "Livello",
"bitDepth": "Profondità in bit",
"pixelFormat": "",
"pixelFormat": "Formato pixel",
"colorSpace": "Spazio colore",
"colorRange": "Gamma colori",
"colorPrimaries": "Colori primari",
"colorTransfer": "",
"colorTransfer": "Trasferimento colore",
"chromaSubsampling": "Sottocampionamento cromatico",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Posizione crominanza",
"scanType": "Tipo di scansione",
"interlaced": "Interlacciato",
"anamorphic": "Anamorfico",
"referenceFrames": "Frame di riferimento",
"dynamicRange": "Dynamic Range",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision Level",
"dolbyVisionVersion": "Dolby Vision Version",
"dolbyVisionLayers": "Dolby Vision Layers",
"baseLayerCompatibility": "Compatibilità layer di base",
"avcBitstream": "Bitstream AVC",
"nalLengthSize": "Dimensione lunghezza NAL",
"scalingMatrix": "Matrice di scaling personalizzata",
"streamIdentifier": "Identificatore flusso",
"streamIndex": "Indice flusso",
"streamId": "ID flusso",
"language": "Lingua",
"languageCode": "Codice lingua",
"streamTitle": "Titolo traccia",
"channels": "Canali",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Frequenza di campionamento",
"spatialAudio": "Audio spaziale",
"textBased": "Testuale",
"subtitleFormat": "Formato sidecar",
"provider": "Provider",
"matchScore": "Punteggio corrispondenza",
"externalDelivery": "Può essere servito separatamente",
"sidecarPath": "Percorso sidecar",
"sourceStream": "Copiato da",
"temporary": "Temporaneo",
"timeBase": "Base temporale",
"overallBitrate": "Bitrate complessivo",
"path": "Percorso",
"fileName": "",
"fileName": "Nome file",
"size": "Dimensione",
"totalSize": "",
"totalSize": "Dimensione totale",
"container": "Contenitore",
"duration": "Durata",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Miniature anteprima",
"previewIndex": "Indice anteprima",
"packetLength": "Lunghezza pacchetto",
"filePresent": "File presente",
"fileReadable": "Leggibile dal server",
"streamPath": "Percorso flusso",
"optimizedForStreaming": "Ottimizzato per lo streaming",
"has64bitOffsets": "Offset a 64 bit",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protocollo",
"mediaType": "Tipo di supporto",
"sourceKind": "Tipo di origine",
"optimizedVersion": "Versione ottimizzata",
"optimizationTarget": "Obiettivo ottimizzazione",
"deletedAt": "Eliminato",
"remoteSource": "Origine remota",
"infiniteStream": "Flusso infinito",
"directPlay": "Riproduzione diretta",
"directStream": "Streaming diretto",
"transcoding": "Transcodifica",
"etag": "ETag",
"versionId": "ID versione",
"fileId": "ID file",
"defaultAudioTrack": "Traccia audio predefinita",
"defaultSubtitleTrack": "Traccia sottotitoli predefinita",
"subtitlesOff": "Disattivati",
"flagDefault": "Predefinito",
"flagForced": "Forzato",
"flagSelected": "Selezionato",
"flagExternal": "Esterno",
"flagHearingImpaired": "Non udenti",
"flagDub": "Doppiato",
"flagOriginal": "Originale"
},
"mediaMenu": {
"markAsWatched": "Segna come visto",
@@ -505,9 +502,35 @@
"shufflePlay": "Riproduzione casuale",
"shuffleNotAvailableOffline": "Riproduzione casuale non disponibile offline",
"fileInfo": "Info sul file",
"deleteFromServer": "Elimina dal server",
"confirmDelete": "Eliminare questo media e i suoi file dal server?",
"deleteEpisodeFromServer": "Elimina episodio dal server",
"deleteSeasonFromServer": "Elimina stagione dal server",
"deleteShowFromServer": "Elimina serie dal server",
"deleteMovieFromServer": "Elimina film dal server",
"deleteEpisodeTitle": "Eliminare questo episodio?",
"deleteSeasonTitle": "Eliminare questa stagione?",
"deleteShowTitle": "Eliminare questa serie?",
"deleteMovieTitle": "Eliminare questo film?",
"deleteEpisodeConfirm": "Elimina episodio",
"deleteSeasonConfirm": "Elimina stagione",
"deleteShowConfirm": "Elimina serie",
"deleteMovieConfirm": "Elimina film",
"deleteAnyway": "Elimina comunque",
"confirmDeleteTarget": "Eliminare definitivamente ${title} dal tuo server?",
"deleteMultipleWarning": "Sono inclusi tutti gli episodi e i relativi file.",
"deleteEpisodeCountWarning": {
"one": "Questo elimina tutti i ${n} episodio in essa contenuto e il relativo file.",
"other": "Questo elimina tutti i ${n} episodi in essa contenuti e i relativi file."
},
"deleteMultiPartWarning": {
"one": "Questo elemento è archiviato in ${n} file, che verrà eliminato.",
"other": "Questo elemento è archiviato in ${n} file, e tutti verranno eliminati."
},
"deleteSharedFileHeading": {
"one": "${n} altro episodio è archiviato nello stesso file e verrà eliminato anche lui:",
"other": "${n} altri episodi sono archiviati nello stesso file e verranno eliminati anche loro:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy non ha potuto verificare quali file rimuoverà, quindi potrebbe eliminare più di quanto indicato sopra. Annulla e riprova, oppure elimina comunque.",
"deleteScopeUnverifiedNoFileInfo": "Il tuo server non ha fornito i dettagli del file per questo elemento, quindi Plezy non può verificare quali file rimuoverà. Potrebbe eliminare più di quanto indicato sopra.",
"mediaDeletedSuccessfully": "Elemento multimediale eliminato correttamente",
"mediaFailedToDelete": "Impossibile eliminare l'elemento multimediale",
"rate": "Valuta",
@@ -583,8 +606,9 @@
"playNext": "Riproduci il successivo",
"playButton": "Riproduci",
"pauseButton": "Pausa",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "In pausa",
"playbackResumed": "Riproduzione",
"loadingVideo": "Caricamento video",
"showPlaybackControls": "Mostra i controlli di riproduzione",
"hidePlaybackControls": "Nascondi i controlli di riproduzione",
"seekBackwardButton": "Riavvolgi di ${seconds} secondi",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Nessun capitolo disponibile",
"queue": "Coda",
"noQueueItems": "Nessun elemento in coda",
"noAudioDevicesAvailable": "Nessun dispositivo audio disponibile",
"searchSubtitles": "Cerca sottotitoli",
"language": "Lingua",
"noSubtitlesFound": "Nessun sottotitolo trovato",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Rimosso da Continua a guardare",
"errorLoading": "Errore: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Non è stato possibile cercare in alcuni server multimediali. Vengono mostrati i risultati disponibili.",
"streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o vai a un altro punto per riprovare.",
"liveStreamInterrupted": "La diretta si è interrotta. Premi Riproduci per riprovare.",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Passaggio al lettore compatibile...",
"serverLimitTitle": "Riproduzione non riuscita",
"serverLimitBody": "Errore del server (HTTP 500). È probabile che un limite di banda o transcodifica abbia impedito questa sessione. Chiedi al proprietario di modificare il limite.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "File non disponibile",
"mediaUnreadableBody": "Il server ha trovato questo elemento ma non ha potuto leggere il suo file (HTTP 404). Il file è stato probabilmente spostato o eliminato, oppure il suo storage è offline. Chiedi al proprietario del server di controllare il file e di riscansionare la libreria.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Log caricati",
"logsUploadFailed": "Impossibile caricare i log",
"logId": "ID log"
"logId": "ID log",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Testo",
@@ -718,6 +746,8 @@
"overrideStrip": "Rimuovi stile",
"positionTop": "In alto",
"positionBottom": "In basso",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Grassetto",
"italic": "Corsivo",
"renderResolution": "Risoluzione di rendering",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Nessuna connessione — aggiungine una per usare questo profilo.",
"noConnections": "Nessuna connessione",
"plexHomeAccount": "Account Plex Home",
"plexAccountChip": "Account Plex: ${account}",
"plexAccountUserChip": "${user} tramite ${account}",
"connectionDefault": "Predefinita",
"connectionAs": "come ${displayName}",
"makeDefault": "Imposta come predefinita",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Sessione scaduta per ${name}",
"sessionExpiredMany": "Sessione scaduta per ${count} server",
"signInAgain": "Accedi di nuovo",
"editJellyfinTitle": "Modifica connessione Jellyfin",
"editJellyfinIntro": "Aggiungi o rimuovi URL per ${serverName}. Plezy userà l'URL raggiungibile con la latenza più bassa."
"editMediaBrowserTitle": "Modifica connessione ${product}",
"editMediaBrowserIntro": "Aggiungi o rimuovi gli URL per ${serverName}. Plezy userà l'URL raggiungibile con la latenza più bassa."
},
"discover": {
"title": "Esplora",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Ricerca non riuscita: ${error}",
"searchUnavailable": "",
"searchUnavailable": "La ricerca non ha potuto raggiungere alcun server multimediale.",
"connectionTimeout": "Tempo scaduto per la connessione durante il caricamento di ${context}",
"connectionFailed": "Impossibile connettersi al server multimediale",
"unableToLoad": "Impossibile caricare ${context}. Riprova.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Carica log"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy non ha potuto avviarsi",
"failedBody": "Si è verificato un errore durante l'avvio. I dettagli seguenti indicano cosa non ha funzionato.",
"failedBodyRepairable": "Il file delle impostazioni salvate di Plezy è danneggiato e deve essere ricostruito prima che Plezy possa avviarsi. Riprovare non servirà: scegli Ripara storage.",
"phaseLabel": "Passaggio",
"showDetails": "Mostra dettagli",
"hideDetails": "Nascondi dettagli",
"copyDetails": "Copia dettagli",
"detailsCopied": "Dettagli copiati negli appunti",
"uploadDetails": "Carica dettagli",
"repairStorage": "Ripara storage",
"repairTitle": "Riparare i dati archiviati?",
"repairBodyCommon": "Il file delle impostazioni di Plezy è danneggiato e non può essere letto. La riparazione ripristina ogni impostazione al suo valore predefinito.",
"repairBodyOneCredential": "Un accesso salvato è danneggiato e non può essere letto. La riparazione rimuove solo quello; le altre impostazioni restano intatte.",
"repairBodySignInsKept": "I tuoi server e profili dovrebbero rimanere con accesso effettuato.",
"repairBodySignInsLost": "La chiave che protegge i tuoi accessi salvati non può essere recuperata da questo file, quindi dovrai effettuare di nuovo l'accesso a ogni server e profilo. Nulla sul tuo server multimediale è interessato.",
"repairBodySessionsUncertain": "I tracker (MAL, AniList, Simkl, Trakt) e Seerr sono archiviati separatamente e potrebbero sopravvivere o meno. Plezy ti dirà esattamente cosa ha conservato.",
"repairConfirm": "Ripara",
"repairSucceeded": "Storage riparato",
"repairNeedsRestart": "Storage riparato — riavvio richiesto",
"restartRequiredBody": "I tuoi dati sono stati riparati, ma Plezy deve ripartire da zero prima di poterli usare. Chiudi Plezy e riaprilo.",
"quitPlezy": "Esci da Plezy",
"repairFailed": "Riparazione non riuscita",
"repairKeptSignIns": "I tuoi server e profili hanno ancora l'accesso effettuato.",
"repairLostSignIns": "La chiave che protegge i tuoi accessi salvati non ha potuto essere recuperata. Dovrai effettuare di nuovo l'accesso a ogni server e profilo.",
"repairLostSessions": "Almeno una connessione a un tracker o a Seerr è andata persa e deve essere riconnessa.",
"backupTitle": "È stata conservata una copia del file danneggiato",
"backupWarning": "Contiene le tue credenziali di accesso. Non caricarla né condividerla.",
"deleteBackup": "Elimina copia",
"backupDeleted": "Copia eliminata.",
"previousFailureTitle": "Plezy non è riuscito ad avviarsi l'ultima volta"
},
"licenses": {
"relatedPackages": "Pacchetti correlati",
@@ -1007,7 +1044,7 @@
"navigation": {
"libraries": "Librerie",
"downloads": "Download",
"liveTv": "TV in diretta",
"liveTv": "Diretta",
"explore": "Esplora"
},
"explore": {
@@ -1043,7 +1080,10 @@
"characters": "Personaggi",
"addToWatchlist": "Aggiungi alla lista da guardare",
"removeFromWatchlist": "Rimuovi dalla lista da guardare",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Impossibile aggiornare la lista da guardare",
"watchlistNoMatch": "",
"notInLibrary": "Non è nella tua libreria",
"inTheseLibraries": "In queste librerie",
"checkingLibrary": "Ricerca nella tua libreria...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Cerca film e serie TV su ${source}.",
"searchFailed": "Ricerca fallita. Controlla la connessione e riprova.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"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",
"available": "Disponibile",
"partiallyAvailable": "Parzialmente disponibile",
"availableIn4k": "Disponibile in 4K",
"requested": "Richiesto",
"pendingApproval": "In attesa di approvazione",
"processing": "In elaborazione",
"declined": "Rifiutato",
"requestFailed": "Richiesta non riuscita",
"requested4k": "Richiesto in 4K",
"seasonsAvailable": "${available}/${total} stagioni",
"nextEpisodeIn": "Ep ${episode} tra ${duration}",
"nextAiringIn": "Prossimo tra ${duration}",
"episodesShort": "${n} ep",
"minutesPerEpisode": "${n} min/ep",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} in elenco",
"viewersDay": "${n} guardati oggi",
"viewersWeek": "${n} guardati questa settimana",
"viewersMonth": "${n} guardati questo mese",
"viewersYear": "${n} guardati quest'anno",
"viewersAllTime": "${n} spettatori",
"planning": "${n} hanno in programma di guardarlo",
"favorited": "${n} preferiti",
"dropRate": "${percent} lo ha abbandonato",
"comments": {
"one": "",
"other": ""
"one": "${n} commento",
"other": "${n} commenti"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} voti",
"watching": "${n} lo stanno guardando",
"completed": "${n} completati",
"onHold": "${n} in pausa",
"dropped": "${n} abbandonati"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Inverno",
"spring": "Primavera",
"summer": "Estate",
"fall": "Autunno",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV Short",
"movie": "Film",
"special": "Speciale",
"ova": "OVA",
"ona": "ONA",
"music": "Musica",
"other": "Altro"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Originale",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Romanzo",
"visualNovel": "Visual novel",
"game": "Videogioco",
"webComic": "Web comic",
"musicRelease": "Musica",
"otherMedia": "Altro"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Regista",
"writer": "Sceneggiatore",
"producer": "Produttore",
"creator": "Creatore",
"composer": "Compositore"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Storia secondaria",
"spinOff": "Spin-off",
"alternativeVersion": "Versione alternativa",
"summary": "Riepilogo",
"parentStory": "Storia originale",
"adaptation": "Adattamento",
"other": "Correlati"
},
"broadcast": "Va in onda ${day} alle ${time}",
"broadcastWithZone": "Va in onda ${day} alle ${time} ${timezone}",
"detail": {
"originalTitle": "Titolo originale",
"alsoKnownAs": "Conosciuto anche come",
"studios": "Studi",
"country": "Paese",
"language": "Lingua",
"released": "Uscito",
"physicalRelease": "Su disco",
"ended": "Concluso",
"addedOn": "Aggiunto ${date}",
"yourRating": "La tua valutazione",
"budget": "Budget",
"revenue": "Incassi",
"contentAdvisory": "Guida all'età",
"tags": "Tag",
"revealSpoilerTags": "Mostra tag spoiler",
"links": "Link",
"watchOn": "Guarda su",
"watchTrailer": "Guarda trailer",
"openOn": "Apri su ${site}",
"crew": "Cast tecnico",
"ratings": "Valutazioni",
"schedule": "Programmazione",
"recommendedByUsers": {
"one": "Consigliato da ${n} utente",
"other": "Consigliato da ${n} utenti"
},
"recommendedBy": "Consigliato da ${who}",
"favoritedBy": "Preferito da ${who}",
"unairedEpisodes": "${n} non ancora in onda",
"recommendedByPercent": "Consigliato dal ${percent} degli spettatori",
"relatedTitles": "Titoli correlati",
"background": "Contesto"
},
"totalResults": {
"one": "${n} risultato",
"other": "${n} risultati"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Guarda il canale",
"favorites": "Preferiti",
"reorderFavorites": "Riordina i preferiti",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Impossibile caricare i preferiti. Controlla la connessione e riprova.",
"favoritesUpdateFailed": "Impossibile aggiornare i preferiti. Controlla la connessione e riprova.",
"joinSession": "Partecipa alla sessione in corso",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Modifica regola di sincronizzazione",
"removeSyncRule": "Rimuovi regola di sincronizzazione",
"removeSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"? Gli episodi scaricati verranno mantenuti.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"?",
"deleteSyncRuleDownloads": "Elimina anche i download associati",
"deleteSyncRuleDownloadsDescription": "I download usati da un'altra regola di sincronizzazione o da un altro profilo verranno conservati.",
"syncRuleCreated": "Regola di sincronizzazione creata — ${count} episodi non visti mantenuti",
"syncRuleUpdated": "Regola di sincronizzazione aggiornata",
"syncRuleRemoved": "Regola di sincronizzazione rimossa",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Regola di sincronizzazione e download associati rimossi",
"syncRuleCleanupBusy": "Le regole di sincronizzazione sono in fase di aggiornamento. Riprova tra un momento.",
"syncRuleCleanupUnavailable": "Non è stato possibile identificare in sicurezza i download associati. Riconnetti il server e riprova, oppure rimuovi la regola senza eliminare i download.",
"syncedNewEpisodes": "${count} nuovi episodi sincronizzati per ${title}",
"activeSyncRules": "Regole di sincronizzazione",
"noSyncRules": "Nessuna regola di sincronizzazione",
@@ -1614,9 +1644,15 @@
"audioSync": "Sincronizzazione audio",
"subtitleSync": "Sincronizzazione sottotitoli",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni",
"audioPassthrough": "Passthrough audio",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Attiva Plezy su ${service}",
"body": "Visita ${url} e inserisci questo codice:",
"instructions": "",
"openToActivate": "Apri ${service} per attivare",
"copyCode": "Copia il codice di attivazione",
"waitingForAuthorization": "In attesa di autorizzazione…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Accedi a ${service}",
"body": "Scansiona questo codice QR o apri l'URL su qualsiasi dispositivo.",
"openToSignIn": "Apri ${service} per accedere",
"openToSignIn": "Apri ${service} per accedere"
},
"pendingAuth": {
"copyUrl": "Copia l'URL di accesso",
"urlCopied": "URL copiato"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Aggiungi server Jellyfin",
"addMediaBrowserTitle": "Aggiungi server ${product}",
"serverUrls": "URL del server",
"serverUrlsHelper": "Sono consentiti più URL, separati da virgole.",
"findServer": "Trova il server",
"searchingLocalServers": "Ricerca dei server Jellyfin locali...",
"localServers": "Server Jellyfin locali",
"searchingLocalMediaBrowserServers": "Ricerca di server ${product} locali...",
"localMediaBrowserServers": "Server ${product} locali",
"username": "Nome utente",
"password": "Password",
"signIn": "Accedi",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Accedi con Plex",
"pinExpired": "PIN scaduto prima dell'accesso. Riprova.",
"failedToRegisterAccount": "Registrazione account non riuscita: ${error}",
"enterJellyfinUrlError": "Inserisci l'URL del tuo server Jellyfin",
"enterMediaBrowserUrlError": "Inserisci l'URL del server ${product}",
"addConnectionTitle": "Aggiungi connessione",
"addConnectionTitleScoped": "Aggiungi a ${name}",
"signInWithPlexCard": "Accedi con Plex",
"signInWithPlexCardSubtitle": "Autorizza questo dispositivo. I server condivisi vengono aggiunti.",
"signInWithPlexCardSubtitleScoped": "Autorizza un account Plex. Gli utenti Home diventano profili.",
"connectToJellyfinCard": "Connettiti a Jellyfin",
"connectToJellyfinCardSubtitle": "Inserisci l'URL del server, il nome utente e la password.",
"connectToJellyfinCardSubtitleScoped": "Accedi a un server Jellyfin. Verrà associato a ${name}.",
"connectToMediaBrowserCard": "Connettiti a ${product}",
"connectToMediaBrowserCardSubtitle": "Inserisci l'URL del server, il nome utente e la password.",
"connectToMediaBrowserCardSubtitleScoped": "Accedi al tuo server ${product}. Associato a ${name}.",
"borrowFromAnotherProfile": "Prendi in prestito da un altro profilo",
"borrowFromAnotherProfileSubtitle": "Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN."
}
+331 -295
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "認証を待っています…\nブラウザでサインインしてください。",
"useBrowser": "ブラウザを使用",
"or": "または",
"connectToJellyfin": "Jellyfinに接続",
"connectToMediaBrowser": "${product}に接続",
"useQuickConnect": "Quick Connect を使う",
"quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。",
"quickConnectWaiting": "承認を待っています…",
@@ -64,7 +64,20 @@
"loading": "読み込み中…",
"fullscreen": "フルスクリーン",
"exitFullscreen": "フルスクリーンを終了",
"pressBackAgainToExit": "もう一度押すと終了します"
"pressBackAgainToExit": "もう一度押すと終了します",
"ratingSource": {
"critic": "批評家",
"audience": "観客",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes 批評家",
"rottenTomatoesAudience": "Rotten Tomatoes 観客"
}
},
"screens": {
"licenses": "ライセンス",
@@ -107,6 +120,7 @@
"darkTheme": "ダーク",
"oledTheme": "OLED",
"libraryDensity": "ライブラリの密度",
"displayScale": "表示スケール",
"compact": "コンパクト",
"comfortable": "ゆったり",
"tvCornerSpotlightBackdrop": "画面隅の注目コンテンツ背景",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "自動(推奨)",
"bufferSizeWarning": "${heap}MBのメモリが利用可能です。${size}MBのバッファは再生に影響する可能性があります。",
"playbackBuffer": "再生バッファー",
"playbackBufferAuto": "自動(推奨)",
"playbackBufferLarge": "大",
"playbackBufferExtraLarge": "特大",
"playbackBufferDescription": "不安定な接続に備えてより多くのバッファーを使用します。バッファーサイズによっても制限されます。",
"defaultQualityTitle": "デフォルト画質",
"musicQualityTitle": "音楽の音質",
"subtitleStyling": "字幕スタイル",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "トラブルシューティング用の詳細なログを有効化",
"viewLogs": "ログを表示",
"viewLogsDescription": "アプリケーションログを表示",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "画像キャッシュをクリア",
"clearImageCacheDescription": "キャッシュされたアートワークとサムネイルをクリアします。再度ダウンロードされるまで画像の読み込みが遅くなる場合があります。",
"clearImageCacheSuccess": "画像キャッシュを正常にクリアしました",
"resetSettings": "設定をリセット",
"resetSettingsDescription": "設定を既定に戻します。元に戻せません。",
"resetSettingsSuccess": "設定を正常にリセットしました",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "ダウンミックス時の音量正規化",
"audioDownmixNormalizeDescription": "クリッピングを防ぐためにミックス音量を下げます。オフにすると元の音量を維持します(大音量シーンで歪む場合があります)。",
"atmosDiagnostics": "Atmos出力テスト",
"atmosDiagnosticsDescription": "システムプレイヤーでテスト信号を再生してDolby Atmos出力を診断します",
"atmosTestHlsAtmos": "Apple Atmosストリーム",
"atmosTestHlsAtmosDescription": "動作確認済みのDolby Atmosストリーム。レシーバーにDolby Atmosと表示されるはずです。",
"atmosTestHlsControl": "Appleサラウンドストリーム",
"atmosTestHlsControlDescription": "Atmosなしの比較用ストリーム。レシーバーにAtmosなしのサラウンドが表示されるはずです。",
"atmosTestRawStream": "生EAC3ストリーム",
"atmosTestRawStreamDescription": "プレイヤー内のAtmos再生と同じ方式でテストファイルをストリーミングします。テストファイルのURLが必要です。",
"atmosTestRawFile": "生EAC3ファイル",
"atmosTestRawFileDescription": "長さが既知のテストファイルを再生します。テストファイルのURLが必要です。",
"atmosTestAsbarNative": "サンプルバッファレンダラー(ネイティブ)",
"atmosTestAsbarNativeDescription": "ファイルの圧縮音声をそのままシステムのレンダラーに渡します。テストファイルのURLが必要です。",
"atmosTestAsbarGenerated": "サンプルバッファレンダラー(再構築)",
"atmosTestAsbarGeneratedDescription": "同じですが、再生時と同じ方法で音声記述を再構築します。テストファイルのURLが必要です。",
"atmosTestSessionMode": "ムービー再生モードを使用",
"atmosTestSessionModeDescription": "オフはDolbyが文書化したモードを使用します。オンは以前のモードを使用します。",
"atmosTestShowRoutePicker": "AirPlay出力を選択",
"atmosTestHideRoutePicker": "AirPlay出力の選択を隠す",
"atmosTestRoutePickerDescription": "テストをAirPlayレシーバーに送信します。解決された音声モードを報告するのはAirPlayのみです。",
"atmosTestStop": "テストを停止",
"atmosTestUrl": "テストファイルのURL",
"atmosTestUrlDescription": "生の.ec3 Dolby AtmosファイルのHTTP URL(例: ffmpegで抽出)",
"atmosTestUrlMissing": "先にテストファイルのURLを設定してください",
"atmosTestStatus": "ステータス",
"dvConversionMode": "Dolby Vision 変換",
"dvConversionModeDescription": "ExoPlayer が Dolby Vision Profile 7 ファイルを処理する方法を選択します。",
"dvConversionAuto": "自動",
@@ -328,6 +323,8 @@
"showNavBarLabels": "ナビゲーションバーのラベルを表示",
"showNavBarLabelsDescription": "ナビゲーションバーのアイコンの下にテキストラベルを表示",
"startupSection": "起動時のセクション",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "お気に入りチャンネルをデフォルトに",
"liveTvDefaultFavoritesDescription": "ライブTVを開いたときにお気に入りチャンネルのみ表示",
"display": "ディスプレイ",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "ファイル情報",
"overview": "",
"overview": "概要",
"video": "映像",
"audio": "音声",
"subtitles": "字幕",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "埋め込み画像",
"dataStreams": "データストリーム",
"lyrics": "歌詞",
"file": "ファイル",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "添付ファイル",
"delivery": "配信",
"versionCounter": "バージョン ${index} / ${count}",
"fileCounter": "ファイル ${index} / ${count}",
"noStreams": "サーバーはこのファイルにストリームがないと報告しました。",
"copyPath": "パスをコピー",
"pathCopied": "ファイルパスをコピーしました",
"codec": "コーデック",
"codecTag": "",
"codecTag": "コーデックタグ",
"resolution": "解像度",
"codedResolution": "",
"codedResolution": "符号化解像度",
"bitrate": "ビットレート",
"frameRate": "フレームレート",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "回転",
"comment": "コメント",
"audioDescription": "音声解説",
"headerCompression": "ヘッダー圧縮",
"sidecarFile": "サイドカーファイル",
"transportTimestamp": "トランスポートタイムスタンプ",
"displayOffset": "表示オフセット",
"previewFailureCode": "プレビュー失敗コード",
"previewRetries": "プレビュー再試行回数",
"aspectRatio": "アスペクト比",
"pixelAspectRatio": "",
"pixelAspectRatio": "ピクセルアスペクト比",
"profile": "プロファイル",
"level": "",
"level": "レベル",
"bitDepth": "ビット深度",
"pixelFormat": "",
"pixelFormat": "ピクセルフォーマット",
"colorSpace": "色空間",
"colorRange": "色範囲",
"colorPrimaries": "色原色",
"colorTransfer": "",
"colorTransfer": "カラートランスファー",
"chromaSubsampling": "クロマサブサンプリング",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "クロマ位置",
"scanType": "スキャンタイプ",
"interlaced": "インターレース",
"anamorphic": "アナモルフィック",
"referenceFrames": "参照フレーム数",
"dynamicRange": "ダイナミックレンジ",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision レベル",
"dolbyVisionVersion": "Dolby Vision バージョン",
"dolbyVisionLayers": "Dolby Vision レイヤー",
"baseLayerCompatibility": "ベースレイヤー互換性",
"avcBitstream": "AVC ビットストリーム",
"nalLengthSize": "NAL 長さサイズ",
"scalingMatrix": "カスタムスケーリングマトリックス",
"streamIdentifier": "ストリーム識別子",
"streamIndex": "ストリームインデックス",
"streamId": "ストリーム ID",
"language": "言語",
"languageCode": "言語コード",
"streamTitle": "トラックタイトル",
"channels": "チャンネル",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "サンプルレート",
"spatialAudio": "空間オーディオ",
"textBased": "テキストベース",
"subtitleFormat": "サイドカーフォーマット",
"provider": "プロバイダー",
"matchScore": "一致スコア",
"externalDelivery": "個別に配信可能",
"sidecarPath": "サイドカーパス",
"sourceStream": "コピー元",
"temporary": "一時的",
"timeBase": "タイムベース",
"overallBitrate": "全体ビットレート",
"path": "パス",
"fileName": "",
"fileName": "ファイル名",
"size": "サイズ",
"totalSize": "",
"totalSize": "合計サイズ",
"container": "コンテナ",
"duration": "長さ",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "プレビューサムネイル",
"previewIndex": "プレビューインデックス",
"packetLength": "パケット長",
"filePresent": "ファイルあり",
"fileReadable": "サーバーから読み取り可能",
"streamPath": "ストリームパス",
"optimizedForStreaming": "ストリーミング最適化",
"has64bitOffsets": "64ビットオフセット",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "プロトコル",
"mediaType": "メディアタイプ",
"sourceKind": "ソース種別",
"optimizedVersion": "最適化バージョン",
"optimizationTarget": "最適化ターゲット",
"deletedAt": "削除済み",
"remoteSource": "リモートソース",
"infiniteStream": "無限ストリーム",
"directPlay": "ダイレクト再生",
"directStream": "ダイレクトストリーム",
"transcoding": "トランスコード",
"etag": "ETag",
"versionId": "バージョン ID",
"fileId": "ファイル ID",
"defaultAudioTrack": "デフォルト音声トラック",
"defaultSubtitleTrack": "デフォルト字幕トラック",
"subtitlesOff": "オフ",
"flagDefault": "デフォルト",
"flagForced": "強制",
"flagSelected": "選択済み",
"flagExternal": "外部",
"flagHearingImpaired": "聴覚障害者向け",
"flagDub": "吹替",
"flagOriginal": "オリジナル"
},
"mediaMenu": {
"markAsWatched": "視聴済みにする",
@@ -505,9 +502,32 @@
"shufflePlay": "シャッフル再生",
"shuffleNotAvailableOffline": "オフラインではシャッフルを利用できません",
"fileInfo": "ファイル情報",
"deleteFromServer": "サーバーから削除",
"confirmDelete": "このメディアとそのファイルをサーバーから削除しますか?",
"deleteEpisodeFromServer": "サーバーからエピソードを削除",
"deleteSeasonFromServer": "サーバーからシーズンを削除",
"deleteShowFromServer": "サーバーから番組を削除",
"deleteMovieFromServer": "サーバーから映画を削除",
"deleteEpisodeTitle": "このエピソードを削除しますか?",
"deleteSeasonTitle": "このシーズンを削除しますか?",
"deleteShowTitle": "この番組を削除しますか?",
"deleteMovieTitle": "この映画を削除しますか?",
"deleteEpisodeConfirm": "エピソードを削除",
"deleteSeasonConfirm": "シーズンを削除",
"deleteShowConfirm": "番組を削除",
"deleteMovieConfirm": "映画を削除",
"deleteAnyway": "それでも削除",
"confirmDeleteTarget": "サーバーから「${title}」を完全に削除しますか?",
"deleteMultipleWarning": "すべてのエピソードとそのファイルが含まれます。",
"deleteEpisodeCountWarning": {
"other": "中の全${n}エピソードとそのファイルが削除されます。"
},
"deleteMultiPartWarning": {
"other": "このアイテムは${n}個のファイルにまたがって保存されており、すべて削除されます。"
},
"deleteSharedFileHeading": {
"other": "同じファイルに保存されている他の${n}エピソードも削除されます:"
},
"deleteScopeUnverifiedProbeFailed": "Plezyは削除対象のファイルを確認できなかったため、上記のアイテム以上に削除される可能性があります。キャンセルして再試行するか、それでも削除してください。",
"deleteScopeUnverifiedNoFileInfo": "サーバーがこのアイテムのファイル情報を提供しなかったため、Plezyは削除対象のファイルを確認できません。上記のアイテム以上に削除される可能性があります。",
"mediaDeletedSuccessfully": "メディアアイテムを正常に削除しました",
"mediaFailedToDelete": "メディアアイテムの削除に失敗しました",
"rate": "評価",
@@ -583,8 +603,9 @@
"playNext": "次を再生",
"playButton": "再生",
"pauseButton": "一時停止",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "一時停止中",
"playbackResumed": "再生中",
"loadingVideo": "動画を読み込み中",
"showPlaybackControls": "再生コントロールを表示",
"hidePlaybackControls": "再生コントロールを非表示",
"seekBackwardButton": "${seconds}秒戻る",
@@ -635,6 +656,7 @@
"noChaptersAvailable": "チャプターがありません",
"queue": "キュー",
"noQueueItems": "キューにアイテムがありません",
"noAudioDevicesAvailable": "オーディオデバイスがありません",
"searchSubtitles": "字幕を検索",
"language": "言語",
"noSubtitlesFound": "字幕が見つかりません",
@@ -655,7 +677,7 @@
},
"removedFromContinueWatching": "視聴中から削除しました",
"errorLoading": "エラー: ${error}",
"searchPartialResults": "",
"searchPartialResults": "一部のメディアサーバーを検索できませんでした。利用可能な結果を表示しています。",
"streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。",
"liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。",
"fileInfoNotAvailable": "ファイル情報が利用できません",
@@ -694,11 +716,14 @@
"switchingToCompatiblePlayer": "互換性のあるプレーヤーに切り替え中…",
"serverLimitTitle": "再生に失敗しました",
"serverLimitBody": "サーバーエラー(HTTP 500)。帯域幅/トランスコード制限により拒否された可能性があります。所有者に調整を依頼してください。",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "ファイルを利用できません",
"mediaUnreadableBody": "サーバーはこのアイテムを見つけましたが、そのファイルを読み取れませんでした(HTTP 404)。ファイルが移動・削除されたか、ストレージがオフラインの可能性があります。サーバーの管理者にファイルの確認とライブラリの再スキャンを依頼してください。",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "ログをアップロードしました",
"logsUploadFailed": "ログのアップロードに失敗しました",
"logId": "ログID"
"logId": "ログID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "テキスト",
@@ -717,6 +742,8 @@
"overrideStrip": "スタイルを削除",
"positionTop": "上",
"positionBottom": "下",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "太字",
"italic": "斜体",
"renderResolution": "レンダリング解像度",
@@ -779,6 +806,8 @@
"noConnectionsHint": "接続がありません — このプロフィールを使うには接続を追加してください。",
"noConnections": "接続がありません",
"plexHomeAccount": "Plex Homeアカウント",
"plexAccountChip": "Plexアカウント: ${account}",
"plexAccountUserChip": "${account}経由の${user}",
"connectionDefault": "デフォルト",
"connectionAs": "${displayName}として",
"makeDefault": "デフォルトに設定",
@@ -812,8 +841,8 @@
"sessionExpiredOne": "${name} のセッションの有効期限が切れました",
"sessionExpiredMany": "${count} 台のサーバーのセッションの有効期限が切れました",
"signInAgain": "再度サインイン",
"editJellyfinTitle": "Jellyfin接続を編集",
"editJellyfinIntro": "${serverName}のURLを追加または削除します。Plezyは接続可能なURLのうち遅延が最も少ないものを使用します。"
"editMediaBrowserTitle": "${product}接続を編集",
"editMediaBrowserIntro": "${serverName}のURLを追加または削除します。Plezyは到達可能なURLのうち、レイテンシーが最も低いものを使用します。"
},
"discover": {
"title": "探す",
@@ -843,7 +872,7 @@
},
"errors": {
"searchFailed": "検索に失敗しました: ${error}",
"searchUnavailable": "",
"searchUnavailable": "検索中にメディアサーバーへ接続できませんでした。",
"connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました",
"connectionFailed": "メディアサーバーに接続できません",
"unableToLoad": "${context}を読み込めませんでした。もう一度お試しください。",
@@ -971,31 +1000,36 @@
"uploadLogs": "ログをアップロード"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezyを起動できませんでした",
"failedBody": "起動中に問題が発生しました。以下の詳細に失敗の原因が示されています。",
"failedBodyRepairable": "Plezyの保存済み設定ファイルが破損しているため、Plezyを起動する前に再構築する必要があります。再試行しても解決しません —「ストレージを修復」を選択してください。",
"phaseLabel": "ステップ",
"showDetails": "詳細を表示",
"hideDetails": "詳細を非表示",
"copyDetails": "詳細をコピー",
"detailsCopied": "詳細をクリップボードにコピーしました",
"uploadDetails": "詳細をアップロード",
"repairStorage": "ストレージを修復",
"repairTitle": "保存データを修復しますか?",
"repairBodyCommon": "Plezyの設定ファイルが破損しており読み取れません。修復するとすべての設定がデフォルトにリセットされます。",
"repairBodyOneCredential": "保存済みのサインインの1つが破損しており読み取れません。修復するとその1つだけが削除され、他の設定には影響しません。",
"repairBodySignInsKept": "サーバーとプロフィールはサインインしたままになります。",
"repairBodySignInsLost": "保存済みのサインインを保護するキーをこのファイルから復元できないため、すべてのサーバーとプロフィールに再度サインインする必要があります。メディアサーバー上のデータには影響ありません。",
"repairBodySessionsUncertain": "トラッカー(MAL、AniList、Simkl、Trakt)とSeerrは別途保存されており、保持されるかどうかは不明です。Plezyは保持した内容を正確にお知らせします。",
"repairConfirm": "修復",
"repairSucceeded": "ストレージを修復しました",
"repairNeedsRestart": "ストレージを修復しました — 再起動が必要です",
"restartRequiredBody": "データは修復されましたが、Plezyはそれを使用する前に再起動する必要があります。Plezyを閉じて再度開いてください。",
"quitPlezy": "Plezyを終了",
"repairFailed": "修復に失敗しました",
"repairKeptSignIns": "サーバーとプロフィールはサインインしたままです。",
"repairLostSignIns": "保存済みのサインインを保護するキーを復元できませんでした。すべてのサーバーとプロフィールに再度サインインする必要があります。",
"repairLostSessions": "少なくとも1つのトラッカーまたはSeerr接続が失われたため、再接続が必要です。",
"backupTitle": "破損したファイルのコピーを保持しました",
"backupWarning": "このコピーにはサインイン資格情報が含まれています。アップロードや共有はしないでください。",
"deleteBackup": "コピーを削除",
"backupDeleted": "コピーを削除しました。",
"previousFailureTitle": "前回、Plezyは起動に失敗しました"
},
"licenses": {
"relatedPackages": "関連パッケージ",
@@ -1041,7 +1075,10 @@
"characters": "キャラクター",
"addToWatchlist": "ウォッチリストに追加",
"removeFromWatchlist": "ウォッチリストから削除",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "ウォッチリストを更新できませんでした",
"watchlistNoMatch": "",
"notInLibrary": "ライブラリにありません",
"inTheseLibraries": "これらのライブラリにあります",
"checkingLibrary": "ライブラリを確認中…",
@@ -1052,144 +1089,131 @@
"searchPrompt": "${source}で映画やテレビ番組を検索します。",
"searchFailed": "検索に失敗しました。接続を確認してもう一度お試しください。",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "人気 #${n}",
"rankAiring": "放送中 #${n}",
"rankRated": "評価 #${n}",
"rankFavorited": "お気に入り #${n}",
"rankTrending": "急上昇 #${n}",
"rankSeasonal": "#${n}(${season})",
"watchingNow": "${n}人視聴中",
"available": "視聴可能",
"partiallyAvailable": "一部視聴可能",
"availableIn4k": "4K対応",
"requested": "リクエスト済み",
"pendingApproval": "承認待ち",
"processing": "処理中",
"declined": "却下",
"requestFailed": "リクエスト失敗",
"requested4k": "4Kリクエスト済み",
"seasonsAvailable": "シーズン ${available}/${total}",
"nextEpisodeIn": "エピソード${episode} あと${duration}",
"nextAiringIn": "次回 あと${duration}",
"episodesShort": "${n}話",
"minutesPerEpisode": "${n}分/話",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "登録者 ${n}人",
"viewersDay": "本日${n}人が視聴",
"viewersWeek": "今週${n}人が視聴",
"viewersMonth": "今月${n}人が視聴",
"viewersYear": "今年${n}人が視聴",
"viewersAllTime": "累計視聴者${n}人",
"planning": "${n}人が視聴予定",
"favorited": "お気に入り ${n}件",
"dropRate": "${percent}が視聴中止",
"comments": {
"other": ""
"other": "コメント ${n}件"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "投票 ${n}件",
"watching": "${n}人が視聴中",
"completed": "${n}人が視聴済み",
"onHold": "${n}人が保留中",
"dropped": "${n}人が視聴中止"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "冬",
"spring": "春",
"summer": "夏",
"fall": "秋",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV短編",
"movie": "映画",
"special": "スペシャル",
"ova": "OVA",
"ona": "ONA",
"music": "音楽",
"other": "その他"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "オリジナル",
"manga": "漫画",
"lightNovel": "ライトノベル",
"novel": "小説",
"visualNovel": "ビジュアルノベル",
"game": "ゲーム",
"webComic": "Web漫画",
"musicRelease": "音楽",
"otherMedia": "その他"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"other": ""
"director": "監督",
"writer": "脚本",
"producer": "プロデューサー",
"creator": "原作者",
"composer": "作曲家"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "前日譚",
"sequel": "続編",
"sideStory": "サイドストーリー",
"spinOff": "スピンオフ",
"alternativeVersion": "別バージョン",
"summary": "総集編",
"parentStory": "親作品",
"adaptation": "原作",
"other": "関連作品"
},
"broadcast": "${day} ${time}に放送",
"broadcastWithZone": "${day} ${time}(${timezone})に放送",
"detail": {
"originalTitle": "原題",
"alsoKnownAs": "別名",
"studios": "制作会社",
"country": "国",
"language": "言語",
"released": "公開日",
"physicalRelease": "ディスク発売",
"ended": "放送終了",
"addedOn": "${date}に追加",
"yourRating": "あなたの評価",
"budget": "制作費",
"revenue": "興行収入",
"contentAdvisory": "年齢区分",
"tags": "タグ",
"revealSpoilerTags": "ネタバレタグを表示",
"links": "リンク",
"watchOn": "視聴先",
"watchTrailer": "予告編を見る",
"openOn": "${site}で開く",
"crew": "スタッフ",
"ratings": "評価",
"schedule": "放送予定",
"recommendedByUsers": {
"other": "${n}人のユーザーがおすすめ"
},
"recommendedBy": "${who}がおすすめ",
"favoritedBy": "${who}がお気に入り登録",
"unairedEpisodes": "未放送 ${n}話",
"recommendedByPercent": "視聴者の${percent}がおすすめ",
"relatedTitles": "関連作品",
"background": "背景"
},
"totalResults": {
"other": "${n}件の結果"
}
},
"liveTv": {
@@ -1221,6 +1245,9 @@
"watchChannel": "チャンネルを視聴",
"favorites": "お気に入り",
"reorderFavorites": "お気に入りを並べ替え",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。",
"favoritesUpdateFailed": "お気に入りを更新できませんでした。接続を確認してもう一度お試しください。",
"joinSession": "進行中のセッションに参加",
@@ -1455,15 +1482,15 @@
"editSyncRule": "同期ルールを編集",
"removeSyncRule": "同期ルールを削除",
"removeSyncRuleConfirm": "「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "「${title}」の同期を停止しますか?",
"deleteSyncRuleDownloads": "関連するダウンロードも削除",
"deleteSyncRuleDownloadsDescription": "別の同期ルールやプロフィールで使用されているダウンロードは保持されます。",
"syncRuleCreated": "同期ルールを作成しました — 未視聴のエピソードを${count}件保持",
"syncRuleUpdated": "同期ルールを更新しました",
"syncRuleRemoved": "同期ルールを削除しました",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "同期ルールと関連するダウンロードを削除しました",
"syncRuleCleanupBusy": "同期ルールが更新中です。しばらくしてからもう一度お試しください。",
"syncRuleCleanupUnavailable": "関連するダウンロードを安全に特定できませんでした。サーバーに再接続して再試行するか、ダウンロードを削除せずにルールを削除してください。",
"syncedNewEpisodes": "${title}の新しいエピソードを${count}件同期しました",
"activeSyncRules": "同期ルール",
"noSyncRules": "同期ルールなし",
@@ -1608,9 +1635,15 @@
"audioSync": "音声同期",
"subtitleSync": "字幕同期",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音声出力",
"performanceOverlay": "パフォーマンスオーバーレイ",
"audioPassthrough": "オーディオパススルー",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "サラウンド",
@@ -1828,11 +1861,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "${service} で Plezy を有効化",
"body": "${url}にアクセスして、このコードを入力してください。",
"instructions": "",
"openToActivate": "${service} を開いて有効化",
"copyCode": "アクティベーションコードをコピー",
"waitingForAuthorization": "認証を待っています…",
@@ -1841,7 +1875,9 @@
"oauthProxy": {
"title": "${service} にサインイン",
"body": "このQRコードをスキャンするか、任意のデバイスでURLを開いてください。",
"openToSignIn": "${service} を開いてサインイン",
"openToSignIn": "${service} を開いてサインイン"
},
"pendingAuth": {
"copyUrl": "サインインURLをコピー",
"urlCopied": "URLをコピーしました"
},
@@ -1861,12 +1897,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfinサーバーを追加",
"addMediaBrowserTitle": "${product}サーバーを追加",
"serverUrls": "サーバーURL",
"serverUrlsHelper": "複数のURLをカンマ区切りで入力できます。",
"findServer": "サーバーを検索",
"searchingLocalServers": "ローカルのJellyfinサーバーを検索中…",
"localServers": "ローカルのJellyfinサーバー",
"searchingLocalMediaBrowserServers": "ローカルの${product}サーバーを検索中…",
"localMediaBrowserServers": "ローカルの${product}サーバー",
"username": "ユーザー名",
"password": "パスワード",
"signIn": "サインイン",
@@ -1878,15 +1914,15 @@
"addPlexTitle": "Plexでサインイン",
"pinExpired": "サインイン前にPINの有効期限が切れました。もう一度お試しください。",
"failedToRegisterAccount": "アカウントの登録に失敗しました: ${error}",
"enterJellyfinUrlError": "JellyfinサーバーのURLを入力してください",
"enterMediaBrowserUrlError": "${product}サーバーのURLを入力してください",
"addConnectionTitle": "接続を追加",
"addConnectionTitleScoped": "${name}に追加",
"signInWithPlexCard": "Plexでサインイン",
"signInWithPlexCardSubtitle": "このデバイスを承認します。共有サーバーが追加されます。",
"signInWithPlexCardSubtitleScoped": "Plexアカウントを承認します。Homeユーザーはプロフィールになります。",
"connectToJellyfinCard": "Jellyfinに接続",
"connectToJellyfinCardSubtitle": "サーバーURL、ユーザー名、パスワードを入力してください。",
"connectToJellyfinCardSubtitleScoped": "Jellyfinサーバーにサインインします。${name}にひも付けられます。",
"connectToMediaBrowserCard": "${product}に接続",
"connectToMediaBrowserCardSubtitle": "サーバーのURL、ユーザー名、パスワードを入力してください。",
"connectToMediaBrowserCardSubtitleScoped": "${product}サーバーにサインインします。「${name}」に紐付けられます。",
"borrowFromAnotherProfile": "別のプロフィールの接続を利用",
"borrowFromAnotherProfileSubtitle": "別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。"
}
+326 -287
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Растау күтілуде...\nБраузеріңізден кіріңіз.",
"useBrowser": "Браузерді пайдалану",
"or": "немесе",
"connectToJellyfin": "Jellyfin-ге қосылу",
"connectToMediaBrowser": "${product} қосылу",
"useQuickConnect": "Жылдам қосылуды пайдалану",
"quickConnectInstructions": "Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.",
"quickConnectWaiting": "Растау күтілуде…",
@@ -64,7 +64,20 @@
"loading": "Жүктелуде...",
"fullscreen": "Толық экран",
"exitFullscreen": "Толық экраннан шығу",
"pressBackAgainToExit": "Шығу үшін артқа түймесін қайтадан басыңыз"
"pressBackAgainToExit": "Шығу үшін артқа түймесін қайтадан басыңыз",
"ratingSource": {
"critic": "Сыншылар",
"audience": "Көрермендер",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes сыншылары",
"rottenTomatoesAudience": "Rotten Tomatoes көрермендері"
}
},
"screens": {
"licenses": "Лицензиялар",
@@ -107,6 +120,7 @@
"darkTheme": "Қараңғы",
"oledTheme": "OLED",
"libraryDensity": "Кітапхана тығыздығы",
"displayScale": "Дисплей масштабы",
"compact": "Тығыз",
"comfortable": "Ыңғайлы",
"tvCornerSpotlightBackdrop": "Бұрыштық жарық фоны",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size} МБ",
"bufferSizeAuto": "Автоматты (Ұсынылатын)",
"bufferSizeWarning": "${heap} МБ ЖҰД қолжетімді. ${size} МБ буфер ойнатуға әсер етуі мүмкін.",
"playbackBuffer": "Ойнату буфері",
"playbackBufferAuto": "Авто (ұсынылады)",
"playbackBufferLarge": "Үлкен",
"playbackBufferExtraLarge": "Өте үлкен",
"playbackBufferDescription": "Тұрақсыз қосылыстарға қарсы көбірек буферлейді. Буфер өлшемімен де шектеледі.",
"defaultQualityTitle": "Әдепкі сапа",
"musicQualityTitle": "Музыка сапасы",
"subtitleStyling": "Субтитр баптаулары",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "дБ",
"audioDownmixNormalize": "Түрлендіруде дыбысты нормалау",
"audioDownmixNormalizeDescription": "Дыбыс бұзылуын болдырмау үшін деңгейді төмендету.",
"atmosDiagnostics": "Atmos шығысын тексеру",
"atmosDiagnosticsDescription": "Dolby Atmos шығысын тексеру",
"atmosTestHlsAtmos": "Apple Atmos ағыны",
"atmosTestHlsAtmosDescription": "Дұрыс жұмыс істейтін Dolby Atmos ағыны.",
"atmosTestHlsControl": "Apple көлемді дыбыс ағыны",
"atmosTestHlsControlDescription": "Atmos емес бақылау ағыны.",
"atmosTestRawStream": "Өңделмеген EAC3 ағыны",
"atmosTestRawStreamDescription": "Тест файлын ішкі Atmos ретінде тарату.",
"atmosTestRawFile": "Өңделмеген EAC3 файлы",
"atmosTestRawFileDescription": "Тест файлын ойнату.",
"atmosTestAsbarNative": "Сэмпл-буфер рендерері (нативті)",
"atmosTestAsbarNativeDescription": "Файлдың өзгертілмеген сығылған дыбысын тікелей жүйелік рендерерге береді. Тест файлының URL-і қажет.",
"atmosTestAsbarGenerated": "Сэмпл-буфер рендерері (қайта құрылған)",
"atmosTestAsbarGeneratedDescription": "Сол сияқты, бірақ дыбыс сипаттамасы ойнату кезіндегідей қайта құрылады. Тест файлының URL-і қажет.",
"atmosTestSessionMode": "Фильмді ойнату сеансы режимін пайдалану",
"atmosTestSessionModeDescription": "Өшірулі — Dolby құжаттаған режим. Қосулы — бұрын пайдаланылған режим.",
"atmosTestShowRoutePicker": "AirPlay шығысын таңдау",
"atmosTestHideRoutePicker": "AirPlay шығысын таңдау терезесін жасыру",
"atmosTestRoutePickerDescription": "Тестті AirPlay қабылдағышына жібереді. Анықталған дыбыс режимін тек AirPlay хабарлайды.",
"atmosTestStop": "Тестті тоқтату",
"atmosTestUrl": "Тест файлының URL-і",
"atmosTestUrlDescription": "Өңделмеген .ec3 файлының HTTP URL-і",
"atmosTestUrlMissing": "Алдымен тест файлының URL-ін орнатыңыз",
"atmosTestStatus": "Мәртебесі",
"dvConversionMode": "Dolby Vision түрлендіруі",
"dvConversionModeDescription": "ExoPlayer-дің Dolby Vision Profile 7 файлдарын өңдеу әдісін таңдаңыз.",
"dvConversionAuto": "Автоматты",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Навигация панелінің мәтіндерін көрсету",
"showNavBarLabelsDescription": "Навигация белгішелерінің астында мәтінді көрсету",
"startupSection": "Бастапқы бөлім",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Тікелей TV-де таңдаулы арналарды әдепкі ету",
"liveTvDefaultFavoritesDescription": "Тікелей TV ашылғанда тек таңдаулы арналарды көрсету",
"display": "Дисплей",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Файл туралы ақпарат",
"overview": "",
"overview": "Шолу",
"video": "Видео",
"audio": "Аудио",
"subtitles": "Субтитрлер",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Кірістірілген суреттер",
"dataStreams": "Деректер ағындары",
"lyrics": "Ән мәтіні",
"file": "Файл",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Тіркемелер",
"delivery": "Жеткізу",
"versionCounter": "Нұсқа ${index} / ${count}",
"fileCounter": "Файл ${index} / ${count}",
"noStreams": "Сервер бұл файл үшін ағындар жоқ деп хабарлады.",
"copyPath": "Жолды көшіру",
"pathCopied": "Файл жолы көшірілді",
"codec": "Кодек",
"codecTag": "",
"codecTag": "Кодек тегі",
"resolution": "Ажыратымдылық",
"codedResolution": "",
"codedResolution": "Кодталған ажыратымдылық",
"bitrate": "Биттік жылдамдық (Bitrate)",
"frameRate": "Кадр жиілігі",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Бұрылыс",
"comment": "Пікір",
"audioDescription": "Аудио сипаттама",
"headerCompression": "Тақырып қысуы",
"sidecarFile": "Қосалқы файл",
"transportTimestamp": "Транспорт уақыт белгісі",
"displayOffset": "Көрсету ығысуы",
"previewFailureCode": "Алдын ала қарау қате коды",
"previewRetries": "Алдын ала қарау қайталаулары",
"aspectRatio": "Тараптар қатынасы",
"pixelAspectRatio": "",
"pixelAspectRatio": "Пиксель пропорциясы",
"profile": "Профиль",
"level": "",
"level": "Деңгей",
"bitDepth": "Бит тереңдігі",
"pixelFormat": "",
"pixelFormat": "Пиксель форматы",
"colorSpace": "Түс кеңістігі",
"colorRange": "Түс диапазоны",
"colorPrimaries": "Негізгі түстер",
"colorTransfer": "",
"colorTransfer": "Түс тасымалдауы",
"chromaSubsampling": "Түстік субдискретизация",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Chroma орналасуы",
"scanType": "Сканерлеу түрі",
"interlaced": "Жоларалық",
"anamorphic": "Анаморфты",
"referenceFrames": "Тірек кадрлар",
"dynamicRange": "Динамикалық диапазон",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision деңгейі",
"dolbyVisionVersion": "Dolby Vision нұсқасы",
"dolbyVisionLayers": "Dolby Vision қабаттары",
"baseLayerCompatibility": "Базалық қабат үйлесімділігі",
"avcBitstream": "AVC бит ағыны",
"nalLengthSize": "NAL ұзындық өлшемі",
"scalingMatrix": "Теңшелетін масштабтау матрицасы",
"streamIdentifier": "Ағын идентификаторы",
"streamIndex": "Ағын индексі",
"streamId": "Ағын ID",
"language": "Тіл",
"languageCode": "Тіл коды",
"streamTitle": "Трек атауы",
"channels": "Арналар",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Дискретизация жиілігі",
"spatialAudio": "Кеңістіктік аудио",
"textBased": "Мәтіндік",
"subtitleFormat": "Қосалқы формат",
"provider": "Провайдер",
"matchScore": "Сәйкестік ұпайы",
"externalDelivery": "Бөлек жеткізілуі мүмкін",
"sidecarPath": "Қосалқы файл жолы",
"sourceStream": "Көшірілген көзі",
"temporary": "Уақытша",
"timeBase": "Уақыт негізі",
"overallBitrate": "Жалпы биттік жылдамдық",
"path": "Жол",
"fileName": "",
"fileName": "Файл атауы",
"size": "Көлемі",
"totalSize": "",
"totalSize": "Жалпы өлшемі",
"container": "Контейнер",
"duration": "Уақыты",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Алдын ала қарау суреттері",
"previewIndex": "Алдын ала қарау индексі",
"packetLength": "Пакет ұзындығы",
"filePresent": "Файл бар",
"fileReadable": "Сервер оқи алады",
"streamPath": "Ағын жолы",
"optimizedForStreaming": "Ағынды беру үшін оңтайландырылған",
"has64bitOffsets": "64-биттік ығысулар",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Протокол",
"mediaType": "Медиа түрі",
"sourceKind": "Дереккөз түрі",
"optimizedVersion": "Оңтайландырылған нұсқа",
"optimizationTarget": "Оңтайландыру мақсаты",
"deletedAt": "Өшірілген",
"remoteSource": "Қашықтағы дереккөз",
"infiniteStream": "Шексіз ағын",
"directPlay": "Тікелей ойнату",
"directStream": "Тікелей ағын",
"transcoding": "Транскодтау",
"etag": "ETag",
"versionId": "Нұсқа ID",
"fileId": "Файл ID",
"defaultAudioTrack": "Әдепкі аудио трек",
"defaultSubtitleTrack": "Әдепкі субтитр трегі",
"subtitlesOff": "Өшірулі",
"flagDefault": "Әдепкі",
"flagForced": "Мәжбүрлі",
"flagSelected": "Таңдалған",
"flagExternal": "Сыртқы",
"flagHearingImpaired": "Есту қабілеті бұзылғандар үшін",
"flagDub": "Дубляж",
"flagOriginal": "Түпнұсқа"
},
"mediaMenu": {
"markAsWatched": "Көрілді деп белгілеу",
@@ -505,9 +502,35 @@
"shufflePlay": "Араластырып ойнату",
"shuffleNotAvailableOffline": "Араластырып ойнату офлайн режимде қолжетімсіз",
"fileInfo": "Файл туралы ақпарат",
"deleteFromServer": "Серверден өшіру",
"confirmDelete": "Осы медиа мен файлдар серверден өшірілсін бе?",
"deleteEpisodeFromServer": "Бөлімді серверден өшіру",
"deleteSeasonFromServer": "Маусымды серверден өшіру",
"deleteShowFromServer": "Сериалды серверден өшіру",
"deleteMovieFromServer": "Фильмді серверден өшіру",
"deleteEpisodeTitle": "Осы бөлім өшірілсін бе?",
"deleteSeasonTitle": "Осы маусым өшірілсін бе?",
"deleteShowTitle": "Осы сериал өшірілсін бе?",
"deleteMovieTitle": "Осы фильм өшірілсін бе?",
"deleteEpisodeConfirm": "Бөлімді өшіру",
"deleteSeasonConfirm": "Маусымды өшіру",
"deleteShowConfirm": "Сериалды өшіру",
"deleteMovieConfirm": "Фильмді өшіру",
"deleteAnyway": "Бәрібір өшіру",
"confirmDeleteTarget": "${title} серверіңізден толық өшірілсін бе?",
"deleteMultipleWarning": "Бұл барлық бөлімдер мен файлдарға әсер етеді.",
"deleteEpisodeCountWarning": {
"one": "Бұл ондағы ${n} бөлімді және оның файлын өшіреді.",
"other": "Бұл ондағы барлық ${n} бөлімді және олардың файлдарын өшіреді."
},
"deleteMultiPartWarning": {
"one": "Бұл элемент ${n} файл ретінде сақталған, ол өшіріледі.",
"other": "Бұл элемент ${n} файлда сақталған, олардың барлығы өшіріледі."
},
"deleteSharedFileHeading": {
"one": "Сол файлда тағы ${n} бөлім сақталған, ол да өшіріледі:",
"other": "Сол файлда тағы ${n} бөлім сақталған, олар да өшіріледі:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy қандай файлдар өшірілетінін тексере алмады, сондықтан ол жоғарыда аталған элементтен артық өшіруі мүмкін. Бас тартып, қайталап көріңіз немесе бәрібір өшіріңіз.",
"deleteScopeUnverifiedNoFileInfo": "Серверіңіз бұл элемент үшін файл мәліметтерін ұсынбады, сондықтан Plezy қандай файлдар өшірілетінін тексере алмайды. Ол жоғарыда аталған элементтен артық өшіруі мүмкін.",
"mediaDeletedSuccessfully": "Медиа элементі сәтті өшірілді",
"mediaFailedToDelete": "Медиа элементін өшіру мүмкін болмады",
"rate": "Бағалау",
@@ -583,8 +606,9 @@
"playNext": "Келесіні ойнату",
"playButton": "Ойнату",
"pauseButton": "Кідірту",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Кідіртілді",
"playbackResumed": "Ойнатылуда",
"loadingVideo": "Бейне жүктелуде",
"showPlaybackControls": "Басқару элементтерін көрсету",
"hidePlaybackControls": "Басқару элементтерін жасыру",
"seekBackwardButton": "${seconds} секунд артқа айналдыру",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Бөлімдер қолжетімсіз",
"queue": "Кезек",
"noQueueItems": "Кезекте элементтер жоқ",
"noAudioDevicesAvailable": "Қолжетімді аудио құрылғылар жоқ",
"searchSubtitles": "Субтитр іздеу",
"language": "Тіл",
"noSubtitlesFound": "Субтитр табылмады",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Үйлесімді ойнатқышқа ауысуда...",
"serverLimitTitle": "Ойнату қатесі",
"serverLimitBody": "Сервер қатесі (HTTP 500). Шектеу бұл сеансты қабылдамады.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Файл қолжетімсіз",
"mediaUnreadableBody": "Сервер бұл элементті тапты, бірақ оның файлын оқи алмады (HTTP 404). Файл жылжытылған, өшірілген немесе оның сақтау орны қолжетімсіз болуы мүмкін. Сервер иесінен файлды тексеріп, кітапхананы қайта сканерлеуді сұраңыз.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Журналдар жүктелді",
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
"logId": "Журнал ID-сі"
"logId": "Журнал ID-сі",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Мәтін",
@@ -718,6 +746,8 @@
"overrideStrip": "Форматтауды жою",
"positionTop": "Жоғары",
"positionBottom": "Төмен",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Қалың",
"italic": "Көлбеу",
"renderResolution": "Рендеринг ажыратымдылығы",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Қосылымдар жоқ — бұл профильді пайдалану үшін қосылым қосыңыз.",
"noConnections": "Қосылымдар жоқ",
"plexHomeAccount": "Plex Home тіркелгісі",
"plexAccountChip": "Plex тіркелгісі: ${account}",
"plexAccountUserChip": "${account} арқылы ${user}",
"connectionDefault": "Әдепкі",
"connectionAs": "${displayName} ретінде",
"makeDefault": "Әдепкі ету",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "${name} үшін сеанс мерзімі өтті",
"sessionExpiredMany": "${count} сервер үшін сеанс мерзімі өтті",
"signInAgain": "Қайтадан кіру",
"editJellyfinTitle": "Jellyfin қосылымын өңдеу",
"editJellyfinIntro": "${serverName} үшін URL мекенжайын қосыңыз немесе өшіріңіз."
"editMediaBrowserTitle": "${product} қосылымын өңдеу",
"editMediaBrowserIntro": "${serverName} үшін URL мекенжайларды қосыңыз немесе өшіріңіз. Plezy ең төмен кідірісі бар қолжетімді URL мекенжайды пайдаланады."
},
"discover": {
"title": "Шолу",
@@ -972,31 +1004,36 @@
"uploadLogs": "Журналдарды жүктеу"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy іске қосылмады",
"failedBody": "Іске қосу кезінде қате орын алды. Төмендегі мәліметтер ненің сәтсіз аяқталғанын көрсетеді.",
"failedBodyRepairable": "Plezy сақталған баптаулар файлы зақымдалған және Plezy іске қосылмас бұрын оны қайта құру қажет. Қайталау көмектеспейді — «Сақтауды қалпына келтіру» тармағын таңдаңыз.",
"phaseLabel": "Қадам",
"showDetails": "Мәліметтерді көрсету",
"hideDetails": "Мәліметтерді жасыру",
"copyDetails": "Мәліметтерді көшіру",
"detailsCopied": "Мәліметтер алмасу буферіне көшірілді",
"uploadDetails": "Мәліметтерді жүктеп жіберу",
"repairStorage": "Сақтауды қалпына келтіру",
"repairTitle": "Сақталған деректер қалпына келтірілсін бе?",
"repairBodyCommon": "Plezy баптаулар файлы зақымдалған және оқылмайды. Қалпына келтіру барлық баптауды әдепкі мәнге қайтарады.",
"repairBodyOneCredential": "Бір сақталған кіру деректері зақымдалған және оқылмайды. Қалпына келтіру тек соны өшіреді; қалған баптауларыңызға тиіспейді.",
"repairBodySignInsKept": "Серверлеріңіз бен профильдеріңіз кірген күйінде қалады.",
"repairBodySignInsLost": "Сақталған кіру деректерін қорғайтын кілт осы файлдан қалпына келтірілмейді, сондықтан әрбір сервер мен профильге қайта кіруге тура келеді. Медиа серверіңіздегі ештеңеге әсер етілмейді.",
"repairBodySessionsUncertain": "Tracker-лар (MAL, AniList, Simkl, Trakt) және Seerr бөлек сақталады және сақталуы да, сақталмауы да мүмкін. Plezy не сақталғанын нақты айтады.",
"repairConfirm": "Қалпына келтіру",
"repairSucceeded": "Сақтау қалпына келтірілді",
"repairNeedsRestart": "Сақтау қалпына келтірілді — қайта іске қосу қажет",
"restartRequiredBody": "Деректеріңіз қалпына келтірілді, бірақ Plezy оларды пайдаланар алдында жаңадан іске қосылуы керек. Plezy жабыңыз да, қайта ашыңыз.",
"quitPlezy": "Plezy бағдарламасынан шығу",
"repairFailed": "Қалпына келтіру сәтсіз аяқталды",
"repairKeptSignIns": "Серверлеріңіз бен профильдеріңіз әлі кірген күйде.",
"repairLostSignIns": "Сақталған кіру деректерін қорғайтын кілт қалпына келтірілмеді. Әрбір сервер мен профильге қайта кіруге тура келеді.",
"repairLostSessions": "Кем дегенде бір tracker немесе Seerr қосылымы жоғалды, оны қайта қосу қажет.",
"backupTitle": "Зақымдалған файлдың көшірмесі сақталды",
"backupWarning": "Онда сіздің кіру деректеріңіз бар. Оны жүктеп жібермеңіз немесе бөліспеңіз.",
"deleteBackup": "Көшірмені өшіру",
"backupDeleted": "Көшірме өшірілді.",
"previousFailureTitle": "Plezy соңғы рет іске қосыла алмады"
},
"licenses": {
"relatedPackages": "Байланысты пакеттер",
@@ -1043,7 +1080,10 @@
"characters": "Кейіпкерлер",
"addToWatchlist": "Көру тізіміне қосу",
"removeFromWatchlist": "Көру тізімінен өшіру",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Көру тізімін жаңарту мүмкін болмады",
"watchlistNoMatch": "",
"notInLibrary": "Кітапханаңызда жоқ",
"inTheseLibraries": "Осы кітапханаларда бар",
"checkingLibrary": "Кітапхана тексерілуде...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "${source} арқылы фильмдер мен сериалдарды іздеңіз.",
"searchFailed": "Іздеу қатесі. Қосылымды тексеріңіз.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} танымал",
"rankAiring": "#${n} эфирде",
"rankRated": "#${n} бағаланған",
"rankFavorited": "#${n} таңдаулы",
"rankTrending": "#${n} трендте",
"rankSeasonal": "#${n} — ${season}",
"watchingNow": "${n} қарауда",
"available": "Қолжетімді",
"partiallyAvailable": "Жартылай қолжетімді",
"availableIn4k": "4K қолжетімді",
"requested": "Сұралған",
"pendingApproval": "Растау күтілуде",
"processing": "Өңделуде",
"declined": "Қабылданбады",
"requestFailed": "Сұрау сәтсіз аяқталды",
"requested4k": "4K сұралған",
"seasonsAvailable": "${available}/${total} маусым",
"nextEpisodeIn": "${episode}-бөлім ${duration} ішінде",
"nextAiringIn": "Келесісі ${duration} ішінде",
"episodesShort": "${n} бөл.",
"minutesPerEpisode": "${n} мин/бөл.",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} тізімде",
"viewersDay": "Бүгін ${n} көрді",
"viewersWeek": "Осы аптада ${n} көрді",
"viewersMonth": "Осы айда ${n} көрді",
"viewersYear": "Осы жылы ${n} көрді",
"viewersAllTime": "${n} көрермен",
"planning": "${n} көруді жоспарлауда",
"favorited": "${n} таңдаулы",
"dropRate": "${percent} тастап кетті",
"comments": {
"one": "",
"other": ""
"one": "${n} пікір",
"other": "${n} пікір"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} дауыс",
"watching": "${n} қарап жатыр",
"completed": "${n} аяқтады",
"onHold": "${n} кейінге қалдырды",
"dropped": "${n} тастады"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Қыс",
"spring": "Көктем",
"summer": "Жаз",
"fall": "Күз",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "ТВ",
"tvShort": "Қысқа ТВ",
"movie": "Фильм",
"special": "Арнайы",
"ova": "OVA",
"ona": "ONA",
"music": "Музыка",
"other": "Басқа"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Түпнұсқа",
"manga": "Манга",
"lightNovel": "Ранобэ",
"novel": "Роман",
"visualNovel": "Визуалды роман",
"game": "Ойын",
"webComic": "Веб-комикс",
"musicRelease": "Музыка",
"otherMedia": "Басқа"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Режиссер",
"writer": "Сценарист",
"producer": "Продюсер",
"creator": "Жасаушы",
"composer": "Композитор"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Приквел",
"sequel": "Сиквел",
"sideStory": "Жанама оқиға",
"spinOff": "Спин-офф",
"alternativeVersion": "Балама нұсқа",
"summary": "Қысқаша мазмұны",
"parentStory": "Негізгі оқиға",
"adaptation": "Экранизация",
"other": "Қатысты"
},
"broadcast": "${day} күні ${time} көрсетіледі",
"broadcastWithZone": "${day} күні ${time} ${timezone} көрсетіледі",
"detail": {
"originalTitle": "Түпнұсқа атауы",
"alsoKnownAs": "Басқа атаулары",
"studios": "Студиялар",
"country": "Ел",
"language": "Тіл",
"released": "Шығарылған",
"physicalRelease": "Дискіде",
"ended": "Аяқталды",
"addedOn": "${date} қосылды",
"yourRating": "Сіздің бағаңыз",
"budget": "Бюджет",
"revenue": "Кассалық жиын",
"contentAdvisory": "Жас шектеуі",
"tags": "Тегтер",
"revealSpoilerTags": "Спойлер тегтерін көрсету",
"links": "Сілтемелер",
"watchOn": "Мына жерде қарау:",
"watchTrailer": "Трейлерді қарау",
"openOn": "${site} сайтында ашу",
"crew": "Ұжым",
"ratings": "Бағалаулар",
"schedule": "Кесте",
"recommendedByUsers": {
"one": "${n} пайдаланушы ұсынды",
"other": "${n} пайдаланушы ұсынды"
},
"recommendedBy": "${who} ұсынды",
"favoritedBy": "${who} таңдаулыға қосты",
"unairedEpisodes": "${n} әлі көрсетілмеген",
"recommendedByPercent": "Көрермендердің ${percent} ұсынады",
"relatedTitles": "Қатысты атаулар",
"background": "Фон"
},
"totalResults": {
"one": "${n} нәтиже",
"other": "${n} нәтиже"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Арнаны көру",
"favorites": "Таңдаулылар",
"reorderFavorites": "Таңдаулыларды қайта реттеу",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Таңдаулыларды жүктеу мүмкін болмады.",
"favoritesUpdateFailed": "Таңдаулыларды жаңарту мүмкін болмады.",
"joinSession": "Сеансқа қосылу",
@@ -1614,9 +1644,15 @@
"audioSync": "Аудио синхрондау",
"subtitleSync": "Субтитр синхрондау",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудио шығысы",
"performanceOverlay": "Өнімділік панелі",
"audioPassthrough": "Дыбысты тікелей өткізу",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Көлемді дыбыс",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Plezy-ді ${service} қызметінде белсендіру",
"body": "${url} мекенжайына өтіп, осы кодты енгізіңіз:",
"instructions": "",
"openToActivate": "Белсендіру үшін ${service} ашу",
"copyCode": "Белсендіру кодын көшіру",
"waitingForAuthorization": "Авторизация күтілуде…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "${service} қызметіне кіру",
"body": "Осы QR кодты сканерлеңіз немесе URL-ді ашыңыз.",
"openToSignIn": "Кіру үшін ${service} ашу",
"openToSignIn": "Кіру үшін ${service} ашу"
},
"pendingAuth": {
"copyUrl": "Кіру URL-ін көшіру",
"urlCopied": "URL көшірілді"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfin серверін қосу",
"addMediaBrowserTitle": "${product} серверін қосу",
"serverUrls": "Сервер URL-дері",
"serverUrlsHelper": "Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.",
"findServer": "Серверді табу",
"searchingLocalServers": "Жергілікті Jellyfin серверлері ізделуде...",
"localServers": "Жергілікті Jellyfin серверлері",
"searchingLocalMediaBrowserServers": "Жергілікті ${product} серверлері ізделуде...",
"localMediaBrowserServers": "Жергілікті ${product} серверлері",
"username": "Пайдаланушы аты",
"password": "Құпия сөз",
"signIn": "Кіру",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Plex арқылы кіру",
"pinExpired": "PIN код мерзімі өтті.",
"failedToRegisterAccount": "Тіркелгіні тіркеу қатесі: ${error}",
"enterJellyfinUrlError": "Jellyfin сервер URL-ін енгізіңіз",
"enterMediaBrowserUrlError": "${product} серверінің URL мекенжайын енгізіңіз",
"addConnectionTitle": "Қосылым қосу",
"addConnectionTitleScoped": "${name} профиліне қосу",
"signInWithPlexCard": "Plex арқылы кіру",
"signInWithPlexCardSubtitle": "Осы құрылғыны авторизациялау.",
"signInWithPlexCardSubtitleScoped": "Plex тіркелгісін авторизациялау.",
"connectToJellyfinCard": "Jellyfin-ге қосылу",
"connectToJellyfinCardSubtitle": "Сервер URL-ін, пайдаланушы атын енгізіңіз.",
"connectToJellyfinCardSubtitleScoped": "Jellyfin серверіне кіру. ${name} профиліне жалғануда.",
"connectToMediaBrowserCard": "${product} қосылу",
"connectToMediaBrowserCardSubtitle": "Сервер URL мекенжайын, пайдаланушы атын және құпия сөзді енгізіңіз.",
"connectToMediaBrowserCardSubtitleScoped": "${product} серверіне кіріңіз. ${name} тіркелгісіне байланыстырылады.",
"borrowFromAnotherProfile": "Басқа профильден алу",
"borrowFromAnotherProfileSubtitle": "Басқа профильдің қосылымын қайта пайдалану."
}
+331 -295
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "인증 대기 중...\n브라우저에서 로그인하세요.",
"useBrowser": "브라우저 사용",
"or": "또는",
"connectToJellyfin": "Jellyfin에 연결",
"connectToMediaBrowser": "${product}에 연결",
"useQuickConnect": "Quick Connect 사용",
"quickConnectInstructions": "Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.",
"quickConnectWaiting": "승인 대기 중…",
@@ -64,7 +64,20 @@
"loading": "로딩 중...",
"fullscreen": "전체 화면",
"exitFullscreen": "전체 화면 종료",
"pressBackAgainToExit": "한 번 더 누르면 종료됩니다"
"pressBackAgainToExit": "한 번 더 누르면 종료됩니다",
"ratingSource": {
"critic": "평론가",
"audience": "관객",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes 평론가",
"rottenTomatoesAudience": "Rotten Tomatoes 관객"
}
},
"screens": {
"licenses": "라이선스",
@@ -107,6 +120,7 @@
"darkTheme": "다크",
"oledTheme": "OLED",
"libraryDensity": "라이브러리 표시 밀도",
"displayScale": "표시 배율",
"compact": "조밀하게",
"comfortable": "여유롭게",
"tvCornerSpotlightBackdrop": "모서리 스포트라이트 배경",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "자동 (권장)",
"bufferSizeWarning": "${heap}MB 메모리를 사용할 수 있습니다. ${size}MB 버퍼는 재생에 영향을 줄 수 있습니다.",
"playbackBuffer": "재생 버퍼",
"playbackBufferAuto": "자동 (권장)",
"playbackBufferLarge": "대형",
"playbackBufferExtraLarge": "초대형",
"playbackBufferDescription": "불안정한 연결에 대비해 더 많은 버퍼를 사용합니다. 버퍼 크기에 의해서도 제한됩니다.",
"defaultQualityTitle": "기본 화질",
"musicQualityTitle": "음악 음질",
"subtitleStyling": "자막 스타일",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "문제 해결을 위해 상세 로깅 활성화",
"viewLogs": "로그 보기",
"viewLogsDescription": "애플리케이션 로그 확인",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "이미지 캐시 지우기",
"clearImageCacheDescription": "캐시된 아트워크와 썸네일을 지웁니다. 다시 다운로드할 때까지 이미지가 느리게 로드될 수 있습니다.",
"clearImageCacheSuccess": "이미지 캐시를 성공적으로 지웠습니다",
"resetSettings": "설정 재설정",
"resetSettingsDescription": "기본 설정으로 복원합니다. 되돌릴 수 없습니다.",
"resetSettingsSuccess": "설정 재설정 성공",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "다운믹스 시 음량 정규화",
"audioDownmixNormalizeDescription": "클리핑을 방지하기 위해 믹스 음량을 낮춥니다. 원래 음량을 유지하려면 끄세요(큰 소리 장면에서 왜곡될 수 있음).",
"atmosDiagnostics": "Atmos 출력 테스트",
"atmosDiagnosticsDescription": "시스템 플레이어로 테스트 신호를 재생하여 Dolby Atmos 출력을 진단합니다",
"atmosTestHlsAtmos": "Apple Atmos 스트림",
"atmosTestHlsAtmosDescription": "검증된 Dolby Atmos 스트림입니다. 리시버에 Dolby Atmos가 표시되어야 합니다.",
"atmosTestHlsControl": "Apple 서라운드 스트림",
"atmosTestHlsControlDescription": "Atmos 없는 대조용 스트림입니다. 리시버에 Atmos 없는 서라운드가 표시되어야 합니다.",
"atmosTestRawStream": "원시 EAC3 스트림",
"atmosTestRawStreamDescription": "플레이어 내 Atmos 재생과 동일한 방식으로 테스트 파일을 스트리밍합니다. 테스트 파일 URL이 필요합니다.",
"atmosTestRawFile": "원시 EAC3 파일",
"atmosTestRawFileDescription": "길이가 알려진 테스트 파일을 재생합니다. 테스트 파일 URL이 필요합니다.",
"atmosTestAsbarNative": "샘플 버퍼 렌더러(네이티브)",
"atmosTestAsbarNativeDescription": "파일의 압축 오디오를 그대로 시스템 렌더러에 전달합니다. 테스트 파일 URL이 필요합니다.",
"atmosTestAsbarGenerated": "샘플 버퍼 렌더러(재구성)",
"atmosTestAsbarGeneratedDescription": "동일하지만 오디오 설명을 재생과 같은 방식으로 재구성합니다. 테스트 파일 URL이 필요합니다.",
"atmosTestSessionMode": "동영상 재생 세션 모드 사용",
"atmosTestSessionModeDescription": "끄면 Dolby가 문서화한 모드를 사용합니다. 켜면 이전 모드를 사용합니다.",
"atmosTestShowRoutePicker": "AirPlay 출력 선택",
"atmosTestHideRoutePicker": "AirPlay 출력 선택기 숨기기",
"atmosTestRoutePickerDescription": "테스트를 AirPlay 수신기로 보냅니다. 확인된 오디오 모드는 AirPlay에서만 보고됩니다.",
"atmosTestStop": "테스트 중지",
"atmosTestUrl": "테스트 파일 URL",
"atmosTestUrlDescription": "원시 .ec3 Dolby Atmos 파일의 HTTP URL(예: ffmpeg로 추출)",
"atmosTestUrlMissing": "먼저 테스트 파일 URL을 설정하세요",
"atmosTestStatus": "상태",
"dvConversionMode": "Dolby Vision 변환",
"dvConversionModeDescription": "ExoPlayer가 Dolby Vision Profile 7 파일을 처리하는 방식을 선택합니다.",
"dvConversionAuto": "자동",
@@ -328,6 +323,8 @@
"showNavBarLabels": "내비게이션 바 라벨 표시",
"showNavBarLabelsDescription": "내비게이션 바 아이콘 아래에 텍스트 라벨을 표시합니다",
"startupSection": "시작 섹션",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "즐겨찾기 채널 기본 설정",
"liveTvDefaultFavoritesDescription": "라이브 TV를 열 때 즐겨찾기 채널만 표시",
"display": "디스플레이",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "파일 정보",
"overview": "",
"overview": "개요",
"video": "비디오",
"audio": "오디오",
"subtitles": "자막",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "내장 이미지",
"dataStreams": "데이터 스트림",
"lyrics": "가사",
"file": "파일",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "첨부 파일",
"delivery": "재생 방식",
"versionCounter": "버전 ${index} / ${count}",
"fileCounter": "파일 ${index} / ${count}",
"noStreams": "서버에서 이 파일에 대한 스트림 정보를 보고하지 않았습니다.",
"copyPath": "경로 복사",
"pathCopied": "파일 경로가 복사되었습니다",
"codec": "코덱",
"codecTag": "",
"codecTag": "코덱 태그",
"resolution": "해상도",
"codedResolution": "",
"codedResolution": "코딩 해상도",
"bitrate": "비트레이트",
"frameRate": "프레임 속도",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "회전",
"comment": "주석",
"audioDescription": "오디오 설명",
"headerCompression": "헤더 압축",
"sidecarFile": "사이드카 파일",
"transportTimestamp": "전송 타임스탬프",
"displayOffset": "표시 오프셋",
"previewFailureCode": "미리보기 오류 코드",
"previewRetries": "미리보기 재시도 횟수",
"aspectRatio": "종횡비",
"pixelAspectRatio": "",
"pixelAspectRatio": "픽셀 종횡비",
"profile": "프로파일",
"level": "",
"level": "레벨",
"bitDepth": "비트 심도",
"pixelFormat": "",
"pixelFormat": "픽셀 포맷",
"colorSpace": "색 공간",
"colorRange": "색 범위",
"colorPrimaries": "색 원색",
"colorTransfer": "",
"colorTransfer": "색상 전송",
"chromaSubsampling": "색차 서브샘플링",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "크로마 위치",
"scanType": "주사 방식",
"interlaced": "비월 주사",
"anamorphic": "아나모픽",
"referenceFrames": "참조 프레임",
"dynamicRange": "동적 범위",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision 레벨",
"dolbyVisionVersion": "Dolby Vision 버전",
"dolbyVisionLayers": "Dolby Vision 레이어",
"baseLayerCompatibility": "베이스 레이어 호환성",
"avcBitstream": "AVC 비트스트림",
"nalLengthSize": "NAL 길이 크기",
"scalingMatrix": "사용자 지정 스케일링 매트릭스",
"streamIdentifier": "스트림 식별자",
"streamIndex": "스트림 인덱스",
"streamId": "스트림 ID",
"language": "언어",
"languageCode": "언어 코드",
"streamTitle": "트랙 제목",
"channels": "채널",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "샘플 레이트",
"spatialAudio": "공간 오디오",
"textBased": "텍스트 기반",
"subtitleFormat": "사이드카 포맷",
"provider": "제공자",
"matchScore": "일치 점수",
"externalDelivery": "별도 제공 가능",
"sidecarPath": "사이드카 경로",
"sourceStream": "복사 출처",
"temporary": "임시",
"timeBase": "시간 기준",
"overallBitrate": "전체 비트레이트",
"path": "경로",
"fileName": "",
"fileName": "파일 이름",
"size": "크기",
"totalSize": "",
"totalSize": "총 크기",
"container": "컨테이너",
"duration": "재생 시간",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "미리보기 썸네일",
"previewIndex": "미리보기 인덱스",
"packetLength": "패킷 길이",
"filePresent": "파일 있음",
"fileReadable": "서버에서 읽기 가능",
"streamPath": "스트림 경로",
"optimizedForStreaming": "스트리밍 최적화",
"has64bitOffsets": "64비트 오프셋",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "프로토콜",
"mediaType": "미디어 유형",
"sourceKind": "소스 종류",
"optimizedVersion": "최적화 버전",
"optimizationTarget": "최적화 대상",
"deletedAt": "삭제됨",
"remoteSource": "원격 소스",
"infiniteStream": "무한 스트림",
"directPlay": "Direct Play",
"directStream": "Direct Stream",
"transcoding": "트랜스코딩",
"etag": "ETag",
"versionId": "버전 ID",
"fileId": "파일 ID",
"defaultAudioTrack": "기본 오디오 트랙",
"defaultSubtitleTrack": "기본 자막 트랙",
"subtitlesOff": "꺼짐",
"flagDefault": "기본",
"flagForced": "강제",
"flagSelected": "선택됨",
"flagExternal": "외부",
"flagHearingImpaired": "청각 장애인용",
"flagDub": "더빙",
"flagOriginal": "원본"
},
"mediaMenu": {
"markAsWatched": "시청 완료로 표시",
@@ -505,9 +502,32 @@
"shufflePlay": "무작위 재생",
"shuffleNotAvailableOffline": "오프라인에서는 무작위 재생을 사용할 수 없습니다",
"fileInfo": "파일 정보",
"deleteFromServer": "서버에서 삭제",
"confirmDelete": "이 미디어와 해당 파일을 서버에서 삭제할까요?",
"deleteEpisodeFromServer": "서버에서 에피소드 삭제",
"deleteSeasonFromServer": "서버에서 시즌 삭제",
"deleteShowFromServer": "서버에서 프로그램 삭제",
"deleteMovieFromServer": "서버에서 영화 삭제",
"deleteEpisodeTitle": "이 에피소드를 삭제할까요?",
"deleteSeasonTitle": "이 시즌을 삭제할까요?",
"deleteShowTitle": "이 프로그램을 삭제할까요?",
"deleteMovieTitle": "이 영화를 삭제할까요?",
"deleteEpisodeConfirm": "에피소드 삭제",
"deleteSeasonConfirm": "시즌 삭제",
"deleteShowConfirm": "프로그램 삭제",
"deleteMovieConfirm": "영화 삭제",
"deleteAnyway": "그래도 삭제",
"confirmDeleteTarget": "서버에서 ${title}을(를) 영구 삭제할까요?",
"deleteMultipleWarning": "모든 에피소드와 파일이 포함됩니다.",
"deleteEpisodeCountWarning": {
"other": "포함된 에피소드 ${n}개와 해당 파일이 모두 삭제됩니다."
},
"deleteMultiPartWarning": {
"other": "이 항목은 파일 ${n}개에 걸쳐 저장되어 있으며, 모든 파일이 삭제됩니다."
},
"deleteSharedFileHeading": {
"other": "같은 파일에 저장된 다른 에피소드 ${n}개도 함께 삭제됩니다:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy가 어떤 파일이 삭제될지 확인하지 못해 위 항목보다 더 많은 파일이 삭제될 수 있습니다. 취소하고 다시 시도하거나 그래도 삭제하세요.",
"deleteScopeUnverifiedNoFileInfo": "서버가 이 항목의 파일 정보를 제공하지 않아 Plezy가 어떤 파일이 삭제될지 확인할 수 없습니다. 위 항목보다 더 많은 파일이 삭제될 수 있습니다.",
"mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다",
"mediaFailedToDelete": "미디어 항목 삭제 실패",
"rate": "평가",
@@ -583,8 +603,9 @@
"playNext": "다음 재생",
"playButton": "재생",
"pauseButton": "일시정지",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "일시정지됨",
"playbackResumed": "재생 중",
"loadingVideo": "동영상 로딩 중...",
"showPlaybackControls": "재생 컨트롤 표시",
"hidePlaybackControls": "재생 컨트롤 숨기기",
"seekBackwardButton": "${seconds}초 뒤로 이동",
@@ -635,6 +656,7 @@
"noChaptersAvailable": "사용 가능한 챕터가 없습니다",
"queue": "재생 대기열",
"noQueueItems": "대기열에 항목이 없습니다",
"noAudioDevicesAvailable": "사용 가능한 오디오 기기가 없습니다",
"searchSubtitles": "자막 검색",
"language": "언어",
"noSubtitlesFound": "자막을 찾을 수 없습니다",
@@ -655,7 +677,7 @@
},
"removedFromContinueWatching": "계속 시청 목록에서 제거됨",
"errorLoading": "오류: ${error}",
"searchPartialResults": "",
"searchPartialResults": "일부 미디어 서버를 검색하지 못했습니다. 사용 가능한 결과만 표시합니다.",
"streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.",
"liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.",
"fileInfoNotAvailable": "파일 정보가 없습니다",
@@ -694,11 +716,14 @@
"switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중...",
"serverLimitTitle": "재생 실패",
"serverLimitBody": "서버 오류(HTTP 500). 대역폭/트랜스코딩 제한으로 세션이 거부된 것 같습니다. 소유자에게 조정을 요청하세요.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "파일을 사용할 수 없음",
"mediaUnreadableBody": "서버에서 이 항목을 찾았지만 파일을 읽을 수 없습니다(HTTP 404). 파일이 이동되거나 삭제되었거나 저장소가 오프라인 상태일 수 있습니다. 서버 관리자에게 파일 확인과 라이브러리 재스캔을 요청하세요.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "로그 업로드 완료",
"logsUploadFailed": "로그 업로드 실패",
"logId": "로그 ID"
"logId": "로그 ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "텍스트",
@@ -717,6 +742,8 @@
"overrideStrip": "스타일 제거",
"positionTop": "위",
"positionBottom": "아래",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "굵게",
"italic": "기울임꼴",
"renderResolution": "렌더링 해상도",
@@ -779,6 +806,8 @@
"noConnectionsHint": "연결이 없습니다 — 이 프로필을 사용하려면 하나 추가하세요.",
"noConnections": "연결 없음",
"plexHomeAccount": "Plex Home 계정",
"plexAccountChip": "Plex 계정: ${account}",
"plexAccountUserChip": "${account} 계정의 ${user}",
"connectionDefault": "기본값",
"connectionAs": "${displayName}(으)로",
"makeDefault": "기본값으로 설정",
@@ -812,8 +841,8 @@
"sessionExpiredOne": "${name}의 세션이 만료되었습니다",
"sessionExpiredMany": "${count}개 서버의 세션이 만료되었습니다",
"signInAgain": "다시 로그인",
"editJellyfinTitle": "Jellyfin 연결 편집",
"editJellyfinIntro": "${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다."
"editMediaBrowserTitle": "${product} 연결 편집",
"editMediaBrowserIntro": "${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다."
},
"discover": {
"title": "둘러보기",
@@ -843,7 +872,7 @@
},
"errors": {
"searchFailed": "검색 실패: ${error}",
"searchUnavailable": "",
"searchUnavailable": "검색이 어떤 미디어 서버에도 연결할 수 없습니다.",
"connectionTimeout": "${context} 로드 중 연결 시간 초과",
"connectionFailed": "미디어 서버에 연결할 수 없습니다",
"unableToLoad": "${context}을(를) 불러올 수 없습니다. 다시 시도하세요.",
@@ -971,31 +1000,36 @@
"uploadLogs": "로그 업로드"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy를 시작하지 못했습니다",
"failedBody": "시작 중 문제가 발생했습니다. 아래 세부 정보에서 실패 원인을 확인할 수 있습니다.",
"failedBodyRepairable": "Plezy의 저장된 설정 파일이 손상되어 Plezy를 시작하려면 먼저 파일을 다시 만들어야 합니다. 재시도해도 해결되지 않습니다 — 저장소 복구를 선택하세요.",
"phaseLabel": "단계",
"showDetails": "세부 정보 표시",
"hideDetails": "세부 정보 숨기기",
"copyDetails": "세부 정보 복사",
"detailsCopied": "세부 정보가 클립보드에 복사되었습니다",
"uploadDetails": "세부 정보 업로드",
"repairStorage": "저장소 복구",
"repairTitle": "저장된 데이터를 복구할까요?",
"repairBodyCommon": "Plezy의 설정 파일이 손상되어 읽을 수 없습니다. 복구하면 모든 설정이 기본값으로 재설정됩니다.",
"repairBodyOneCredential": "저장된 로그인 정보 하나가 손상되어 읽을 수 없습니다. 복구하면 해당 로그인 정보만 제거되며 다른 설정은 그대로 유지됩니다.",
"repairBodySignInsKept": "서버와 프로필은 로그인 상태가 유지됩니다.",
"repairBodySignInsLost": "저장된 로그인 정보를 보호하는 키를 이 파일에서 복구할 수 없어 모든 서버와 프로필에 다시 로그인해야 합니다. 미디어 서버의 데이터에는 영향이 없습니다.",
"repairBodySessionsUncertain": "트래커(MAL, AniList, Simkl, Trakt)와 Seerr는 별도로 저장되며 유지 여부가 확실하지 않습니다. Plezy가 유지된 항목을 정확히 알려줄 것입니다.",
"repairConfirm": "복구",
"repairSucceeded": "저장소가 복구되었습니다",
"repairNeedsRestart": "저장소가 복구되었습니다 — 재시작 필요",
"restartRequiredBody": "데이터가 복구되었지만 Plezy는 이를 사용하려면 새로 시작해야 합니다. Plezy를 종료한 후 다시 열어주세요.",
"quitPlezy": "Plezy 종료",
"repairFailed": "복구 실패",
"repairKeptSignIns": "서버와 프로필이 계속 로그인 상태입니다.",
"repairLostSignIns": "저장된 로그인 정보를 보호하는 키를 복구할 수 없었습니다. 모든 서버와 프로필에 다시 로그인해야 합니다.",
"repairLostSessions": "트래커 또는 Seerr 연결이 하나 이상 끊어져 다시 연결해야 합니다.",
"backupTitle": "손상된 파일의 복사본이 보관되었습니다",
"backupWarning": "로그인 자격 증명이 포함되어 있습니다. 업로드하거나 공유하지 마세요.",
"deleteBackup": "복사본 삭제",
"backupDeleted": "복사본이 삭제되었습니다.",
"previousFailureTitle": "지난번에 Plezy를 시작하지 못했습니다"
},
"licenses": {
"relatedPackages": "관련 소프트웨어 패키지",
@@ -1041,7 +1075,10 @@
"characters": "캐릭터",
"addToWatchlist": "관심 목록에 추가",
"removeFromWatchlist": "관심 목록에서 제거",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "관심 목록을 업데이트하지 못했습니다",
"watchlistNoMatch": "",
"notInLibrary": "라이브러리에 없음",
"inTheseLibraries": "이 라이브러리에 있음",
"checkingLibrary": "라이브러리 확인 중...",
@@ -1052,144 +1089,131 @@
"searchPrompt": "${source}에서 영화와 TV 프로그램을 검색하세요.",
"searchFailed": "검색에 실패했습니다. 연결을 확인하고 다시 시도하세요.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} 인기",
"rankAiring": "#${n} 방영 중",
"rankRated": "#${n} 평가",
"rankFavorited": "#${n} 즐겨찾기",
"rankTrending": "#${n} 트렌드",
"rankSeasonal": "#${n} ${season}",
"watchingNow": "${n}명 시청 중",
"available": "이용 가능",
"partiallyAvailable": "부분 이용 가능",
"availableIn4k": "4K 이용 가능",
"requested": "요청됨",
"pendingApproval": "승인 대기 중",
"processing": "처리 중",
"declined": "거절됨",
"requestFailed": "요청 실패",
"requested4k": "4K 요청됨",
"seasonsAvailable": "시즌 ${available}/${total}",
"nextEpisodeIn": "${episode}화 · ${duration} 후",
"nextAiringIn": "${duration} 후 다음 화",
"episodesShort": "${n}화",
"minutesPerEpisode": "${n}분/화",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n}명 등록",
"viewersDay": "오늘 ${n}명 시청",
"viewersWeek": "이번 주 ${n}명 시청",
"viewersMonth": "이번 달 ${n}명 시청",
"viewersYear": "올해 ${n}명 시청",
"viewersAllTime": "시청자 ${n}명",
"planning": "${n}명 볼 예정",
"favorited": "즐겨찾기 ${n}명",
"dropRate": "${percent} 하차",
"comments": {
"other": ""
"other": "댓글 ${n}개"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "투표 ${n}개",
"watching": "시청 중 ${n}명",
"completed": "완료 ${n}명",
"onHold": "보류 ${n}명",
"dropped": "하차 ${n}명"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "겨울",
"spring": "봄",
"summer": "여름",
"fall": "가을",
"withYear": "${season} ${year}년"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV 단편",
"movie": "영화",
"special": "스페셜",
"ova": "OVA",
"ona": "ONA",
"music": "음악",
"other": "기타"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "오리지널",
"manga": "만화",
"lightNovel": "라이트 노벨",
"novel": "소설",
"visualNovel": "비주얼 노벨",
"game": "게임",
"webComic": "웹툰",
"musicRelease": "음악",
"otherMedia": "기타"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"other": ""
"director": "감독",
"writer": "각본",
"producer": "프로듀서",
"creator": "제작자",
"composer": "작곡"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "프리퀄",
"sequel": "시퀄",
"sideStory": "사이드 스토리",
"spinOff": "스핀오프",
"alternativeVersion": "대체 버전",
"summary": "요약",
"parentStory": "원작",
"adaptation": "각색",
"other": "관련"
},
"broadcast": "${day} ${time} 방영",
"broadcastWithZone": "${day} ${time} ${timezone} 방영",
"detail": {
"originalTitle": "원제",
"alsoKnownAs": "다른 이름",
"studios": "스튜디오",
"country": "국가",
"language": "언어",
"released": "출시일",
"physicalRelease": "디스크 출시",
"ended": "종영",
"addedOn": "${date}에 추가됨",
"yourRating": "내 평점",
"budget": "제작비",
"revenue": "흥행 수익",
"contentAdvisory": "연령 등급",
"tags": "태그",
"revealSpoilerTags": "스포일러 태그 표시",
"links": "링크",
"watchOn": "시청처",
"watchTrailer": "예고편 보기",
"openOn": "${site}에서 열기",
"crew": "제작진",
"ratings": "평점",
"schedule": "방영 일정",
"recommendedByUsers": {
"other": "사용자 ${n}명이 추천"
},
"recommendedBy": "${who} 추천",
"favoritedBy": "${who} 즐겨찾기",
"unairedEpisodes": "미방영 ${n}개",
"recommendedByPercent": "시청자의 ${percent} 추천",
"relatedTitles": "관련 작품",
"background": "배경"
},
"totalResults": {
"other": "결과 ${n}개"
}
},
"liveTv": {
@@ -1221,6 +1245,9 @@
"watchChannel": "채널 시청",
"favorites": "즐겨찾기",
"reorderFavorites": "즐겨찾기 순서 변경",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"favoritesUpdateFailed": "즐겨찾기를 업데이트할 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"joinSession": "진행 중인 세션 참여",
@@ -1455,15 +1482,15 @@
"editSyncRule": "동기화 규칙 편집",
"removeSyncRule": "동기화 규칙 제거",
"removeSyncRuleConfirm": "\"${title}\" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "\"${title}\" 동기화를 중지할까요?",
"deleteSyncRuleDownloads": "연결된 다운로드도 삭제",
"deleteSyncRuleDownloadsDescription": "다른 동기화 규칙이나 프로필에서 사용하는 다운로드는 유지됩니다.",
"syncRuleCreated": "동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지",
"syncRuleUpdated": "동기화 규칙 업데이트됨",
"syncRuleRemoved": "동기화 규칙 제거됨",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "동기화 규칙과 연결된 다운로드가 삭제되었습니다",
"syncRuleCleanupBusy": "동기화 규칙이 현재 업데이트 중입니다. 잠시 후 다시 시도하세요.",
"syncRuleCleanupUnavailable": "연결된 다운로드를 안전하게 확인할 수 없습니다. 서버에 다시 연결한 후 시도하거나, 다운로드를 삭제하지 않고 규칙만 제거하세요.",
"syncedNewEpisodes": "${title}의 새 에피소드 ${count}개 동기화됨",
"activeSyncRules": "동기화 규칙",
"noSyncRules": "동기화 규칙 없음",
@@ -1608,9 +1635,15 @@
"audioSync": "오디오 동기화",
"subtitleSync": "자막 동기화",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이",
"audioPassthrough": "오디오 패스스루",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "서라운드",
@@ -1828,11 +1861,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "${service}에서 Plezy 활성화",
"body": "${url}을 방문하여 이 코드를 입력하세요:",
"instructions": "",
"openToActivate": "활성화하려면 ${service} 열기",
"copyCode": "활성화 코드 복사",
"waitingForAuthorization": "인증을 기다리는 중…",
@@ -1841,7 +1875,9 @@
"oauthProxy": {
"title": "${service}에 로그인",
"body": "이 QR 코드를 스캔하거나 아무 기기에서 URL을 여세요.",
"openToSignIn": "로그인하려면 ${service} 열기",
"openToSignIn": "로그인하려면 ${service} 열기"
},
"pendingAuth": {
"copyUrl": "로그인 URL 복사",
"urlCopied": "URL이 복사되었습니다"
},
@@ -1861,12 +1897,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfin 서버 추가",
"addMediaBrowserTitle": "${product} 서버 추가",
"serverUrls": "서버 URL",
"serverUrlsHelper": "쉼표로 구분하여 여러 URL을 입력할 수 있습니다.",
"findServer": "서버 찾기",
"searchingLocalServers": "로컬 Jellyfin 서버 검색 중...",
"localServers": "로컬 Jellyfin 서버",
"searchingLocalMediaBrowserServers": "로컬 ${product} 서버를 찾는 중...",
"localMediaBrowserServers": "로컬 ${product} 서버",
"username": "사용자 이름",
"password": "비밀번호",
"signIn": "로그인",
@@ -1878,15 +1914,15 @@
"addPlexTitle": "Plex로 로그인",
"pinExpired": "로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.",
"failedToRegisterAccount": "계정 등록 실패: ${error}",
"enterJellyfinUrlError": "Jellyfin 서버 URL을 입력하세요",
"enterMediaBrowserUrlError": "${product} 서버 URL을 입력하세요",
"addConnectionTitle": "연결 추가",
"addConnectionTitleScoped": "${name}에 추가",
"signInWithPlexCard": "Plex로 로그인",
"signInWithPlexCardSubtitle": "이 기기를 승인합니다. 공유 서버가 추가됩니다.",
"signInWithPlexCardSubtitleScoped": "Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.",
"connectToJellyfinCard": "Jellyfin에 연결",
"connectToJellyfinCardSubtitle": "서버 URL, 사용자 이름, 비밀번호를 입력하세요.",
"connectToJellyfinCardSubtitleScoped": "Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.",
"connectToMediaBrowserCard": "${product}에 연결",
"connectToMediaBrowserCardSubtitle": "서버 URL, 사용자 이름, 비밀번호를 입력하세요.",
"connectToMediaBrowserCardSubtitleScoped": "${product} 서버에 로그인하세요. ${name}에 연결됩니다.",
"borrowFromAnotherProfile": "다른 프로필에서 빌리기",
"borrowFromAnotherProfileSubtitle": "다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Venter på autentisering...\nLogg inn fra nettleseren.",
"useBrowser": "Bruk nettleser",
"or": "eller",
"connectToJellyfin": "Koble til Jellyfin",
"connectToMediaBrowser": "Koble til ${product}",
"useQuickConnect": "Bruk Quick Connect",
"quickConnectInstructions": "Åpne Quick Connect i Jellyfin og skriv inn denne koden.",
"quickConnectWaiting": "Venter på godkjenning…",
@@ -64,7 +64,20 @@
"loading": "Laster...",
"fullscreen": "Fullskjerm",
"exitFullscreen": "Avslutt fullskjerm",
"pressBackAgainToExit": "Trykk på Tilbake en gang til for å avslutte"
"pressBackAgainToExit": "Trykk på Tilbake en gang til for å avslutte",
"ratingSource": {
"critic": "Kritikere",
"audience": "Publikum",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes-kritikere",
"rottenTomatoesAudience": "Rotten Tomatoes-publikum"
}
},
"screens": {
"licenses": "Lisenser",
@@ -107,6 +120,7 @@
"darkTheme": "Mørkt",
"oledTheme": "OLED",
"libraryDensity": "Innholdstetthet i biblioteket",
"displayScale": "Skalering",
"compact": "Kompakt",
"comfortable": "Komfortabel",
"tvCornerSpotlightBackdrop": "Fremhevet bakgrunn i hjørnet",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size} MB",
"bufferSizeAuto": "Automatisk (anbefalt)",
"bufferSizeWarning": "${heap} MB minne tilgjengelig. En buffer på ${size} MB kan påvirke avspillingen.",
"playbackBuffer": "Avspillingsbuffer",
"playbackBufferAuto": "Auto (anbefalt)",
"playbackBufferLarge": "Stor",
"playbackBufferExtraLarge": "Ekstra stor",
"playbackBufferDescription": "Bufrer mer mot ustabile tilkoblinger. Begrenses også av bufferstørrelsen.",
"defaultQualityTitle": "Standardkvalitet",
"musicQualityTitle": "Musikkvalitet",
"subtitleStyling": "Undertekststil",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Aktiver detaljert logging for feilsøking",
"viewLogs": "Vis logger",
"viewLogsDescription": "Vis applikasjonslogger",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Tøm bildebufferen",
"clearImageCacheDescription": "Tømmer bufret kunstverk og miniatyrbilder. Bilder kan lastes langsommere til de er lastet ned på nytt.",
"clearImageCacheSuccess": "Bildebufferen ble tømt",
"resetSettings": "Tilbakestill innstillinger",
"resetSettingsDescription": "Gjenopprett standardinnstillinger. Dette kan ikke angres.",
"resetSettingsSuccess": "Innstillinger tilbakestilt",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normaliser lydstyrke ved nedmiks",
"audioDownmixNormalizeDescription": "Senker miksen for å unngå klipping. Slå av for å beholde originalvolumet (høye scener kan forvrenges).",
"atmosDiagnostics": "Atmos-utgangstest",
"atmosDiagnosticsDescription": "Diagnostiser Dolby Atmos-utgangen ved å spille testsignaler gjennom systemspilleren",
"atmosTestHlsAtmos": "Apple Atmos-strøm",
"atmosTestHlsAtmosDescription": "Verifisert Dolby Atmos-strøm. Mottakeren bør vise Dolby Atmos.",
"atmosTestHlsControl": "Apple surround-strøm",
"atmosTestHlsControlDescription": "Kontrollstrøm uten Atmos. Mottakeren bør vise surround uten Atmos.",
"atmosTestRawStream": "Rå EAC3-strøm",
"atmosTestRawStreamDescription": "Strømmer testfilen akkurat som Atmos-avspilling i spilleren. Krever testfilens URL.",
"atmosTestRawFile": "Rå EAC3-fil",
"atmosTestRawFileDescription": "Spiller av testfilen med kjent lengde. Krever testfilens URL.",
"atmosTestAsbarNative": "Sample-buffer-renderer (nativ)",
"atmosTestAsbarNativeDescription": "Sender filens urørte komprimerte lyd rett til systemets renderer. Krever URL til testfilen.",
"atmosTestAsbarGenerated": "Sample-buffer-renderer (gjenoppbygd)",
"atmosTestAsbarGeneratedDescription": "Det samme, men med lydbeskrivelsen bygd slik avspilling bygger den. Krever URL til testfilen.",
"atmosTestSessionMode": "Bruk filmavspillingsmodus",
"atmosTestSessionModeDescription": "Av bruker modusen Dolby dokumenterer. På bruker den tidligere modusen.",
"atmosTestShowRoutePicker": "Velg AirPlay-utgang",
"atmosTestHideRoutePicker": "Skjul AirPlay-utgangsvelger",
"atmosTestRoutePickerDescription": "Sender testen til en AirPlay-mottaker. Bare AirPlay rapporterer den valgte lydmodusen.",
"atmosTestStop": "Stopp test",
"atmosTestUrl": "Testfilens URL",
"atmosTestUrlDescription": "HTTP-URL til en rå .ec3 Dolby Atmos-fil (f.eks. hentet ut med ffmpeg)",
"atmosTestUrlMissing": "Angi testfilens URL først",
"atmosTestStatus": "Status",
"dvConversionMode": "Dolby Vision-konvertering",
"dvConversionModeDescription": "Velg hvordan ExoPlayer håndterer filer med Dolby Vision-profil 7.",
"dvConversionAuto": "Automatisk",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Vis etiketter i navigasjonsfeltet",
"showNavBarLabelsDescription": "Vis tekstetiketter under ikonene i navigasjonsfeltet",
"startupSection": "Startseksjon",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Vis favorittkanaler som standard",
"liveTvDefaultFavoritesDescription": "Vis bare favorittkanaler når du åpner Direkte-TV",
"display": "Skjerm",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Filinformasjon",
"overview": "",
"overview": "Oversikt",
"video": "Video",
"audio": "Lyd",
"subtitles": "Undertekster",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Innebygde bilder",
"dataStreams": "Datastrømmer",
"lyrics": "Sangtekster",
"file": "Fil",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Vedlegg",
"delivery": "Levering",
"versionCounter": "Versjon ${index} av ${count}",
"fileCounter": "Fil ${index} av ${count}",
"noStreams": "Serveren rapporterte ingen strømmer for denne filen.",
"copyPath": "Kopier bane",
"pathCopied": "Filbane kopiert",
"codec": "Kodek",
"codecTag": "",
"codecTag": "Codec-tagg",
"resolution": "Oppløsning",
"codedResolution": "",
"codedResolution": "Kodet oppløsning",
"bitrate": "Bitrate",
"frameRate": "Bildefrekvens",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotasjon",
"comment": "Kommentar",
"audioDescription": "Lydbeskrivelse",
"headerCompression": "Overskriftkomprimering",
"sidecarFile": "Sidecar-fil",
"transportTimestamp": "Transporttidsstempel",
"displayOffset": "Visningsforskyvning",
"previewFailureCode": "Forhåndsvisningsfeilkode",
"previewRetries": "Nye forhåndsvisningsforsøk",
"aspectRatio": "Sideforhold",
"pixelAspectRatio": "",
"pixelAspectRatio": "Piksel-aspektforhold",
"profile": "Profil",
"level": "",
"level": "Nivå",
"bitDepth": "Bitdybde",
"pixelFormat": "",
"pixelFormat": "Pikselformat",
"colorSpace": "Fargerom",
"colorRange": "Fargeområde",
"colorPrimaries": "Fargeprimærer",
"colorTransfer": "",
"colorTransfer": "Fargeoverføring",
"chromaSubsampling": "Krominansnedsampling",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Kroma-plassering",
"scanType": "Skanningstype",
"interlaced": "Interlaced",
"anamorphic": "Anamorfisk",
"referenceFrames": "Referanserammer",
"dynamicRange": "Dynamisk omfang",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision-nivå",
"dolbyVisionVersion": "Dolby Vision-versjon",
"dolbyVisionLayers": "Dolby Vision-lag",
"baseLayerCompatibility": "Kompatibilitet for basislag",
"avcBitstream": "AVC-bitstrøm",
"nalLengthSize": "NAL-lengdestørrelse",
"scalingMatrix": "Egendefinert skaleringsmatrise",
"streamIdentifier": "Strømidentifikator",
"streamIndex": "Strømindeks",
"streamId": "Strøm-ID",
"language": "Språk",
"languageCode": "Språkkode",
"streamTitle": "Spor-tittel",
"channels": "Kanaler",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Samplingsfrekvens",
"spatialAudio": "Romlig lyd",
"textBased": "Tekstbasert",
"subtitleFormat": "Sidecar-format",
"provider": "Tilbyder",
"matchScore": "Match-poengsum",
"externalDelivery": "Kan leveres separat",
"sidecarPath": "Sidecar-bane",
"sourceStream": "Kopiert fra",
"temporary": "Midlertidig",
"timeBase": "Tidsbase",
"overallBitrate": "Total bitrate",
"path": "Sti",
"fileName": "",
"fileName": "Filnavn",
"size": "Størrelse",
"totalSize": "",
"totalSize": "Total størrelse",
"container": "Format",
"duration": "Varighet",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Forhåndsvisningsminiatyrer",
"previewIndex": "Forhåndsvisningsindeks",
"packetLength": "Pakkelengde",
"filePresent": "Fil til stede",
"fileReadable": "Lesbar for serveren",
"streamPath": "Strømbane",
"optimizedForStreaming": "Optimalisert for strømming",
"has64bitOffsets": "64-biters forskyvninger",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protokoll",
"mediaType": "Mediatype",
"sourceKind": "Kildetype",
"optimizedVersion": "Optimalisert versjon",
"optimizationTarget": "Optimaliseringsmål",
"deletedAt": "Slettet",
"remoteSource": "Ekstern kilde",
"infiniteStream": "Uendelig strøm",
"directPlay": "Direct Play",
"directStream": "Direct Stream",
"transcoding": "Transkoding",
"etag": "ETag",
"versionId": "Versjons-ID",
"fileId": "Fil-ID",
"defaultAudioTrack": "Standard lydspor",
"defaultSubtitleTrack": "Standard tekstspor",
"subtitlesOff": "Av",
"flagDefault": "Standard",
"flagForced": "Tvunget",
"flagSelected": "Valgt",
"flagExternal": "Ekstern",
"flagHearingImpaired": "Hørselshemmet",
"flagDub": "Dub",
"flagOriginal": "Original"
},
"mediaMenu": {
"markAsWatched": "Merk som sett",
@@ -505,9 +502,35 @@
"shufflePlay": "Tilfeldig avspilling",
"shuffleNotAvailableOffline": "Tilfeldig avspilling er ikke tilgjengelig uten nett",
"fileInfo": "Filinformasjon",
"deleteFromServer": "Slett fra server",
"confirmDelete": "Slette dette mediet og filene fra serveren?",
"deleteEpisodeFromServer": "Slett episode fra serveren",
"deleteSeasonFromServer": "Slett sesong fra serveren",
"deleteShowFromServer": "Slett serie fra serveren",
"deleteMovieFromServer": "Slett film fra serveren",
"deleteEpisodeTitle": "Slette denne episoden?",
"deleteSeasonTitle": "Slette denne sesongen?",
"deleteShowTitle": "Slette denne serien?",
"deleteMovieTitle": "Slette denne filmen?",
"deleteEpisodeConfirm": "Slett episode",
"deleteSeasonConfirm": "Slett sesong",
"deleteShowConfirm": "Slett serie",
"deleteMovieConfirm": "Slett film",
"deleteAnyway": "Slett likevel",
"confirmDeleteTarget": "Slette ${title} permanent fra serveren din?",
"deleteMultipleWarning": "Dette inkluderer alle episoder og deres filer.",
"deleteEpisodeCountWarning": {
"one": "Dette sletter ${n} episode i den, og filen.",
"other": "Dette sletter alle ${n} episodene i den, og filene deres."
},
"deleteMultiPartWarning": {
"one": "Dette elementet er lagret som ${n} fil, som vil bli slettet.",
"other": "Dette elementet er lagret på tvers av ${n} filer, og alle vil bli slettet."
},
"deleteSharedFileHeading": {
"one": "${n} annen episode er lagret i samme fil og vil også bli slettet:",
"other": "${n} andre episoder er lagret i samme fil og vil også bli slettet:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy kunne ikke sjekke hvilke filer dette vil fjerne, så det kan slette mer enn elementet som er nevnt ovenfor. Avbryt og prøv igjen, eller slett likevel.",
"deleteScopeUnverifiedNoFileInfo": "Serveren din ga ikke fildetaljer for dette elementet, så Plezy kan ikke sjekke hvilke filer dette vil fjerne. Det kan slette mer enn elementet som er nevnt ovenfor.",
"mediaDeletedSuccessfully": "Medieelement slettet",
"mediaFailedToDelete": "Kunne ikke slette medieelement",
"rate": "Vurder",
@@ -583,8 +606,9 @@
"playNext": "Spill neste",
"playButton": "Spill av",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Pauset",
"playbackResumed": "Spiller",
"loadingVideo": "Laster video",
"showPlaybackControls": "Vis avspillingskontroller",
"hidePlaybackControls": "Skjul avspillingskontroller",
"seekBackwardButton": "Spol tilbake ${seconds} sekunder",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Ingen kapitler tilgjengelig",
"queue": "Kø",
"noQueueItems": "Ingen elementer i kø",
"noAudioDevicesAvailable": "Ingen lydenheter tilgjengelig",
"searchSubtitles": "Søk etter undertekster",
"language": "Språk",
"noSubtitlesFound": "Ingen undertekster funnet",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Fjernet fra Fortsett å se",
"errorLoading": "Feil: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Noen medieservere kunne ikke søkes i. Viser tilgjengelige resultater.",
"streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.",
"liveStreamInterrupted": "Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.",
"fileInfoNotAvailable": "Filinformasjon ikke tilgjengelig",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Bytter til kompatibel spiller...",
"serverLimitTitle": "Avspilling mislyktes",
"serverLimitBody": "Serverfeil (HTTP 500). En båndbredde-/transkodingsgrense avviste trolig økten. Be eieren justere den.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Fil utilgjengelig",
"mediaUnreadableBody": "Serveren fant dette elementet, men kunne ikke lese filen (HTTP 404). Filen er sannsynligvis flyttet, slettet, eller lagringen er frakoblet. Be serverens eier om å sjekke filen og skanne biblioteket på nytt.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Logger lastet opp",
"logsUploadFailed": "Kunne ikke laste opp logger",
"logId": "Logg-ID"
"logId": "Logg-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
@@ -718,6 +746,8 @@
"overrideStrip": "Fjern formatering",
"positionTop": "Øverst",
"positionBottom": "Nederst",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Fet",
"italic": "Kursiv",
"renderResolution": "Gjengivelsesoppløsning",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Ingen tilkoblinger — legg til én for å bruke denne profilen.",
"noConnections": "Ingen tilkoblinger",
"plexHomeAccount": "Plex Home-konto",
"plexAccountChip": "Plex-konto: ${account}",
"plexAccountUserChip": "${user} via ${account}",
"connectionDefault": "Standard",
"connectionAs": "som ${displayName}",
"makeDefault": "Gjør til standard",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Økten er utløpt for ${name}",
"sessionExpiredMany": "Økten er utløpt for ${count} servere",
"signInAgain": "Logg inn igjen",
"editJellyfinTitle": "Rediger Jellyfin-tilkobling",
"editJellyfinIntro": "Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse."
"editMediaBrowserTitle": "Rediger ${product}-tilkobling",
"editMediaBrowserIntro": "Legg til eller fjern URL-er for ${serverName}. Plezy vil bruke den nåbare URL-en med lavest ventetid."
},
"discover": {
"title": "Oppdag",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Søk mislyktes: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Søket kunne ikke nå noen medieserver.",
"connectionTimeout": "Tidsavbrudd ved lasting av ${context}",
"connectionFailed": "Kan ikke koble til medieserver",
"unableToLoad": "Kunne ikke laste ${context}. Prøv igjen.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Last opp logger"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy kunne ikke starte",
"failedBody": "Noe gikk galt under oppstart. Detaljene nedenfor viser hva som feilet.",
"failedBodyRepairable": "Plezy's lagrede innstillingsfil er skadet og må bygges på nytt før Plezy kan starte. Å prøve igjen hjelper ikke – velg Reparer lagring.",
"phaseLabel": "Trinn",
"showDetails": "Vis detaljer",
"hideDetails": "Skjul detaljer",
"copyDetails": "Kopier detaljer",
"detailsCopied": "Detaljer kopiert til utklippstavlen",
"uploadDetails": "Last opp detaljer",
"repairStorage": "Reparer lagring",
"repairTitle": "Reparere lagrede data?",
"repairBodyCommon": "Plezy's innstillingsfil er skadet og kan ikke leses. Reparasjon tilbakestiller alle innstillinger til standard.",
"repairBodyOneCredential": "Én lagret pålogging er skadet og kan ikke leses. Reparasjon fjerner bare den; de andre innstillingene dine blir urørt.",
"repairBodySignInsKept": "Serverne og profilene dine skal forbli pålogget.",
"repairBodySignInsLost": "Nøkkelen som beskytter de lagrede påloggingene dine kan ikke gjenvinnes fra denne filen, så du må logge på alle servere og profiler på nytt. Ingenting på medieserveren din påvirkes.",
"repairBodySessionsUncertain": "Trackere (MAL, AniList, Simkl, Trakt) og Seerr lagres separat og kan overleve eller ikke. Plezy vil fortelle deg nøyaktig hva det beholdt.",
"repairConfirm": "Reparer",
"repairSucceeded": "Lagring reparert",
"repairNeedsRestart": "Lagring reparert – omstart kreves",
"restartRequiredBody": "Dataene dine ble reparert, men Plezy må starte på nytt før det kan bruke dem. Lukk Plezy og åpne det igjen.",
"quitPlezy": "Avslutt Plezy",
"repairFailed": "Reparasjon mislyktes",
"repairKeptSignIns": "Serverne og profilene dine er fortsatt pålogget.",
"repairLostSignIns": "Nøkkelen som beskytter de lagrede påloggingene dine kunne ikke gjenvinnes. Du må logge på alle servere og profiler på nytt.",
"repairLostSessions": "Minst én tracker- eller Seerr-tilkobling gikk tapt og må kobles til på nytt.",
"backupTitle": "En kopi av den skadede filen ble beholdt",
"backupWarning": "Den inneholder påloggingsinformasjonen din. Ikke last den opp eller del den.",
"deleteBackup": "Slett kopi",
"backupDeleted": "Kopi slettet.",
"previousFailureTitle": "Plezy mislyktes i å starte forrige gang"
},
"licenses": {
"relatedPackages": "Relaterte pakker",
@@ -1043,7 +1080,10 @@
"characters": "Figurer",
"addToWatchlist": "Legg til i ønskeliste",
"removeFromWatchlist": "Fjern fra ønskeliste",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Kunne ikke oppdatere ønskelisten",
"watchlistNoMatch": "",
"notInLibrary": "Ikke i biblioteket ditt",
"inTheseLibraries": "I disse bibliotekene",
"checkingLibrary": "Sjekker biblioteket ditt...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Søk etter filmer og serier på ${source}.",
"searchFailed": "Søk mislyktes. Sjekk tilkoblingen og prøv igjen.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} populær",
"rankAiring": "#${n} på lufta",
"rankRated": "#${n} vurdert",
"rankFavorited": "#${n} favorisert",
"rankTrending": "#${n} trendende",
"rankSeasonal": "#${n} i ${season}",
"watchingNow": "${n} ser på",
"available": "Tilgjengelig",
"partiallyAvailable": "Delvis tilgjengelig",
"availableIn4k": "4K tilgjengelig",
"requested": "Forespurt",
"pendingApproval": "Venter på godkjenning",
"processing": "Behandler",
"declined": "Avslått",
"requestFailed": "Forespørsel mislyktes",
"requested4k": "4K forespurt",
"seasonsAvailable": "${available}/${total} sesonger",
"nextEpisodeIn": "Ep ${episode} om ${duration}",
"nextAiringIn": "Neste om ${duration}",
"episodesShort": "${n} ep.",
"minutesPerEpisode": "${n} min/ep",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} oppført",
"viewersDay": "${n} så på i dag",
"viewersWeek": "${n} så på denne uken",
"viewersMonth": "${n} så på denne måneden",
"viewersYear": "${n} så på i år",
"viewersAllTime": "${n} seere",
"planning": "${n} planlegger å se",
"favorited": "${n} favoritter",
"dropRate": "${percent} droppet den",
"comments": {
"one": "",
"other": ""
"one": "${n} kommentar",
"other": "${n} kommentarer"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} stemmer",
"watching": "${n} ser på den",
"completed": "${n} fullførte",
"onHold": "${n} på pause",
"dropped": "${n} droppet"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Vinter",
"spring": "Vår",
"summer": "Sommer",
"fall": "Høst",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV-kort",
"movie": "Film",
"special": "Spesial",
"ova": "OVA",
"ona": "ONA",
"music": "Musikk",
"other": "Annet"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Original",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Roman",
"visualNovel": "Visual novel",
"game": "Spill",
"webComic": "Nettserie",
"musicRelease": "Musikk",
"otherMedia": "Annet"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Regissør",
"writer": "Forfatter",
"producer": "Produsent",
"creator": "Skaper",
"composer": "Komponist"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Bihistorie",
"spinOff": "Spin-off",
"alternativeVersion": "Alternativ versjon",
"summary": "Sammendrag",
"parentStory": "Hovedhistorie",
"adaptation": "Adaptasjon",
"other": "Relatert"
},
"broadcast": "Sendes ${day} kl. ${time}",
"broadcastWithZone": "Sendes ${day} kl. ${time} ${timezone}",
"detail": {
"originalTitle": "Originaltittel",
"alsoKnownAs": "Også kjent som",
"studios": "Studioer",
"country": "Land",
"language": "Språk",
"released": "Utgitt",
"physicalRelease": "På disk",
"ended": "Avsluttet",
"addedOn": "Lagt til ${date}",
"yourRating": "Din vurdering",
"budget": "Budsjett",
"revenue": "Billettinntekter",
"contentAdvisory": "Aldersveiledning",
"tags": "Tagger",
"revealSpoilerTags": "Vis spoilertagger",
"links": "Lenker",
"watchOn": "Se på",
"watchTrailer": "Se trailer",
"openOn": "Åpne på ${site}",
"crew": "Mannskap",
"ratings": "Vurderinger",
"schedule": "Sendeskjema",
"recommendedByUsers": {
"one": "Anbefalt av ${n} bruker",
"other": "Anbefalt av ${n} brukere"
},
"recommendedBy": "Anbefalt av ${who}",
"favoritedBy": "Favorisert av ${who}",
"unairedEpisodes": "${n} har ikke blitt sendt ennå",
"recommendedByPercent": "Anbefalt av ${percent} av seerne",
"relatedTitles": "Relaterte titler",
"background": "Bakgrunn"
},
"totalResults": {
"one": "${n} resultat",
"other": "${n} resultater"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Se kanal",
"favorites": "Favoritter",
"reorderFavorites": "Endre rekkefølge på favoritter",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.",
"favoritesUpdateFailed": "Kunne ikke oppdatere favorittene. Kontroller tilkoblingen og prøv på nytt.",
"joinSession": "Bli med i pågående økt",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Rediger synkroniseringsregel",
"removeSyncRule": "Fjern synkroniseringsregel",
"removeSyncRuleConfirm": "Slutte å synkronisere \"${title}\"? Nedlastede episoder beholdes.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Slutte å synkronisere «${title}»?",
"deleteSyncRuleDownloads": "Slett også tilknyttede nedlastinger",
"deleteSyncRuleDownloadsDescription": "Nedlastinger som brukes av en annen synkroniseringsregel eller profil, beholdes.",
"syncRuleCreated": "Synkroniseringsregel opprettet — beholder ${count} usette episoder",
"syncRuleUpdated": "Synkroniseringsregel oppdatert",
"syncRuleRemoved": "Synkroniseringsregel fjernet",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Synkroniseringsregel og tilknyttede nedlastinger fjernet",
"syncRuleCleanupBusy": "Synkroniseringsreglene oppdateres for øyeblikket. Prøv igjen om et øyeblikk.",
"syncRuleCleanupUnavailable": "Tilknyttede nedlastinger kunne ikke identifiseres på en trygg måte. Koble til serveren på nytt og prøv igjen, eller fjern regelen uten å slette nedlastingene.",
"syncedNewEpisodes": "Synkroniserte ${count} nye episoder for ${title}",
"activeSyncRules": "Synkroniseringsregler",
"noSyncRules": "Ingen synkroniseringsregler",
@@ -1614,9 +1644,15 @@
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Lydutgang",
"performanceOverlay": "Ytelsesoverlegg",
"audioPassthrough": "Direkte lydutgang",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Aktiver Plezy på ${service}",
"body": "Besøk ${url} og skriv inn denne koden:",
"instructions": "",
"openToActivate": "Åpne ${service} for å aktivere",
"copyCode": "Kopier aktiveringskode",
"waitingForAuthorization": "Venter på godkjenning…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Logg inn på ${service}",
"body": "Skann denne QR-koden eller åpne URL-en på en enhet.",
"openToSignIn": "Åpne ${service} for å logge inn",
"openToSignIn": "Åpne ${service} for å logge inn"
},
"pendingAuth": {
"copyUrl": "Kopier URL for pålogging",
"urlCopied": "URL kopiert"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Legg til Jellyfin-server",
"addMediaBrowserTitle": "Legg til ${product}-server",
"serverUrls": "Server-URL-er",
"serverUrlsHelper": "Flere URL-er er tillatt, atskilt med komma.",
"findServer": "Finn server",
"searchingLocalServers": "Søker etter lokale Jellyfin-servere...",
"localServers": "Lokale Jellyfin-servere",
"searchingLocalMediaBrowserServers": "Leter etter lokale ${product}-servere...",
"localMediaBrowserServers": "Lokale ${product}-servere",
"username": "Brukernavn",
"password": "Passord",
"signIn": "Logg inn",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Logg inn med Plex",
"pinExpired": "PIN-koden utløp før innloggingen var fullført. Prøv igjen.",
"failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}",
"enterJellyfinUrlError": "Oppgi URL-en til Jellyfin-serveren din",
"enterMediaBrowserUrlError": "Skriv inn URL-en til ${product}-serveren din",
"addConnectionTitle": "Legg til tilkobling",
"addConnectionTitleScoped": "Legg til for ${name}",
"signInWithPlexCard": "Logg inn med Plex",
"signInWithPlexCardSubtitle": "Autoriser denne enheten. Delte servere legges til.",
"signInWithPlexCardSubtitleScoped": "Autoriser en Plex-konto. Home-brukere blir profiler.",
"connectToJellyfinCard": "Koble til Jellyfin",
"connectToJellyfinCardSubtitle": "Skriv inn server-URL, brukernavn og passord.",
"connectToJellyfinCardSubtitleScoped": "Logg på en Jellyfin-server. Knyttes til ${name}.",
"connectToMediaBrowserCard": "Koble til ${product}",
"connectToMediaBrowserCardSubtitle": "Skriv inn server-URL-en, brukernavnet og passordet ditt.",
"connectToMediaBrowserCardSubtitleScoped": "Logg på ${product}-serveren din. Knyttes til ${name}.",
"borrowFromAnotherProfile": "Lån fra en annen profil",
"borrowFromAnotherProfileSubtitle": "Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Wachten op authenticatie...\nMeld je aan via je browser.",
"useBrowser": "Gebruik browser",
"or": "of",
"connectToJellyfin": "Verbinden met Jellyfin",
"connectToMediaBrowser": "Verbinding maken met ${product}",
"useQuickConnect": "Quick Connect gebruiken",
"quickConnectInstructions": "Open Quick Connect in Jellyfin en voer deze code in.",
"quickConnectWaiting": "Wachten op goedkeuring…",
@@ -64,7 +64,20 @@
"loading": "Laden...",
"fullscreen": "Volledig scherm",
"exitFullscreen": "Volledig scherm verlaten",
"pressBackAgainToExit": "Druk nogmaals op terug om af te sluiten"
"pressBackAgainToExit": "Druk nogmaals op terug om af te sluiten",
"ratingSource": {
"critic": "Critici",
"audience": "Publiek",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Rotten Tomatoes-critici",
"rottenTomatoesAudience": "Rotten Tomatoes-publiek"
}
},
"screens": {
"licenses": "Licenties",
@@ -107,6 +120,7 @@
"darkTheme": "Donker",
"oledTheme": "OLED",
"libraryDensity": "Bibliotheekdichtheid",
"displayScale": "Weergaveschaal",
"compact": "Compact",
"comfortable": "Comfortabel",
"tvCornerSpotlightBackdrop": "Uitgelichte achtergrond in de hoek",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automatisch (aanbevolen)",
"bufferSizeWarning": "${heap}MB geheugen beschikbaar. Een buffer van ${size}MB kan afspelen beïnvloeden.",
"playbackBuffer": "Afspeelbuffer",
"playbackBufferAuto": "Auto (aanbevolen)",
"playbackBufferLarge": "Groot",
"playbackBufferExtraLarge": "Extra groot",
"playbackBufferDescription": "Buffert meer tegen onstabiele verbindingen. Ook beperkt door de buffergrootte.",
"defaultQualityTitle": "Standaardkwaliteit",
"musicQualityTitle": "Muziekkwaliteit",
"subtitleStyling": "Ondertitelopmaak",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Schakel gedetailleerde logboekregistratie in om problemen op te lossen",
"viewLogs": "Logbestanden bekijken",
"viewLogsDescription": "Logbestanden van de app bekijken",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Afbeeldingscache wissen",
"clearImageCacheDescription": "Gecachte artwork en miniaturen wissen. Afbeeldingen kunnen langzamer laden tot ze opnieuw zijn gedownload.",
"clearImageCacheSuccess": "Afbeeldingscache succesvol gewist",
"resetSettings": "Instellingen resetten",
"resetSettingsDescription": "Standaardinstellingen herstellen. Dit kan niet ongedaan worden gemaakt.",
"resetSettingsSuccess": "Instellingen succesvol gereset",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Volume normaliseren bij downmix",
"audioDownmixNormalizeDescription": "Verlaagt de mix om clipping te voorkomen. Zet uit om het originele volume te behouden (kan vervormen bij luide scènes).",
"atmosDiagnostics": "Atmos-uitvoertest",
"atmosDiagnosticsDescription": "Diagnosticeer de Dolby Atmos-uitvoer door testsignalen via de systeemspeler af te spelen",
"atmosTestHlsAtmos": "Apple Atmos-stream",
"atmosTestHlsAtmosDescription": "Bewezen werkende Dolby Atmos-stream. De receiver zou Dolby Atmos moeten tonen.",
"atmosTestHlsControl": "Apple surround-stream",
"atmosTestHlsControlDescription": "Controlestream zonder Atmos. De receiver zou surround zonder Atmos moeten tonen.",
"atmosTestRawStream": "Ruwe EAC3-stream",
"atmosTestRawStreamDescription": "Streamt het testbestand precies zoals Atmos-weergave in de speler. Vereist de URL van het testbestand.",
"atmosTestRawFile": "Ruw EAC3-bestand",
"atmosTestRawFileDescription": "Speelt het testbestand met bekende lengte af. Vereist de URL van het testbestand.",
"atmosTestAsbarNative": "Sample-bufferrenderer (native)",
"atmosTestAsbarNativeDescription": "Stuurt de ongewijzigde gecomprimeerde audio van het bestand rechtstreeks naar de systeemrenderer. Vereist de URL van het testbestand.",
"atmosTestAsbarGenerated": "Sample-bufferrenderer (opnieuw opgebouwd)",
"atmosTestAsbarGeneratedDescription": "Hetzelfde, maar met de audiobeschrijving opgebouwd zoals bij afspelen. Vereist de URL van het testbestand.",
"atmosTestSessionMode": "Filmafspeelmodus gebruiken",
"atmosTestSessionModeDescription": "Uit gebruikt de modus die Dolby documenteert. Aan gebruikt de vorige modus.",
"atmosTestShowRoutePicker": "AirPlay-uitvoer kiezen",
"atmosTestHideRoutePicker": "AirPlay-uitvoerkiezer verbergen",
"atmosTestRoutePickerDescription": "Stuurt de test naar een AirPlay-ontvanger. Alleen AirPlay meldt de bepaalde audiomodus.",
"atmosTestStop": "Test stoppen",
"atmosTestUrl": "URL van testbestand",
"atmosTestUrlDescription": "HTTP-URL van een ruw .ec3 Dolby Atmos-bestand (bijv. uitgepakt met ffmpeg)",
"atmosTestUrlMissing": "Stel eerst de URL van het testbestand in",
"atmosTestStatus": "Status",
"dvConversionMode": "Dolby Vision-conversie",
"dvConversionModeDescription": "Kies hoe ExoPlayer Dolby Vision Profile 7-bestanden verwerkt.",
"dvConversionAuto": "Automatisch",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Labels op navigatiebalk tonen",
"showNavBarLabelsDescription": "Tekstlabels onder de pictogrammen op de navigatiebalk weergeven",
"startupSection": "Opstartsectie",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Standaard favoriete zenders",
"liveTvDefaultFavoritesDescription": "Toon alleen favoriete zenders bij het openen van Live TV",
"display": "Weergave",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Bestandsinformatie",
"overview": "",
"overview": "Overzicht",
"video": "Video",
"audio": "Audio",
"subtitles": "Ondertitels",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Ingebedde afbeeldingen",
"dataStreams": "Datastreams",
"lyrics": "Songteksten",
"file": "Bestand",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Bijlagen",
"delivery": "Levering",
"versionCounter": "Versie ${index} van ${count}",
"fileCounter": "Bestand ${index} van ${count}",
"noStreams": "De server rapporteerde geen streams voor dit bestand.",
"copyPath": "Pad kopiëren",
"pathCopied": "Bestandspad gekopieerd",
"codec": "Codec",
"codecTag": "",
"codecTag": "Codec-tag",
"resolution": "Resolutie",
"codedResolution": "",
"codedResolution": "Gecodeerde resolutie",
"bitrate": "Bitrate",
"frameRate": "Framesnelheid",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotatie",
"comment": "Opmerking",
"audioDescription": "Audiodescriptie",
"headerCompression": "Headercompressie",
"sidecarFile": "Sidecar-bestand",
"transportTimestamp": "Transporttijdstempel",
"displayOffset": "Display-offset",
"previewFailureCode": "Foutcode preview",
"previewRetries": "Previewpogingen",
"aspectRatio": "Beeldverhouding",
"pixelAspectRatio": "",
"pixelAspectRatio": "Pixel-aspectratio",
"profile": "Profiel",
"level": "",
"level": "Niveau",
"bitDepth": "Bitdiepte",
"pixelFormat": "",
"pixelFormat": "Pixelformaat",
"colorSpace": "Kleurruimte",
"colorRange": "Kleurbereik",
"colorPrimaries": "Kleurprimaires",
"colorTransfer": "",
"colorTransfer": "Kleurtransfer",
"chromaSubsampling": "Chroma-subsampling",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Chroma-positie",
"scanType": "Scantype",
"interlaced": "Interlaced",
"anamorphic": "Anamorf",
"referenceFrames": "Referentieframes",
"dynamicRange": "Dynamisch bereik",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Dolby Vision-niveau",
"dolbyVisionVersion": "Dolby Vision-versie",
"dolbyVisionLayers": "Dolby Vision-lagen",
"baseLayerCompatibility": "Compatibiliteit basislaag",
"avcBitstream": "AVC-bitstroom",
"nalLengthSize": "NAL-lengte",
"scalingMatrix": "Aangepaste schalingsmatrix",
"streamIdentifier": "Stream-id",
"streamIndex": "Streamindex",
"streamId": "Stream-ID",
"language": "Taal",
"languageCode": "Taalcode",
"streamTitle": "Tracktitel",
"channels": "Kanalen",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Samplingsnelheid",
"spatialAudio": "Ruimtelijke audio",
"textBased": "Op tekst gebaseerd",
"subtitleFormat": "Sidecar-formaat",
"provider": "Provider",
"matchScore": "Matchscore",
"externalDelivery": "Kan afzonderlijk worden geleverd",
"sidecarPath": "Sidecar-pad",
"sourceStream": "Gekopieerd van",
"temporary": "Tijdelijk",
"timeBase": "Tijdbasis",
"overallBitrate": "Totale bitrate",
"path": "Pad",
"fileName": "",
"fileName": "Bestandsnaam",
"size": "Grootte",
"totalSize": "",
"totalSize": "Totale grootte",
"container": "Container",
"duration": "Duur",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Preview-miniaturen",
"previewIndex": "Preview-index",
"packetLength": "Pakketlengte",
"filePresent": "Bestand aanwezig",
"fileReadable": "Leesbaar door server",
"streamPath": "Streampad",
"optimizedForStreaming": "Geoptimaliseerd voor streaming",
"has64bitOffsets": "64-bits offsets",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protocol",
"mediaType": "Mediatype",
"sourceKind": "Soort bron",
"optimizedVersion": "Geoptimaliseerde versie",
"optimizationTarget": "Optimalisatiedoel",
"deletedAt": "Verwijderd",
"remoteSource": "Externe bron",
"infiniteStream": "Oneindige stream",
"directPlay": "Direct Play",
"directStream": "Direct Stream",
"transcoding": "Transcoderen",
"etag": "ETag",
"versionId": "Versie-ID",
"fileId": "Bestands-ID",
"defaultAudioTrack": "Standaard audiotrack",
"defaultSubtitleTrack": "Standaard ondertitelingstrack",
"subtitlesOff": "Uit",
"flagDefault": "Standaard",
"flagForced": "Geforceerd",
"flagSelected": "Geselecteerd",
"flagExternal": "Extern",
"flagHearingImpaired": "Slechthorend",
"flagDub": "Nasynchronisatie",
"flagOriginal": "Origineel"
},
"mediaMenu": {
"markAsWatched": "Als bekeken markeren",
@@ -505,9 +502,35 @@
"shufflePlay": "Willekeurig afspelen",
"shuffleNotAvailableOffline": "Willekeurig afspelen is offline niet beschikbaar",
"fileInfo": "Bestandsinformatie",
"deleteFromServer": "Van server verwijderen",
"confirmDelete": "Deze media en bestanden van je server verwijderen?",
"deleteEpisodeFromServer": "Aflevering verwijderen van server",
"deleteSeasonFromServer": "Seizoen verwijderen van server",
"deleteShowFromServer": "Serie verwijderen van server",
"deleteMovieFromServer": "Film verwijderen van server",
"deleteEpisodeTitle": "Deze aflevering verwijderen?",
"deleteSeasonTitle": "Dit seizoen verwijderen?",
"deleteShowTitle": "Deze serie verwijderen?",
"deleteMovieTitle": "Deze film verwijderen?",
"deleteEpisodeConfirm": "Aflevering verwijderen",
"deleteSeasonConfirm": "Seizoen verwijderen",
"deleteShowConfirm": "Serie verwijderen",
"deleteMovieConfirm": "Film verwijderen",
"deleteAnyway": "Toch verwijderen",
"confirmDeleteTarget": "${title} definitief van je server verwijderen?",
"deleteMultipleWarning": "Dit omvat alle afleveringen en hun bestanden.",
"deleteEpisodeCountWarning": {
"one": "Hiermee wordt de ${n} aflevering erin verwijderd, inclusief het bestand.",
"other": "Hiermee worden alle ${n} afleveringen erin verwijderd, inclusief hun bestanden."
},
"deleteMultiPartWarning": {
"one": "Dit item wordt opgeslagen als ${n} bestand, dat wordt verwijderd.",
"other": "Dit item wordt opgeslagen over ${n} bestanden, en ze worden allemaal verwijderd."
},
"deleteSharedFileHeading": {
"one": "${n} andere aflevering staat in hetzelfde bestand en wordt ook verwijderd:",
"other": "${n} andere afleveringen staan in hetzelfde bestand en worden ook verwijderd:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy kon niet controleren welke bestanden hiermee worden verwijderd, dus het kan meer verwijderen dan het hierboven genoemde item. Annuleer en probeer het opnieuw, of verwijder toch.",
"deleteScopeUnverifiedNoFileInfo": "Je server heeft geen bestandsgegevens voor dit item verstrekt, dus Plezy kan niet controleren welke bestanden hiermee worden verwijderd. Het kan meer verwijderen dan het hierboven genoemde item.",
"mediaDeletedSuccessfully": "Media-item succesvol verwijderd",
"mediaFailedToDelete": "Verwijderen van media-item mislukt",
"rate": "Beoordelen",
@@ -583,8 +606,9 @@
"playNext": "Volgende afspelen",
"playButton": "Afspelen",
"pauseButton": "Pauzeren",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Gepauzeerd",
"playbackResumed": "Afspelen",
"loadingVideo": "Video laden",
"showPlaybackControls": "Afspeelbediening tonen",
"hidePlaybackControls": "Afspeelbediening verbergen",
"seekBackwardButton": "${seconds} seconden terugspoelen",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Geen hoofdstukken beschikbaar",
"queue": "Wachtrij",
"noQueueItems": "Geen items in de wachtrij",
"noAudioDevicesAvailable": "Geen audioapparaten beschikbaar",
"searchSubtitles": "Ondertitels zoeken",
"language": "Taal",
"noSubtitlesFound": "Geen ondertitels gevonden",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Verwijderd uit 'Doorgaan met kijken'",
"errorLoading": "Fout: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Sommige mediaservers konden niet worden doorzocht. Beschikbare resultaten worden getoond.",
"streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.",
"liveStreamInterrupted": "De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.",
"fileInfoNotAvailable": "Bestandsinformatie niet beschikbaar",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Overschakelen naar compatibele speler...",
"serverLimitTitle": "Afspelen mislukt",
"serverLimitBody": "Serverfout (HTTP 500). Waarschijnlijk weigerde een bandbreedte-/transcodeerlimiet deze sessie. Vraag de eigenaar dit aan te passen.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Bestand niet beschikbaar",
"mediaUnreadableBody": "De server heeft dit item gevonden maar kon het bestand niet lezen (HTTP 404). Het bestand is waarschijnlijk verplaatst of verwijderd, of de opslag is offline. Vraag de serverbeheerder om het bestand te controleren en de bibliotheek opnieuw te scannen.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Logbestanden geüpload",
"logsUploadFailed": "Uploaden van logbestanden mislukt",
"logId": "Logboek-ID"
"logId": "Logboek-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
@@ -718,6 +746,8 @@
"overrideStrip": "Opmaak verwijderen",
"positionTop": "Bovenaan",
"positionBottom": "Onderaan",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Vet",
"italic": "Cursief",
"renderResolution": "Renderresolutie",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Geen verbindingen — voeg er één toe om dit profiel te gebruiken.",
"noConnections": "Geen verbindingen",
"plexHomeAccount": "Plex Home-account",
"plexAccountChip": "Plex-account: ${account}",
"plexAccountUserChip": "${user} via ${account}",
"connectionDefault": "Standaard",
"connectionAs": "als ${displayName}",
"makeDefault": "Als standaard instellen",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Sessie verlopen voor ${name}",
"sessionExpiredMany": "Sessie verlopen voor ${count} servers",
"signInAgain": "Opnieuw aanmelden",
"editJellyfinTitle": "Jellyfin-verbinding bewerken",
"editJellyfinIntro": "Voeg URL's voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie."
"editMediaBrowserTitle": "${product}-verbinding bewerken",
"editMediaBrowserIntro": "Voeg URL's toe aan ${serverName} of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie."
},
"discover": {
"title": "Ontdekken",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Zoeken mislukt: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Zoeken kon geen enkele mediaserver bereiken.",
"connectionTimeout": "Time-out van verbinding tijdens het laden van ${context}",
"connectionFailed": "Kan geen verbinding maken met mediaserver",
"unableToLoad": "Kan ${context} niet laden. Probeer het opnieuw.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Logbestanden uploaden"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy kon niet starten",
"failedBody": "Er is iets misgegaan tijdens het opstarten. De onderstaande details laten zien wat er misging.",
"failedBodyRepairable": "Het opgeslagen instellingenbestand van Plezy is beschadigd en moet opnieuw worden opgebouwd voordat Plezy kan starten. Opnieuw proberen helpt niet — kies 'Opslag repareren'.",
"phaseLabel": "Stap",
"showDetails": "Details tonen",
"hideDetails": "Details verbergen",
"copyDetails": "Details kopiëren",
"detailsCopied": "Details naar het klembord gekopieerd",
"uploadDetails": "Details uploaden",
"repairStorage": "Opslag repareren",
"repairTitle": "Opgeslagen gegevens repareren?",
"repairBodyCommon": "Het instellingenbestand van Plezy is beschadigd en kan niet worden gelezen. Door te repareren worden alle instellingen teruggezet naar hun standaardwaarde.",
"repairBodyOneCredential": "Eén opgeslagen aanmelding is beschadigd en kan niet worden gelezen. Door te repareren wordt alleen die ene verwijderd; je andere instellingen blijven onaangetast.",
"repairBodySignInsKept": "Je servers en profielen blijven naar verwachting aangemeld.",
"repairBodySignInsLost": "De sleutel die je opgeslagen aanmeldingen beschermt, kan niet uit dit bestand worden hersteld. Je moet je daarom opnieuw aanmelden bij elke server en elk profiel. Er wordt niets op je mediaserver gewijzigd.",
"repairBodySessionsUncertain": "Trackers (MAL, AniList, Simkl, Trakt) en Seerr worden apart opgeslagen en blijven mogelijk wel of niet bewaard. Plezy vertelt je precies wat het heeft bewaard.",
"repairConfirm": "Repareren",
"repairSucceeded": "Opslag gerepareerd",
"repairNeedsRestart": "Opslag gerepareerd — herstart vereist",
"restartRequiredBody": "Je gegevens zijn gerepareerd, maar Plezy moet opnieuw starten voordat het ze kan gebruiken. Sluit Plezy en open het opnieuw.",
"quitPlezy": "Plezy afsluiten",
"repairFailed": "Repareren mislukt",
"repairKeptSignIns": "Je servers en profielen zijn nog steeds aangemeld.",
"repairLostSignIns": "De sleutel die je opgeslagen aanmeldingen beschermt, kon niet worden hersteld. Je moet je opnieuw aanmelden bij elke server en elk profiel.",
"repairLostSessions": "Er is minstens één tracker- of Seerr-verbinding verloren gegaan en die moet opnieuw worden verbonden.",
"backupTitle": "Er is een kopie van het beschadigde bestand bewaard",
"backupWarning": "Het bevat je aanmeldgegevens. Upload of deel het niet.",
"deleteBackup": "Kopie verwijderen",
"backupDeleted": "Kopie verwijderd.",
"previousFailureTitle": "Plezy kon de vorige keer niet starten"
},
"licenses": {
"relatedPackages": "Gerelateerde pakketten",
@@ -1043,7 +1080,10 @@
"characters": "Personages",
"addToWatchlist": "Toevoegen aan kijklijst",
"removeFromWatchlist": "Verwijderen uit kijklijst",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Kon kijklijst niet bijwerken",
"watchlistNoMatch": "",
"notInLibrary": "Niet in je bibliotheek",
"inTheseLibraries": "In deze bibliotheken",
"checkingLibrary": "Je bibliotheek controleren...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Zoek naar films en series op ${source}.",
"searchFailed": "Zoeken mislukt. Controleer je verbinding en probeer opnieuw.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} populair",
"rankAiring": "#${n} nu op tv",
"rankRated": "#${n} beoordeeld",
"rankFavorited": "#${n} favoriet",
"rankTrending": "#${n} trending",
"rankSeasonal": "#${n} in ${season}",
"watchingNow": "${n} kijken",
"available": "Beschikbaar",
"partiallyAvailable": "Gedeeltelijk beschikbaar",
"availableIn4k": "4K beschikbaar",
"requested": "Aangevraagd",
"pendingApproval": "In afwachting van goedkeuring",
"processing": "Wordt verwerkt",
"declined": "Afgewezen",
"requestFailed": "Aanvraag mislukt",
"requested4k": "4K aangevraagd",
"seasonsAvailable": "${available}/${total} seizoenen",
"nextEpisodeIn": "Afl. ${episode} over ${duration}",
"nextAiringIn": "Volgende over ${duration}",
"episodesShort": "${n} afl.",
"minutesPerEpisode": "${n} min/afl.",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} op de lijst",
"viewersDay": "${n} vandaag bekeken",
"viewersWeek": "${n} deze week bekeken",
"viewersMonth": "${n} deze maand bekeken",
"viewersYear": "${n} dit jaar bekeken",
"viewersAllTime": "${n} kijkers",
"planning": "${n} van plan om te kijken",
"favorited": "${n} favorieten",
"dropRate": "${percent} is ermee gestopt",
"comments": {
"one": "",
"other": ""
"one": "${n} reactie",
"other": "${n} reacties"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} stemmen",
"watching": "${n} kijken ernaar",
"completed": "${n} afgerond",
"onHold": "${n} op pauze",
"dropped": "${n} gestopt"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Winter",
"spring": "Lente",
"summer": "Zomer",
"fall": "Herfst",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "Korte tv-serie",
"movie": "Film",
"special": "Special",
"ova": "OVA",
"ona": "ONA",
"music": "Muziek",
"other": "Anders"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Origineel",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Roman",
"visualNovel": "Visual novel",
"game": "Game",
"webComic": "Webcomic",
"musicRelease": "Muziek",
"otherMedia": "Anders"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Regisseur",
"writer": "Schrijver",
"producer": "Producent",
"creator": "Maker",
"composer": "Componist"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Side story",
"spinOff": "Spin-off",
"alternativeVersion": "Alternatieve versie",
"summary": "Samenvatting",
"parentStory": "Hoofdverhaal",
"adaptation": "Adaptatie",
"other": "Gerelateerd"
},
"broadcast": "Wordt uitgezonden op ${day} om ${time}",
"broadcastWithZone": "Wordt uitgezonden op ${day} om ${time} ${timezone}",
"detail": {
"originalTitle": "Originele titel",
"alsoKnownAs": "Ook bekend als",
"studios": "Studio's",
"country": "Land",
"language": "Taal",
"released": "Uitgebracht",
"physicalRelease": "Op schijf",
"ended": "Beëindigd",
"addedOn": "Toegevoegd op ${date}",
"yourRating": "Jouw beoordeling",
"budget": "Budget",
"revenue": "Box office",
"contentAdvisory": "Leeftijdsadvies",
"tags": "Tags",
"revealSpoilerTags": "Spoilertags tonen",
"links": "Links",
"watchOn": "Bekijk op",
"watchTrailer": "Trailer bekijken",
"openOn": "Openen op ${site}",
"crew": "Crew",
"ratings": "Beoordelingen",
"schedule": "Uitzendschema",
"recommendedByUsers": {
"one": "Aanbevolen door ${n} gebruiker",
"other": "Aanbevolen door ${n} gebruikers"
},
"recommendedBy": "Aanbevolen door ${who}",
"favoritedBy": "Favoriet bij ${who}",
"unairedEpisodes": "${n} nog niet uitgezonden",
"recommendedByPercent": "Aanbevolen door ${percent} van de kijkers",
"relatedTitles": "Gerelateerde titels",
"background": "Achtergrond"
},
"totalResults": {
"one": "${n} resultaat",
"other": "${n} resultaten"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Kanaal bekijken",
"favorites": "Favorieten",
"reorderFavorites": "Favorieten herordenen",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.",
"favoritesUpdateFailed": "Favorieten konden niet worden bijgewerkt. Controleer je verbinding en probeer het opnieuw.",
"joinSession": "Deelnemen aan lopende sessie",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Synchronisatieregel bewerken",
"removeSyncRule": "Synchronisatieregel verwijderen",
"removeSyncRuleConfirm": "Synchronisatie van \"${title}\" stoppen? Gedownloade afleveringen worden behouden.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Stoppen met synchroniseren van \"${title}\"?",
"deleteSyncRuleDownloads": "Gekoppelde downloads ook verwijderen",
"deleteSyncRuleDownloadsDescription": "Downloads die door een andere synchronisatieregel of een ander profiel worden gebruikt, blijven behouden.",
"syncRuleCreated": "Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden",
"syncRuleUpdated": "Synchronisatieregel bijgewerkt",
"syncRuleRemoved": "Synchronisatieregel verwijderd",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Synchronisatieregel en gekoppelde downloads verwijderd",
"syncRuleCleanupBusy": "Synchronisatieregels worden momenteel bijgewerkt. Probeer het over een moment opnieuw.",
"syncRuleCleanupUnavailable": "Gekoppelde downloads konden niet veilig worden geïdentificeerd. Maak opnieuw verbinding met de server en probeer het opnieuw, of verwijder de regel zonder de downloads te verwijderen.",
"syncedNewEpisodes": "${count} nieuwe afleveringen gesynchroniseerd voor ${title}",
"activeSyncRules": "Synchronisatieregels",
"noSyncRules": "Geen synchronisatieregels",
@@ -1614,9 +1644,15 @@
"audioSync": "Audiosynchronisatie",
"subtitleSync": "Ondertitelsynchronisatie",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay",
"audioPassthrough": "Audio-doorvoer",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Plezy activeren op ${service}",
"body": "Ga naar ${url} en voer deze code in:",
"instructions": "",
"openToActivate": "Open ${service} om te activeren",
"copyCode": "Activeringscode kopiëren",
"waitingForAuthorization": "Wachten op autorisatie…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Aanmelden bij ${service}",
"body": "Scan deze QR-code of open de URL op een apparaat.",
"openToSignIn": "${service} openen om aan te melden",
"openToSignIn": "${service} openen om aan te melden"
},
"pendingAuth": {
"copyUrl": "Aanmeldings-URL kopiëren",
"urlCopied": "URL gekopieerd"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Jellyfin-server toevoegen",
"addMediaBrowserTitle": "${product}-server toevoegen",
"serverUrls": "Server-URL's",
"serverUrlsHelper": "Meerdere URL's toegestaan, gescheiden door komma's.",
"findServer": "Server zoeken",
"searchingLocalServers": "Lokale Jellyfin-servers zoeken...",
"localServers": "Lokale Jellyfin-servers",
"searchingLocalMediaBrowserServers": "Zoeken naar lokale ${product}-servers...",
"localMediaBrowserServers": "Lokale ${product}-servers",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"signIn": "Inloggen",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Inloggen met Plex",
"pinExpired": "De pincode verliep voordat je kon inloggen. Probeer het opnieuw.",
"failedToRegisterAccount": "Account registreren mislukt: ${error}",
"enterJellyfinUrlError": "Voer de URL van je Jellyfin-server in",
"enterMediaBrowserUrlError": "Voer de URL van je ${product}-server in",
"addConnectionTitle": "Verbinding toevoegen",
"addConnectionTitleScoped": "Toevoegen aan ${name}",
"signInWithPlexCard": "Inloggen met Plex",
"signInWithPlexCardSubtitle": "Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.",
"signInWithPlexCardSubtitleScoped": "Autoriseer een Plex-account. Home-gebruikers worden profielen.",
"connectToJellyfinCard": "Verbinden met Jellyfin",
"connectToJellyfinCardSubtitle": "Voer je server-URL, gebruikersnaam en wachtwoord in.",
"connectToJellyfinCardSubtitleScoped": "Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.",
"connectToMediaBrowserCard": "Verbinding maken met ${product}",
"connectToMediaBrowserCardSubtitle": "Voer je server-URL, gebruikersnaam en wachtwoord in.",
"connectToMediaBrowserCardSubtitleScoped": "Meld je aan bij je ${product}-server. Wordt gekoppeld aan ${name}.",
"borrowFromAnotherProfile": "Van een ander profiel lenen",
"borrowFromAnotherProfileSubtitle": "Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist."
}
+350 -305
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.",
"useBrowser": "Użyj przeglądarki",
"or": "lub",
"connectToJellyfin": "Połącz z Jellyfin",
"connectToMediaBrowser": "Połącz z ${product}",
"useQuickConnect": "Użyj Quick Connect",
"quickConnectInstructions": "Otwórz Quick Connect w Jellyfin i wpisz ten kod.",
"quickConnectWaiting": "Oczekiwanie na zatwierdzenie…",
@@ -46,7 +46,7 @@
"resume": "Wznów",
"error": "Błąd",
"search": "Szukaj",
"home": "Strona główna",
"home": "Start",
"back": "Wstecz",
"settings": "Ustawienia",
"mute": "Wycisz",
@@ -64,7 +64,20 @@
"loading": "Ładowanie...",
"fullscreen": "Pełny ekran",
"exitFullscreen": "Wyjdź z pełnego ekranu",
"pressBackAgainToExit": "Naciśnij ponownie przycisk Wstecz, aby wyjść"
"pressBackAgainToExit": "Naciśnij ponownie przycisk Wstecz, aby wyjść",
"ratingSource": {
"critic": "Krytycy",
"audience": "Widzowie",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Krytycy Rotten Tomatoes",
"rottenTomatoesAudience": "Widzowie Rotten Tomatoes"
}
},
"screens": {
"licenses": "Licencje",
@@ -107,6 +120,7 @@
"darkTheme": "Ciemny",
"oledTheme": "OLED",
"libraryDensity": "Gęstość biblioteki",
"displayScale": "Skalowanie",
"compact": "Kompaktowy",
"comfortable": "Wygodny",
"tvCornerSpotlightBackdrop": "Tło wyróżnionej pozycji w rogu",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automatyczny (zalecany)",
"bufferSizeWarning": "Dostępna pamięć: ${heap}MB. Bufor ${size}MB może wpłynąć na odtwarzanie.",
"playbackBuffer": "Bufor odtwarzania",
"playbackBufferAuto": "Auto (zalecane)",
"playbackBufferLarge": "Duży",
"playbackBufferExtraLarge": "Bardzo duży",
"playbackBufferDescription": "Buforuje więcej w przypadku niestabilnych połączeń. Ograniczony również przez rozmiar bufora.",
"defaultQualityTitle": "Domyślna jakość",
"musicQualityTitle": "Jakość muzyki",
"subtitleStyling": "Styl napisów",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Włącz szczegółowe rejestrowanie, aby ułatwić rozwiązywanie problemów",
"viewLogs": "Pokaż logi",
"viewLogsDescription": "Pokaż logi aplikacji",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Wyczyść pamięć podręczną obrazów",
"clearImageCacheDescription": "Wyczyść zapisane okładki i miniatury. Obrazy mogą ładować się wolniej, dopóki nie zostaną pobrane ponownie.",
"clearImageCacheSuccess": "Pamięć podręczna obrazów została wyczyszczona",
"resetSettings": "Zresetuj ustawienia",
"resetSettingsDescription": "Przywróć ustawienia domyślne. Tego nie można cofnąć.",
"resetSettingsSuccess": "Przywrócono ustawienia domyślne",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normalizacja głośności przy miksowaniu",
"audioDownmixNormalizeDescription": "Obniża miks, aby zapobiec przesterowaniu. Wyłącz, aby zachować oryginalną głośność (głośne sceny mogą być zniekształcone).",
"atmosDiagnostics": "Test wyjścia Atmos",
"atmosDiagnosticsDescription": "Diagnozuj wyjście Dolby Atmos, odtwarzając sygnały testowe przez odtwarzacz systemowy",
"atmosTestHlsAtmos": "Strumień Atmos Apple",
"atmosTestHlsAtmosDescription": "Sprawdzony strumień Dolby Atmos. Amplituner powinien pokazać Dolby Atmos.",
"atmosTestHlsControl": "Strumień dźwięku przestrzennego Apple",
"atmosTestHlsControlDescription": "Strumień kontrolny bez Atmos. Amplituner powinien wskazywać dźwięk przestrzenny bez Atmos.",
"atmosTestRawStream": "Surowy strumień EAC3",
"atmosTestRawStreamDescription": "Przesyła strumieniowo plik testowy dokładnie tak jak podczas odtwarzania Atmos w odtwarzaczu. Wymaga adresu URL pliku testowego.",
"atmosTestRawFile": "Surowy plik EAC3",
"atmosTestRawFileDescription": "Odtwarza plik testowy o znanej długości. Wymaga URL pliku testowego.",
"atmosTestAsbarNative": "Renderer bufora próbek (natywny)",
"atmosTestAsbarNativeDescription": "Przekazuje nienaruszony skompresowany dźwięk pliku prosto do renderera systemu. Wymaga URL pliku testowego.",
"atmosTestAsbarGenerated": "Renderer bufora próbek (odtworzony)",
"atmosTestAsbarGeneratedDescription": "To samo, ale z opisem dźwięku budowanym tak jak przy odtwarzaniu. Wymaga URL pliku testowego.",
"atmosTestSessionMode": "Użyj trybu odtwarzania filmów",
"atmosTestSessionModeDescription": "Wyłączone używa trybu udokumentowanego przez Dolby. Włączone używa poprzedniego trybu.",
"atmosTestShowRoutePicker": "Wybierz wyjście AirPlay",
"atmosTestHideRoutePicker": "Ukryj wybór wyjścia AirPlay",
"atmosTestRoutePickerDescription": "Wysyła test do odbiornika AirPlay. Tylko AirPlay zgłasza ustalony tryb dźwięku.",
"atmosTestStop": "Zatrzymaj test",
"atmosTestUrl": "Adres URL pliku testowego",
"atmosTestUrlDescription": "Adres URL HTTP surowego pliku Dolby Atmos w formacie .ec3 (np. wyodrębnionego za pomocą ffmpeg)",
"atmosTestUrlMissing": "Najpierw ustaw adres URL pliku testowego",
"atmosTestStatus": "Stan",
"dvConversionMode": "Konwersja Dolby Vision",
"dvConversionModeDescription": "Wybierz, jak ExoPlayer obsługuje pliki Dolby Vision Profile 7.",
"dvConversionAuto": "Automatycznie",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Pokaż etykiety paska nawigacji",
"showNavBarLabelsDescription": "Wyświetl tekstowe etykiety pod ikonami paska nawigacji",
"startupSection": "Sekcja startowa",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Domyślnie ulubione kanały",
"liveTvDefaultFavoritesDescription": "Pokaż tylko ulubione kanały po otwarciu telewizji na żywo",
"display": "Ekran",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Informacje o pliku",
"overview": "",
"overview": "Przegląd",
"video": "Wideo",
"audio": "Audio",
"subtitles": "Napisy",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Obrazy osadzone",
"dataStreams": "Strumienie danych",
"lyrics": "Teksty",
"file": "Plik",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Załączniki",
"delivery": "Dostarczanie",
"versionCounter": "Wersja ${index} z ${count}",
"fileCounter": "Plik ${index} z ${count}",
"noStreams": "Serwer nie zgłosił żadnych strumieni dla tego pliku.",
"copyPath": "Kopiuj ścieżkę",
"pathCopied": "Skopiowano ścieżkę pliku",
"codec": "Kodek",
"codecTag": "",
"codecTag": "Tag kodeka",
"resolution": "Rozdzielczość",
"codedResolution": "",
"codedResolution": "Rozdzielczość kodowania",
"bitrate": "Przepływność",
"frameRate": "Klatki na sekundę",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotacja",
"comment": "Komentarz",
"audioDescription": "Audiodeskrypcja",
"headerCompression": "Kompresja nagłówka",
"sidecarFile": "Plik towarzyszący",
"transportTimestamp": "Znacznik czasu transportu",
"displayOffset": "Przesunięcie wyświetlania",
"previewFailureCode": "Kod błędu podglądu",
"previewRetries": "Ponowne próby podglądu",
"aspectRatio": "Proporcje",
"pixelAspectRatio": "",
"pixelAspectRatio": "Współczynnik proporcji pikseli",
"profile": "Profil",
"level": "",
"level": "Poziom",
"bitDepth": "Głębia bitowa",
"pixelFormat": "",
"pixelFormat": "Format pikseli",
"colorSpace": "Przestrzeń kolorów",
"colorRange": "Zakres kolorów",
"colorPrimaries": "Kolory podstawowe",
"colorTransfer": "",
"colorTransfer": "Przenoszenie kolorów",
"chromaSubsampling": "Podpróbkowanie chrominancji",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Położenie chrominancji",
"scanType": "Typ skanowania",
"interlaced": "Z przeplotem",
"anamorphic": "Anamorficzny",
"referenceFrames": "Klatki referencyjne",
"dynamicRange": "Zakres dynamiczny",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Poziom Dolby Vision",
"dolbyVisionVersion": "Wersja Dolby Vision",
"dolbyVisionLayers": "Warstwy Dolby Vision",
"baseLayerCompatibility": "Zgodność warstwy bazowej",
"avcBitstream": "Strumień bitowy AVC",
"nalLengthSize": "Rozmiar długości NAL",
"scalingMatrix": "Własna macierz skalowania",
"streamIdentifier": "Identyfikator strumienia",
"streamIndex": "Indeks strumienia",
"streamId": "ID strumienia",
"language": "Język",
"languageCode": "Kod języka",
"streamTitle": "Tytuł ścieżki",
"channels": "Kanały",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Częstotliwość próbkowania",
"spatialAudio": "Dźwięk przestrzenny",
"textBased": "Tekstowy",
"subtitleFormat": "Format pliku towarzyszącego",
"provider": "Dostawca",
"matchScore": "Wynik dopasowania",
"externalDelivery": "Może być dostarczany osobno",
"sidecarPath": "Ścieżka pliku towarzyszącego",
"sourceStream": "Skopiowano z",
"temporary": "Tymczasowy",
"timeBase": "Baza czasu",
"overallBitrate": "Całkowita przepływność",
"path": "Ścieżka",
"fileName": "",
"fileName": "Nazwa pliku",
"size": "Rozmiar",
"totalSize": "",
"totalSize": "Całkowity rozmiar",
"container": "Kontener",
"duration": "Czas trwania",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Miniatury podglądu",
"previewIndex": "Indeks podglądu",
"packetLength": "Długość pakietu",
"filePresent": "Plik obecny",
"fileReadable": "Czytelny dla serwera",
"streamPath": "Ścieżka strumienia",
"optimizedForStreaming": "Zoptymalizowane do strumieniowania",
"has64bitOffsets": "Przesunięcia 64-bitowe",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protokół",
"mediaType": "Typ multimediów",
"sourceKind": "Rodzaj źródła",
"optimizedVersion": "Zoptymalizowana wersja",
"optimizationTarget": "Cel optymalizacji",
"deletedAt": "Usunięto",
"remoteSource": "Źródło zdalne",
"infiniteStream": "Nieskończony strumień",
"directPlay": "Odtwarzanie bezpośrednie",
"directStream": "Bezpośrednie strumieniowanie",
"transcoding": "Transkodowanie",
"etag": "ETag",
"versionId": "ID wersji",
"fileId": "ID pliku",
"defaultAudioTrack": "Domyślna ścieżka audio",
"defaultSubtitleTrack": "Domyślna ścieżka napisów",
"subtitlesOff": "Wył.",
"flagDefault": "Domyślna",
"flagForced": "Wymuszone",
"flagSelected": "Wybrana",
"flagExternal": "Zewnętrzne",
"flagHearingImpaired": "Dla niesłyszących",
"flagDub": "Dubbing",
"flagOriginal": "Oryginalna"
},
"mediaMenu": {
"markAsWatched": "Oznacz jako obejrzane",
@@ -505,9 +502,41 @@
"shufflePlay": "Odtwarzanie losowe",
"shuffleNotAvailableOffline": "Odtwarzanie losowe nie jest dostępne offline",
"fileInfo": "Informacje o pliku",
"deleteFromServer": "Usuń z serwera",
"confirmDelete": "Usunąć to medium i jego pliki z serwera?",
"deleteEpisodeFromServer": "Usuń odcinek z serwera",
"deleteSeasonFromServer": "Usuń sezon z serwera",
"deleteShowFromServer": "Usuń serial z serwera",
"deleteMovieFromServer": "Usuń film z serwera",
"deleteEpisodeTitle": "Usunąć ten odcinek?",
"deleteSeasonTitle": "Usunąć ten sezon?",
"deleteShowTitle": "Usunąć ten serial?",
"deleteMovieTitle": "Usunąć ten film?",
"deleteEpisodeConfirm": "Usuń odcinek",
"deleteSeasonConfirm": "Usuń sezon",
"deleteShowConfirm": "Usuń serial",
"deleteMovieConfirm": "Usuń film",
"deleteAnyway": "Usuń mimo to",
"confirmDeleteTarget": "Trwale usunąć ${title} z Twojego serwera?",
"deleteMultipleWarning": "Obejmuje to wszystkie odcinki i ich pliki.",
"deleteEpisodeCountWarning": {
"one": "Usunięty zostanie ${n} odcinek w nim oraz jego plik.",
"few": "Usunięte zostaną ${n} odcinki w nim oraz ich pliki.",
"many": "Usuniętych zostanie ${n} odcinków w nim oraz ich pliki.",
"other": "Usuniętych zostanie ${n} odcinka w nim oraz ich pliki."
},
"deleteMultiPartWarning": {
"one": "Ten element jest przechowywany jako ${n} plik, który zostanie usunięty.",
"few": "Ten element jest przechowywany w ${n} plikach, które wszystkie zostaną usunięte.",
"many": "Ten element jest przechowywany w ${n} plikach, które wszystkie zostaną usunięte.",
"other": "Ten element jest przechowywany w ${n} pliku, który zostanie usunięty."
},
"deleteSharedFileHeading": {
"one": "W tym samym pliku przechowywany jest jeszcze ${n} inny odcinek, który również zostanie usunięty:",
"few": "W tym samym pliku przechowywane są jeszcze ${n} inne odcinki, które również zostaną usunięte:",
"many": "W tym samym pliku przechowywanych jest jeszcze ${n} innych odcinków, które również zostaną usunięte:",
"other": "W tym samym pliku przechowywany jest jeszcze ${n} innego odcinka, który również zostanie usunięty:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy nie mogło sprawdzić, które pliki zostaną usunięte, więc może usunąć więcej niż element wymieniony powyżej. Anuluj i spróbuj ponownie albo usuń mimo to.",
"deleteScopeUnverifiedNoFileInfo": "Twój serwer nie podał informacji o plikach tego elementu, więc Plezy nie może sprawdzić, które pliki zostaną usunięte. Może usunąć więcej niż element wymieniony powyżej.",
"mediaDeletedSuccessfully": "Usunięto element multimedialny",
"mediaFailedToDelete": "Nie udało się usunąć elementu multimedialnego",
"rate": "Oceń",
@@ -583,8 +612,9 @@
"playNext": "Odtwórz następny",
"playButton": "Odtwórz",
"pauseButton": "Pauza",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Wstrzymano",
"playbackResumed": "Odtwarzanie",
"loadingVideo": "Ładowanie wideo",
"showPlaybackControls": "Pokaż elementy sterujące odtwarzaniem",
"hidePlaybackControls": "Ukryj elementy sterujące odtwarzaniem",
"seekBackwardButton": "Przewiń do tyłu o ${seconds} sekund",
@@ -635,6 +665,7 @@
"noChaptersAvailable": "Brak dostępnych rozdziałów",
"queue": "Kolejka",
"noQueueItems": "Brak elementów w kolejce",
"noAudioDevicesAvailable": "Brak dostępnych urządzeń audio",
"searchSubtitles": "Szukaj napisów",
"language": "Język",
"noSubtitlesFound": "Nie znaleziono napisów",
@@ -658,7 +689,7 @@
},
"removedFromContinueWatching": "Usunięto z kontynuowania oglądania",
"errorLoading": "Błąd: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Niektórych serwerów multimediów nie udało się przeszukać. Wyświetlane są dostępne wyniki.",
"streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.",
"liveStreamInterrupted": "Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.",
"fileInfoNotAvailable": "Informacje o pliku niedostępne",
@@ -697,11 +728,14 @@
"switchingToCompatiblePlayer": "Przełączanie na kompatybilny odtwarzacz...",
"serverLimitTitle": "Odtwarzanie nie powiodło się",
"serverLimitBody": "Błąd serwera (HTTP 500). Limit przepustowości/transkodowania prawdopodobnie odrzucił tę sesję. Poproś właściciela o zmianę.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Plik niedostępny",
"mediaUnreadableBody": "Serwer znalazł ten element, ale nie mógł odczytać jego pliku (HTTP 404). Plik został prawdopodobnie przeniesiony, usunięty albo jego pamięć masowa jest niedostępna. Poproś właściciela serwera o sprawdzenie pliku i ponowne przeskanowanie biblioteki.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Logi przesłane",
"logsUploadFailed": "Nie udało się przesłać logów",
"logId": "ID logu"
"logId": "ID logu",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
@@ -720,6 +754,8 @@
"overrideStrip": "Usuń style",
"positionTop": "Góra",
"positionBottom": "Dół",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Pogrubienie",
"italic": "Kursywa",
"renderResolution": "Rozdzielczość renderowania",
@@ -782,6 +818,8 @@
"noConnectionsHint": "Brak połączeń — dodaj jedno, aby używać tego profilu.",
"noConnections": "Brak połączeń",
"plexHomeAccount": "Konto Plex Home",
"plexAccountChip": "Konto Plex: ${account}",
"plexAccountUserChip": "${user} przez ${account}",
"connectionDefault": "Domyślne",
"connectionAs": "jako ${displayName}",
"makeDefault": "Ustaw jako domyślne",
@@ -815,8 +853,8 @@
"sessionExpiredOne": "Sesja wygasła dla ${name}",
"sessionExpiredMany": "Sesja wygasła dla ${count} serwerów",
"signInAgain": "Zaloguj się ponownie",
"editJellyfinTitle": "Edytuj połączenie Jellyfin",
"editJellyfinIntro": "Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu."
"editMediaBrowserTitle": "Edytuj połączenie z ${product}",
"editMediaBrowserIntro": "Dodaj lub usuń adresy URL dla ${serverName}. Plezy będzie używać osiągalnego adresu URL o najniższym opóźnieniu."
},
"discover": {
"title": "Odkryj",
@@ -846,7 +884,7 @@
},
"errors": {
"searchFailed": "Wyszukiwanie nie powiodło się: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Wyszukiwanie nie mogło połączyć się z żadnym serwerem multimediów.",
"connectionTimeout": "Limit czasu połączenia przy ładowaniu ${context}",
"connectionFailed": "Nie można połączyć się z serwerem multimediów",
"unableToLoad": "Nie udało się załadować ${context}. Spróbuj ponownie.",
@@ -974,31 +1012,36 @@
"uploadLogs": "Prześlij logi"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Nie udało się uruchomić Plezy",
"failedBody": "Coś poszło nie tak podczas uruchamiania. Poniższe szczegóły wskazują, co zawiodło.",
"failedBodyRepairable": "Zapisany plik ustawień Plezy jest uszkodzony i musi zostać odbudowany, zanim Plezy będzie mogło się uruchomić. Ponawianie nie pomoże — wybierz Napraw pamięć.",
"phaseLabel": "Krok",
"showDetails": "Pokaż szczegóły",
"hideDetails": "Ukryj szczegóły",
"copyDetails": "Kopiuj szczegóły",
"detailsCopied": "Skopiowano szczegóły do schowka",
"uploadDetails": "Prześlij szczegóły",
"repairStorage": "Napraw pamięć",
"repairTitle": "Naprawić zapisane dane?",
"repairBodyCommon": "Plik ustawień Plezy jest uszkodzony i nie można go odczytać. Naprawa przywraca wszystkie ustawienia do wartości domyślnych.",
"repairBodyOneCredential": "Jedno zapisane logowanie jest uszkodzone i nie można go odczytać. Naprawa usunie tylko je; pozostałe ustawienia pozostaną nietknięte.",
"repairBodySignInsKept": "Twoje serwery i profile powinny pozostać zalogowane.",
"repairBodySignInsLost": "Klucza chroniącego zapisane logowania nie można odzyskać z tego pliku, więc będziesz musiał zalogować się ponownie na każdym serwerze i profilu. Nic na Twoim serwerze multimediów nie zostanie zmienione.",
"repairBodySessionsUncertain": "Usługi śledzenia (MAL, AniList, Simkl, Trakt) i Seerr są przechowywane osobno i mogą, ale nie muszą przetrwać. Plezy dokładnie poinformuje Cię, co zachowało.",
"repairConfirm": "Napraw",
"repairSucceeded": "Naprawiono pamięć",
"repairNeedsRestart": "Naprawiono pamięć — wymagany restart",
"restartRequiredBody": "Twoje dane zostały naprawione, ale Plezy musi uruchomić się od nowa, zanim będzie mogło z nich korzystać. Zamknij Plezy i otwórz je ponownie.",
"quitPlezy": "Zamknij Plezy",
"repairFailed": "Naprawa nie powiodła się",
"repairKeptSignIns": "Twoje serwery i profile są nadal zalogowane.",
"repairLostSignIns": "Nie udało się odzyskać klucza chroniącego zapisane logowania. Musisz zalogować się ponownie na każdym serwerze i profilu.",
"repairLostSessions": "Utracono co najmniej jedno połączenie z usługą śledzenia lub Seerr i trzeba je ponownie połączyć.",
"backupTitle": "Zachowano kopię uszkodzonego pliku",
"backupWarning": "Zawiera Twoje dane logowania. Nie przesyłaj ani nie udostępniaj jej.",
"deleteBackup": "Usuń kopię",
"backupDeleted": "Usunięto kopię.",
"previousFailureTitle": "Ostatnim razem nie udało się uruchomić Plezy"
},
"licenses": {
"relatedPackages": "Powiązane pakiety",
@@ -1047,7 +1090,10 @@
"characters": "Postacie",
"addToWatchlist": "Dodaj do listy do obejrzenia",
"removeFromWatchlist": "Usuń z listy do obejrzenia",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Nie udało się zaktualizować listy do obejrzenia",
"watchlistNoMatch": "",
"notInLibrary": "Nie ma tego w Twojej bibliotece",
"inTheseLibraries": "W tych bibliotekach",
"checkingLibrary": "Sprawdzanie Twojej biblioteki...",
@@ -1058,153 +1104,140 @@
"searchPrompt": "Szukaj filmów i seriali w ${source}.",
"searchFailed": "Wyszukiwanie nie powiodło się. Sprawdź połączenie i spróbuj ponownie.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} popularne",
"rankAiring": "#${n} emitowane",
"rankRated": "#${n} najwyżej oceniane",
"rankFavorited": "#${n} w ulubionych",
"rankTrending": "#${n} na czasie",
"rankSeasonal": "#${n} w sezonie ${season}",
"watchingNow": "${n} ogląda teraz",
"available": "Dostępne",
"partiallyAvailable": "Częściowo dostępne",
"availableIn4k": "Dostępne w 4K",
"requested": "Zamówiono",
"pendingApproval": "Oczekuje na zatwierdzenie",
"processing": "Przetwarzanie",
"declined": "Odrzucono",
"requestFailed": "Żądanie nie powiodło się",
"requested4k": "Zamówiono w 4K",
"seasonsAvailable": "${available}/${total} sezonów",
"nextEpisodeIn": "Odc. ${episode} za ${duration}",
"nextAiringIn": "Następny za ${duration}",
"episodesShort": "${n} odc.",
"minutesPerEpisode": "${n} min/odc.",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} na listach",
"viewersDay": "${n} obejrzało dziś",
"viewersWeek": "${n} obejrzało w tym tygodniu",
"viewersMonth": "${n} obejrzało w tym miesiącu",
"viewersYear": "${n} obejrzało w tym roku",
"viewersAllTime": "${n} widzów",
"planning": "${n} planuje obejrzeć",
"favorited": "${n} w ulubionych",
"dropRate": "${percent} porzuciło",
"comments": {
"one": "",
"few": "",
"many": "",
"other": ""
"one": "${n} komentarz",
"few": "${n} komentarze",
"many": "${n} komentarzy",
"other": "${n} komentarza"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} głosów",
"watching": "${n} ogląda",
"completed": "${n} ukończyło",
"onHold": "${n} wstrzymało",
"dropped": "${n} porzuciło"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Zima",
"spring": "Wiosna",
"summer": "Lato",
"fall": "Jesień",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "TV Short",
"movie": "Film",
"special": "Odcinek specjalny",
"ova": "OVA",
"ona": "ONA",
"music": "Muzyka",
"other": "Inne"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Oryginał",
"manga": "Manga",
"lightNovel": "Light novel",
"novel": "Powieść",
"visualNovel": "Visual novel",
"game": "Gra",
"webComic": "Komiks internetowy",
"musicRelease": "Muzyka",
"otherMedia": "Inne"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"few": "",
"many": "",
"other": ""
"director": "Reżyser",
"writer": "Scenarzysta",
"producer": "Producent",
"creator": "Twórca",
"composer": "Kompozytor"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequel",
"sequel": "Sequel",
"sideStory": "Poboczna historia",
"spinOff": "Spin-off",
"alternativeVersion": "Wersja alternatywna",
"summary": "Streszczenie",
"parentStory": "Historia pierwotna",
"adaptation": "Adaptacja",
"other": "Powiązane"
},
"broadcast": "Emisja ${day} o ${time}",
"broadcastWithZone": "Emisja ${day} o ${time} ${timezone}",
"detail": {
"originalTitle": "Tytuł oryginalny",
"alsoKnownAs": "Znany również jako",
"studios": "Studia",
"country": "Kraj",
"language": "Język",
"released": "Premiera",
"physicalRelease": "Na nośniku",
"ended": "Zakończony",
"addedOn": "Dodano ${date}",
"yourRating": "Twoja ocena",
"budget": "Budżet",
"revenue": "Box office",
"contentAdvisory": "Klasyfikacja wiekowa",
"tags": "Tagi",
"revealSpoilerTags": "Pokaż tagi zawierające spoilery",
"links": "Linki",
"watchOn": "Oglądaj na",
"watchTrailer": "Obejrzyj zwiastun",
"openOn": "Otwórz na ${site}",
"crew": "Obsada i ekipa",
"ratings": "Oceny",
"schedule": "Harmonogram",
"recommendedByUsers": {
"one": "Polecany przez ${n} użytkownika",
"few": "Polecany przez ${n} użytkowników",
"many": "Polecany przez ${n} użytkowników",
"other": "Polecany przez ${n} użytkownika"
},
"recommendedBy": "Polecany przez ${who}",
"favoritedBy": "Dodany do ulubionych przez ${who}",
"unairedEpisodes": "${n} jeszcze nie wyemitowano",
"recommendedByPercent": "Polecany przez ${percent} widzów",
"relatedTitles": "Powiązane tytuły",
"background": "Tło"
},
"totalResults": {
"one": "${n} wynik",
"few": "${n} wyniki",
"many": "${n} wyników",
"other": "${n} wyniku"
}
},
"liveTv": {
@@ -1236,6 +1269,9 @@
"watchChannel": "Oglądaj kanał",
"favorites": "Ulubione",
"reorderFavorites": "Zmień kolejność ulubionych",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.",
"favoritesUpdateFailed": "Nie udało się zaktualizować ulubionych. Sprawdź połączenie i spróbuj ponownie.",
"joinSession": "Dołącz do trwającej sesji",
@@ -1473,15 +1509,15 @@
"editSyncRule": "Edytuj regułę synchronizacji",
"removeSyncRule": "Usuń regułę synchronizacji",
"removeSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"? Pobrane odcinki zostaną zachowane.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"?",
"deleteSyncRuleDownloads": "Usuń również powiązane pobrania",
"deleteSyncRuleDownloadsDescription": "Pobrania używane przez inną regułę synchronizacji lub profil zostaną zachowane.",
"syncRuleCreated": "Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków",
"syncRuleUpdated": "Reguła synchronizacji zaktualizowana",
"syncRuleRemoved": "Reguła synchronizacji usunięta",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Usunięto regułę synchronizacji i powiązane pobrania",
"syncRuleCleanupBusy": "Reguły synchronizacji są aktualnie aktualizowane. Spróbuj ponownie za chwilę.",
"syncRuleCleanupUnavailable": "Nie udało się bezpiecznie zidentyfikować powiązanych pobrań. Połącz się ponownie z serwerem i spróbuj ponownie albo usuń regułę bez usuwania pobrań.",
"syncedNewEpisodes": "Zsynchronizowano ${count} nowych odcinków dla ${title}",
"activeSyncRules": "Reguły synchronizacji",
"noSyncRules": "Brak reguł synchronizacji",
@@ -1626,9 +1662,15 @@
"audioSync": "Synchronizacja audio",
"subtitleSync": "Synchronizacja napisów",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Wyjście audio",
"performanceOverlay": "Nakładka wydajności",
"audioPassthrough": "Przekazywanie dźwięku",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Przestrzenny",
@@ -1846,11 +1888,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Aktywuj Plezy w ${service}",
"body": "Odwiedź ${url} i wpisz ten kod:",
"instructions": "",
"openToActivate": "Otwórz ${service}, aby aktywować",
"copyCode": "Skopiuj kod aktywacyjny",
"waitingForAuthorization": "Oczekiwanie na autoryzację…",
@@ -1859,7 +1902,9 @@
"oauthProxy": {
"title": "Zaloguj się do ${service}",
"body": "Zeskanuj ten kod QR lub otwórz URL na dowolnym urządzeniu.",
"openToSignIn": "Otwórz ${service}, aby się zalogować",
"openToSignIn": "Otwórz ${service}, aby się zalogować"
},
"pendingAuth": {
"copyUrl": "Skopiuj adres URL logowania",
"urlCopied": "URL skopiowany"
},
@@ -1879,12 +1924,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Dodaj serwer Jellyfin",
"addMediaBrowserTitle": "Dodaj serwer ${product}",
"serverUrls": "Adresy URL serwera",
"serverUrlsHelper": "Można podać wiele adresów URL rozdzielonych przecinkami.",
"findServer": "Znajdź serwer",
"searchingLocalServers": "Szukanie lokalnych serwerów Jellyfin...",
"localServers": "Lokalne serwery Jellyfin",
"searchingLocalMediaBrowserServers": "Szukanie lokalnych serwerów ${product}...",
"localMediaBrowserServers": "Lokalne serwery ${product}",
"username": "Nazwa użytkownika",
"password": "Hasło",
"signIn": "Zaloguj się",
@@ -1896,15 +1941,15 @@
"addPlexTitle": "Zaloguj się przez Plex",
"pinExpired": "PIN wygasł przed zalogowaniem. Spróbuj ponownie.",
"failedToRegisterAccount": "Nie udało się zarejestrować konta: ${error}",
"enterJellyfinUrlError": "Podaj URL serwera Jellyfin",
"enterMediaBrowserUrlError": "Podaj adres URL serwera ${product}",
"addConnectionTitle": "Dodaj połączenie",
"addConnectionTitleScoped": "Dodaj do ${name}",
"signInWithPlexCard": "Zaloguj się przez Plex",
"signInWithPlexCardSubtitle": "Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.",
"signInWithPlexCardSubtitleScoped": "Autoryzuj konto Plex. Użytkownicy Home staną się profilami.",
"connectToJellyfinCard": "Połącz z Jellyfin",
"connectToJellyfinCardSubtitle": "Wpisz URL serwera, nazwę użytkownika i hasło.",
"connectToJellyfinCardSubtitleScoped": "Zaloguj się do serwera Jellyfin. Powiązane z ${name}.",
"connectToMediaBrowserCard": "Połącz z ${product}",
"connectToMediaBrowserCardSubtitle": "Podaj adres URL serwera, nazwę użytkownika i hasło.",
"connectToMediaBrowserCardSubtitleScoped": "Zaloguj się do serwera ${product}. Powiązane z ${name}.",
"borrowFromAnotherProfile": "Pożycz z innego profilu",
"borrowFromAnotherProfileSubtitle": "Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u."
}
+337 -298
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Aguardando autenticação...\nEntre pelo navegador.",
"useBrowser": "Usar navegador",
"or": "ou",
"connectToJellyfin": "Conectar ao Jellyfin",
"connectToMediaBrowser": "Conectar ao ${product}",
"useQuickConnect": "Usar Quick Connect",
"quickConnectInstructions": "Abra o Quick Connect no Jellyfin e insira este código.",
"quickConnectWaiting": "Aguardando aprovação…",
@@ -64,7 +64,20 @@
"loading": "Carregando...",
"fullscreen": "Tela cheia",
"exitFullscreen": "Sair da tela cheia",
"pressBackAgainToExit": "Pressione voltar novamente para sair"
"pressBackAgainToExit": "Pressione voltar novamente para sair",
"ratingSource": {
"critic": "Críticos",
"audience": "Público",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Críticos do Rotten Tomatoes",
"rottenTomatoesAudience": "Público do Rotten Tomatoes"
}
},
"screens": {
"licenses": "Licenças",
@@ -107,6 +120,7 @@
"darkTheme": "Escuro",
"oledTheme": "OLED",
"libraryDensity": "Densidade da Biblioteca",
"displayScale": "Escala de exibição",
"compact": "Compacto",
"comfortable": "Confortável",
"tvCornerSpotlightBackdrop": "Imagem de destaque no canto",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automático (Recomendado)",
"bufferSizeWarning": "${heap}MB de memória disponível. Um buffer de ${size}MB pode afetar a reprodução.",
"playbackBuffer": "Buffer de reprodução",
"playbackBufferAuto": "Automático (recomendado)",
"playbackBufferLarge": "Grande",
"playbackBufferExtraLarge": "Extra grande",
"playbackBufferDescription": "Faz buffer extra contra conexões instáveis. Também limitado pelo tamanho do buffer.",
"defaultQualityTitle": "Qualidade padrão",
"musicQualityTitle": "Qualidade da música",
"subtitleStyling": "Estilo de Legendas",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Ativar log detalhado para solução de problemas",
"viewLogs": "Ver Logs",
"viewLogsDescription": "Ver logs do app",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Limpar cache de imagens",
"clearImageCacheDescription": "Limpar artes e miniaturas em cache. As imagens podem carregar mais devagar até serem baixadas novamente.",
"clearImageCacheSuccess": "Cache de imagens limpo com sucesso",
"resetSettings": "Redefinir Configurações",
"resetSettingsDescription": "Restaurar configurações padrão. Não pode ser desfeito.",
"resetSettingsSuccess": "Configurações redefinidas com sucesso",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "dB",
"audioDownmixNormalize": "Normalizar volume na conversão para estéreo",
"audioDownmixNormalizeDescription": "Reduzir o volume da mixagem para evitar saturação. Desative para manter o volume original, que pode distorcer em cenas muito altas.",
"atmosDiagnostics": "Teste de saída Atmos",
"atmosDiagnosticsDescription": "Diagnosticar a saída Dolby Atmos reproduzindo sinais de teste pelo reprodutor do sistema",
"atmosTestHlsAtmos": "Transmissão Atmos da Apple",
"atmosTestHlsAtmosDescription": "Transmissão Dolby Atmos comprovadamente compatível. O receptor deve indicar Dolby Atmos.",
"atmosTestHlsControl": "Transmissão surround da Apple",
"atmosTestHlsControlDescription": "Transmissão de controle sem Atmos. O receptor deve indicar surround sem Atmos.",
"atmosTestRawStream": "Transmissão EAC3 bruta",
"atmosTestRawStreamDescription": "Transmite o arquivo de teste exatamente como na reprodução Atmos pelo reprodutor. Requer a URL do arquivo de teste.",
"atmosTestRawFile": "Arquivo EAC3 bruto",
"atmosTestRawFileDescription": "Reproduz o arquivo de teste com duração conhecida. Requer a URL do arquivo de teste.",
"atmosTestAsbarNative": "Renderizador de buffer de amostras (nativo)",
"atmosTestAsbarNativeDescription": "Envia o áudio comprimido intacto do ficheiro diretamente para o renderizador do sistema. Requer o URL do ficheiro de teste.",
"atmosTestAsbarGenerated": "Renderizador de buffer de amostras (reconstruído)",
"atmosTestAsbarGeneratedDescription": "O mesmo, mas com a descrição de áudio construída como na reprodução. Requer o URL do ficheiro de teste.",
"atmosTestSessionMode": "Usar modo de reprodução de filmes",
"atmosTestSessionModeDescription": "Desativado usa o modo documentado pela Dolby. Ativado usa o modo anterior.",
"atmosTestShowRoutePicker": "Escolher saída AirPlay",
"atmosTestHideRoutePicker": "Ocultar seletor de saída AirPlay",
"atmosTestRoutePickerDescription": "Envia o teste para um recetor AirPlay. Só o AirPlay comunica o modo de áudio resolvido.",
"atmosTestStop": "Parar teste",
"atmosTestUrl": "URL do arquivo de teste",
"atmosTestUrlDescription": "URL HTTP de um arquivo .ec3 Dolby Atmos bruto (ex.: extraído com ffmpeg)",
"atmosTestUrlMissing": "Defina primeiro a URL do arquivo de teste",
"atmosTestStatus": "Status",
"dvConversionMode": "Conversão Dolby Vision",
"dvConversionModeDescription": "Escolha como o ExoPlayer lida com arquivos Dolby Vision Profile 7.",
"dvConversionAuto": "Automático",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Mostrar Rótulos da Barra de Navegação",
"showNavBarLabelsDescription": "Exibir rótulos de texto sob os ícones da barra de navegação",
"startupSection": "Seção inicial",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Canais favoritos por padrão",
"liveTvDefaultFavoritesDescription": "Mostrar apenas os canais favoritos ao abrir a TV ao vivo",
"display": "Tela",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Informações do arquivo",
"overview": "",
"overview": "Visão geral",
"video": "Vídeo",
"audio": "Áudio",
"subtitles": "Legendas",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Imagens incorporadas",
"dataStreams": "Fluxos de dados",
"lyrics": "Letras",
"file": "Arquivo",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Anexos",
"delivery": "Entrega",
"versionCounter": "Versão ${index} de ${count}",
"fileCounter": "Arquivo ${index} de ${count}",
"noStreams": "O servidor não relatou fluxos para este arquivo.",
"copyPath": "Copiar caminho",
"pathCopied": "Caminho do arquivo copiado",
"codec": "Codec",
"codecTag": "",
"codecTag": "Tag do codec",
"resolution": "Resolução",
"codedResolution": "",
"codedResolution": "Resolução codificada",
"bitrate": "Taxa de bits",
"frameRate": "Taxa de Quadros",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Rotação",
"comment": "Comentário",
"audioDescription": "Descrição de áudio",
"headerCompression": "Compactação de cabeçalho",
"sidecarFile": "Arquivo sidecar",
"transportTimestamp": "Carimbo de transporte",
"displayOffset": "Deslocamento de exibição",
"previewFailureCode": "Código de falha da pré-visualização",
"previewRetries": "Tentativas da pré-visualização",
"aspectRatio": "Proporção",
"pixelAspectRatio": "",
"pixelAspectRatio": "Proporção de aspecto de pixel",
"profile": "Perfil",
"level": "",
"level": "Nível",
"bitDepth": "Profundidade de bits",
"pixelFormat": "",
"pixelFormat": "Formato de pixel",
"colorSpace": "Espaço de Cor",
"colorRange": "Faixa de Cor",
"colorPrimaries": "Primárias de Cor",
"colorTransfer": "",
"colorTransfer": "Transferência de cor",
"chromaSubsampling": "Subamostragem de Croma",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Localização do croma",
"scanType": "Tipo de varredura",
"interlaced": "Entrelaçado",
"anamorphic": "Anamórfico",
"referenceFrames": "Quadros de referência",
"dynamicRange": "Faixa dinâmica",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Nível Dolby Vision",
"dolbyVisionVersion": "Versão Dolby Vision",
"dolbyVisionLayers": "Camadas Dolby Vision",
"baseLayerCompatibility": "Compatibilidade da camada base",
"avcBitstream": "Bitstream AVC",
"nalLengthSize": "Tamanho do comprimento NAL",
"scalingMatrix": "Matriz de dimensionamento personalizada",
"streamIdentifier": "Identificador do fluxo",
"streamIndex": "Índice do fluxo",
"streamId": "ID do fluxo",
"language": "Idioma",
"languageCode": "Código do idioma",
"streamTitle": "Título da faixa",
"channels": "Canais",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Taxa de amostragem",
"spatialAudio": "Áudio espacial",
"textBased": "Baseado em texto",
"subtitleFormat": "Formato sidecar",
"provider": "Provedor",
"matchScore": "Pontuação de correspondência",
"externalDelivery": "Pode ser servido separadamente",
"sidecarPath": "Caminho do sidecar",
"sourceStream": "Copiado de",
"temporary": "Temporário",
"timeBase": "Base de tempo",
"overallBitrate": "Taxa de bits total",
"path": "Caminho",
"fileName": "",
"fileName": "Nome do arquivo",
"size": "Tamanho",
"totalSize": "",
"totalSize": "Tamanho total",
"container": "Contêiner",
"duration": "Duração",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Miniaturas de pré-visualização",
"previewIndex": "Índice de pré-visualização",
"packetLength": "Tamanho do pacote",
"filePresent": "Arquivo presente",
"fileReadable": "Legível pelo servidor",
"streamPath": "Caminho do fluxo",
"optimizedForStreaming": "Otimizado para transmissão",
"has64bitOffsets": "Deslocamentos de 64 bits",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Protocolo",
"mediaType": "Tipo de mídia",
"sourceKind": "Tipo de origem",
"optimizedVersion": "Versão otimizada",
"optimizationTarget": "Alvo de otimização",
"deletedAt": "Excluído",
"remoteSource": "Origem remota",
"infiniteStream": "Fluxo infinito",
"directPlay": "Reprodução direta",
"directStream": "Fluxo direto",
"transcoding": "Transcodificação",
"etag": "ETag",
"versionId": "ID da versão",
"fileId": "ID do arquivo",
"defaultAudioTrack": "Faixa de áudio padrão",
"defaultSubtitleTrack": "Faixa de legenda padrão",
"subtitlesOff": "Desativadas",
"flagDefault": "Padrão",
"flagForced": "Forçada",
"flagSelected": "Selecionada",
"flagExternal": "Externa",
"flagHearingImpaired": "Deficiência auditiva",
"flagDub": "Dublada",
"flagOriginal": "Original"
},
"mediaMenu": {
"markAsWatched": "Marcar como Assistido",
@@ -505,9 +502,35 @@
"shufflePlay": "Reprodução Aleatória",
"shuffleNotAvailableOffline": "Reprodução aleatória indisponível offline",
"fileInfo": "Informações do arquivo",
"deleteFromServer": "Excluir do servidor",
"confirmDelete": "Excluir esta mídia e seus arquivos do servidor?",
"deleteEpisodeFromServer": "Excluir episódio do servidor",
"deleteSeasonFromServer": "Excluir temporada do servidor",
"deleteShowFromServer": "Excluir série do servidor",
"deleteMovieFromServer": "Excluir filme do servidor",
"deleteEpisodeTitle": "Excluir este episódio?",
"deleteSeasonTitle": "Excluir esta temporada?",
"deleteShowTitle": "Excluir esta série?",
"deleteMovieTitle": "Excluir este filme?",
"deleteEpisodeConfirm": "Excluir episódio",
"deleteSeasonConfirm": "Excluir temporada",
"deleteShowConfirm": "Excluir série",
"deleteMovieConfirm": "Excluir filme",
"deleteAnyway": "Excluir mesmo assim",
"confirmDeleteTarget": "Excluir permanentemente ${title} do seu servidor?",
"deleteMultipleWarning": "Isso inclui todos os episódios e seus arquivos.",
"deleteEpisodeCountWarning": {
"one": "Isso exclui o ${n} episódio e o arquivo dele.",
"other": "Isso exclui todos os ${n} episódios e os arquivos deles."
},
"deleteMultiPartWarning": {
"one": "Este item está armazenado como ${n} arquivo, que será excluído.",
"other": "Este item está armazenado em ${n} arquivos, e todos eles serão excluídos."
},
"deleteSharedFileHeading": {
"one": "Mais ${n} episódio está armazenado no mesmo arquivo e também será excluído:",
"other": "Mais ${n} episódios estão armazenados no mesmo arquivo e também serão excluídos:"
},
"deleteScopeUnverifiedProbeFailed": "O Plezy não conseguiu verificar quais arquivos serão removidos, então pode excluir mais do que o item acima. Cancele e tente novamente, ou exclua mesmo assim.",
"deleteScopeUnverifiedNoFileInfo": "Seu servidor não forneceu detalhes do arquivo para este item, então o Plezy não pode verificar quais arquivos serão removidos. Pode excluir mais do que o item acima.",
"mediaDeletedSuccessfully": "Item de mídia excluído com sucesso",
"mediaFailedToDelete": "Falha ao excluir item de mídia",
"rate": "Avaliar",
@@ -583,8 +606,9 @@
"playNext": "Reproduzir Próximo",
"playButton": "Reproduzir",
"pauseButton": "Pausar",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "Pausado",
"playbackResumed": "Reproduzindo",
"loadingVideo": "Carregando vídeo",
"showPlaybackControls": "Mostrar controles de reprodução",
"hidePlaybackControls": "Ocultar controles de reprodução",
"seekBackwardButton": "Retroceder ${seconds} segundos",
@@ -635,6 +659,7 @@
"noChaptersAvailable": "Nenhum capítulo disponível",
"queue": "Fila",
"noQueueItems": "Nenhum item na fila",
"noAudioDevicesAvailable": "Nenhum dispositivo de áudio disponível",
"searchSubtitles": "Pesquisar legendas",
"language": "Idioma",
"noSubtitlesFound": "Nenhuma legenda encontrada",
@@ -656,7 +681,7 @@
},
"removedFromContinueWatching": "Removido de Continuar assistindo",
"errorLoading": "Erro: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Alguns servidores de mídia não puderam ser pesquisados. Exibindo os resultados disponíveis.",
"streamInterrupted": "A transmissão foi interrompida. Pressione reproduzir ou avance para tentar novamente.",
"liveStreamInterrupted": "A transmissão ao vivo foi interrompida. Pressione reproduzir para tentar novamente.",
"fileInfoNotAvailable": "Informações do arquivo não disponíveis",
@@ -695,11 +720,14 @@
"switchingToCompatiblePlayer": "Alternando para um reprodutor compatível...",
"serverLimitTitle": "Falha na reprodução",
"serverLimitBody": "Erro do servidor (HTTP 500). Um limite de largura de banda ou transcodificação provavelmente rejeitou esta sessão. Peça ao proprietário do servidor para ajustá-lo.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Arquivo indisponível",
"mediaUnreadableBody": "O servidor encontrou este item, mas não conseguiu ler o arquivo (HTTP 404). O arquivo provavelmente foi movido, excluído ou o armazenamento está offline. Peça ao responsável pelo servidor para verificar o arquivo e reexaminar a biblioteca.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Logs enviados",
"logsUploadFailed": "Falha ao enviar logs",
"logId": "ID do log"
"logId": "ID do log",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Texto",
@@ -718,6 +746,8 @@
"overrideStrip": "Remover estilo",
"positionTop": "Superior",
"positionBottom": "Inferior",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Negrito",
"italic": "Itálico",
"renderResolution": "Resolução de renderização",
@@ -780,6 +810,8 @@
"noConnectionsHint": "Sem conexões — adicione uma para usar este perfil.",
"noConnections": "Sem conexões",
"plexHomeAccount": "Conta Plex Home",
"plexAccountChip": "Conta Plex: ${account}",
"plexAccountUserChip": "${user} via ${account}",
"connectionDefault": "Padrão",
"connectionAs": "como ${displayName}",
"makeDefault": "Definir como padrão",
@@ -813,8 +845,8 @@
"sessionExpiredOne": "Sessão de ${name} expirada",
"sessionExpiredMany": "Sessões expiradas em ${count} servidores",
"signInAgain": "Entrar novamente",
"editJellyfinTitle": "Editar conexão Jellyfin",
"editJellyfinIntro": "Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência."
"editMediaBrowserTitle": "Editar conexão do ${product}",
"editMediaBrowserIntro": "Adicione ou remova URLs para ${serverName}. O Plezy usará a URL acessível com menor latência."
},
"discover": {
"title": "Descobrir",
@@ -844,7 +876,7 @@
},
"errors": {
"searchFailed": "Falha na busca: ${error}",
"searchUnavailable": "",
"searchUnavailable": "A busca não conseguiu alcançar nenhum servidor de mídia.",
"connectionTimeout": "Tempo de conexão esgotado ao carregar ${context}",
"connectionFailed": "Não foi possível conectar ao servidor de mídia",
"unableToLoad": "Não foi possível carregar ${context}. Tente novamente.",
@@ -972,31 +1004,36 @@
"uploadLogs": "Enviar Logs"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "O Plezy não conseguiu iniciar",
"failedBody": "Algo deu errado durante a inicialização. Os detalhes abaixo identificam o que falhou.",
"failedBodyRepairable": "O arquivo de configurações salvo do Plezy está danificado e precisa ser reconstruído antes que o Plezy possa iniciar. Tentar novamente não vai ajudar — escolha Reparar armazenamento.",
"phaseLabel": "Etapa",
"showDetails": "Mostrar detalhes",
"hideDetails": "Ocultar detalhes",
"copyDetails": "Copiar detalhes",
"detailsCopied": "Detalhes copiados para a área de transferência",
"uploadDetails": "Enviar detalhes",
"repairStorage": "Reparar armazenamento",
"repairTitle": "Reparar dados armazenados?",
"repairBodyCommon": "O arquivo de configurações do Plezy está danificado e não pode ser lido. Reparar redefine todas as configurações para o padrão.",
"repairBodyOneCredential": "Um login salvo está danificado e não pode ser lido. Reparar remove apenas ele; suas outras configurações permanecem intactas.",
"repairBodySignInsKept": "Seus servidores e perfis devem permanecer conectados.",
"repairBodySignInsLost": "A chave que protege seus logins salvos não pode ser recuperada deste arquivo, então você terá que entrar novamente em todos os servidores e perfis. Nada no seu servidor de mídia é afetado.",
"repairBodySessionsUncertain": "Os rastreadores (MAL, AniList, Simkl, Trakt) e o Seerr são armazenados separadamente e podem ou não ser preservados. O Plezy informará exatamente o que foi mantido.",
"repairConfirm": "Reparar",
"repairSucceeded": "Armazenamento reparado",
"repairNeedsRestart": "Armazenamento reparado — reinício necessário",
"restartRequiredBody": "Seus dados foram reparados, mas o Plezy precisa iniciar do zero antes de usá-los. Feche o Plezy e abra-o novamente.",
"quitPlezy": "Sair do Plezy",
"repairFailed": "Falha no reparo",
"repairKeptSignIns": "Seus servidores e perfis ainda estão conectados.",
"repairLostSignIns": "A chave que protege seus logins salvos não pôde ser recuperada. Você terá que entrar novamente em todos os servidores e perfis.",
"repairLostSessions": "Pelo menos uma conexão de rastreador ou do Seerr foi perdida e precisa ser reconectada.",
"backupTitle": "Uma cópia do arquivo danificado foi mantida",
"backupWarning": "Ele contém suas credenciais de login. Não envie nem compartilhe.",
"deleteBackup": "Excluir cópia",
"backupDeleted": "Cópia excluída.",
"previousFailureTitle": "O Plezy falhou ao iniciar da última vez"
},
"licenses": {
"relatedPackages": "Pacotes Relacionados",
@@ -1043,7 +1080,10 @@
"characters": "Personagens",
"addToWatchlist": "Adicionar à lista para assistir",
"removeFromWatchlist": "Remover da lista para assistir",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Não foi possível atualizar a lista para assistir",
"watchlistNoMatch": "",
"notInLibrary": "Não está na sua biblioteca",
"inTheseLibraries": "Nestas bibliotecas",
"checkingLibrary": "Verificando sua biblioteca...",
@@ -1054,147 +1094,134 @@
"searchPrompt": "Busque filmes e séries em ${source}.",
"searchFailed": "Falha na busca. Verifique sua conexão e tente novamente.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} popular",
"rankAiring": "#${n} no ar",
"rankRated": "#${n} avaliado",
"rankFavorited": "#${n} favoritado",
"rankTrending": "#${n} em alta",
"rankSeasonal": "#${n} em ${season}",
"watchingNow": "${n} assistindo",
"available": "Disponível",
"partiallyAvailable": "Parcialmente disponível",
"availableIn4k": "4K disponível",
"requested": "Solicitado",
"pendingApproval": "Aguardando aprovação",
"processing": "Processando",
"declined": "Recusado",
"requestFailed": "Falha na solicitação",
"requested4k": "4K solicitado",
"seasonsAvailable": "${available}/${total} temporadas",
"nextEpisodeIn": "Ep ${episode} em ${duration}",
"nextAiringIn": "Próximo em ${duration}",
"episodesShort": "${n} epps",
"minutesPerEpisode": "${n} min/ep",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} listados",
"viewersDay": "${n} assistiram hoje",
"viewersWeek": "${n} assistiram esta semana",
"viewersMonth": "${n} assistiram este mês",
"viewersYear": "${n} assistiram este ano",
"viewersAllTime": "${n} espectadores",
"planning": "${n} pretendem assistir",
"favorited": "${n} favoritos",
"dropRate": "${percent} abandonaram",
"comments": {
"one": "",
"other": ""
"one": "${n} comentário",
"other": "${n} comentários"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} votos",
"watching": "${n} assistindo",
"completed": "${n} concluíram",
"onHold": "${n} em pausa",
"dropped": "${n} abandonaram"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Inverno",
"spring": "Primavera",
"summer": "Verão",
"fall": "Outono",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "TV",
"tvShort": "Curta de TV",
"movie": "Filme",
"special": "Especial",
"ova": "OVA",
"ona": "ONA",
"music": "Música",
"other": "Outro"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Original",
"manga": "Mangá",
"lightNovel": "Light novel",
"novel": "Romance",
"visualNovel": "Visual novel",
"game": "Jogo",
"webComic": "Webcomic",
"musicRelease": "Música",
"otherMedia": "Outro"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"other": ""
"director": "Diretor",
"writer": "Roteirista",
"producer": "Produtor",
"creator": "Criador",
"composer": "Compositor"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Prequela",
"sequel": "Sequela",
"sideStory": "História paralela",
"spinOff": "Spin-off",
"alternativeVersion": "Versão alternativa",
"summary": "Resumo",
"parentStory": "História principal",
"adaptation": "Adaptação",
"other": "Relacionado"
},
"broadcast": "Vai ao ar ${day} às ${time}",
"broadcastWithZone": "Vai ao ar ${day} às ${time} ${timezone}",
"detail": {
"originalTitle": "Título original",
"alsoKnownAs": "Também conhecido como",
"studios": "Estúdios",
"country": "País",
"language": "Idioma",
"released": "Lançado",
"physicalRelease": "Em disco",
"ended": "Encerrado",
"addedOn": "Adicionado em ${date}",
"yourRating": "Sua avaliação",
"budget": "Orçamento",
"revenue": "Bilheteria",
"contentAdvisory": "Classificação indicativa",
"tags": "Tags",
"revealSpoilerTags": "Mostrar tags de spoiler",
"links": "Links",
"watchOn": "Assistir em",
"watchTrailer": "Ver trailer",
"openOn": "Abrir em ${site}",
"crew": "Equipe",
"ratings": "Avaliações",
"schedule": "Programação",
"recommendedByUsers": {
"one": "Recomendado por ${n} usuário",
"other": "Recomendado por ${n} usuários"
},
"recommendedBy": "Recomendado por ${who}",
"favoritedBy": "Favoritado por ${who}",
"unairedEpisodes": "${n} ainda não exibidos",
"recommendedByPercent": "Recomendado por ${percent} dos espectadores",
"relatedTitles": "Títulos relacionados",
"background": "Contexto"
},
"totalResults": {
"one": "${n} resultado",
"other": "${n} resultados"
}
},
"liveTv": {
@@ -1226,6 +1253,9 @@
"watchChannel": "Assistir ao canal",
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.",
"favoritesUpdateFailed": "Não foi possível atualizar os favoritos. Verifique sua conexão e tente novamente.",
"joinSession": "Entrar na sessão em andamento",
@@ -1461,15 +1491,15 @@
"editSyncRule": "Editar regra de sincronização",
"removeSyncRule": "Remover regra de sincronização",
"removeSyncRuleConfirm": "Parar de sincronizar \"${title}\"? Os episódios baixados serão mantidos.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Parar a sincronização de \"${title}\"?",
"deleteSyncRuleDownloads": "Também excluir downloads associados",
"deleteSyncRuleDownloadsDescription": "Downloads usados por outra regra de sincronização ou perfil serão mantidos.",
"syncRuleCreated": "Regra de sincronização criada — mantendo ${count} episódios não assistidos",
"syncRuleUpdated": "Regra de sincronização atualizada",
"syncRuleRemoved": "Regra de sincronização removida",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Regra de sincronização e downloads associados removidos",
"syncRuleCleanupBusy": "As regras de sincronização estão sendo atualizadas. Tente novamente em instantes.",
"syncRuleCleanupUnavailable": "Os downloads associados não puderam ser identificados com segurança. Reconecte o servidor e tente novamente, ou remova a regra sem excluir os downloads.",
"syncedNewEpisodes": "${count} novos episódios sincronizados para ${title}",
"activeSyncRules": "Regras de sincronização",
"noSyncRules": "Nenhuma regra de sincronização",
@@ -1614,9 +1644,15 @@
"audioSync": "Sincronia de áudio",
"subtitleSync": "Sincronia de legendas",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Saída de áudio",
"performanceOverlay": "Painel de desempenho",
"audioPassthrough": "Passagem direta de áudio",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
@@ -1834,11 +1870,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Ativar o Plezy no ${service}",
"body": "Acesse ${url} e insira este código:",
"instructions": "",
"openToActivate": "Abrir ${service} para ativar",
"copyCode": "Copiar código de ativação",
"waitingForAuthorization": "Aguardando autorização…",
@@ -1847,7 +1884,9 @@
"oauthProxy": {
"title": "Entrar no ${service}",
"body": "Leia este código QR ou abra a URL em qualquer dispositivo.",
"openToSignIn": "Abrir ${service} para entrar",
"openToSignIn": "Abrir ${service} para entrar"
},
"pendingAuth": {
"copyUrl": "Copiar URL de acesso",
"urlCopied": "URL copiada"
},
@@ -1867,12 +1906,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Adicionar servidor Jellyfin",
"addMediaBrowserTitle": "Adicionar servidor ${product}",
"serverUrls": "URLs do servidor",
"serverUrlsHelper": "Várias URLs são permitidas, separadas por vírgulas.",
"findServer": "Encontrar servidor",
"searchingLocalServers": "Procurando servidores Jellyfin locais...",
"localServers": "Servidores Jellyfin locais",
"searchingLocalMediaBrowserServers": "Procurando servidores ${product} locais...",
"localMediaBrowserServers": "Servidores ${product} locais",
"username": "Usuário",
"password": "Senha",
"signIn": "Entrar",
@@ -1884,15 +1923,15 @@
"addPlexTitle": "Entrar com Plex",
"pinExpired": "O PIN expirou antes de entrar. Tente novamente.",
"failedToRegisterAccount": "Falha ao registrar a conta: ${error}",
"enterJellyfinUrlError": "Insira a URL do seu servidor Jellyfin",
"enterMediaBrowserUrlError": "Digite a URL do seu servidor ${product}",
"addConnectionTitle": "Adicionar conexão",
"addConnectionTitleScoped": "Adicionar a ${name}",
"signInWithPlexCard": "Entrar com Plex",
"signInWithPlexCardSubtitle": "Autorize este dispositivo. Servidores compartilhados são adicionados.",
"signInWithPlexCardSubtitleScoped": "Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.",
"connectToJellyfinCard": "Conectar ao Jellyfin",
"connectToJellyfinCardSubtitle": "Insira URL do servidor, usuário e senha.",
"connectToJellyfinCardSubtitleScoped": "Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.",
"connectToMediaBrowserCard": "Conectar ao ${product}",
"connectToMediaBrowserCardSubtitle": "Digite a URL do servidor, o nome de usuário e a senha.",
"connectToMediaBrowserCardSubtitleScoped": "Entre no seu servidor ${product}. Vinculado a ${name}.",
"borrowFromAnotherProfile": "Pegar emprestado de outro perfil",
"borrowFromAnotherProfileSubtitle": "Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN."
}
+350 -305
View File
@@ -11,7 +11,7 @@
"waitingForAuth": "Ожидание аутентификации...\nВыполните вход в браузере.",
"useBrowser": "Использовать браузер",
"or": "или",
"connectToJellyfin": "Подключиться к Jellyfin",
"connectToMediaBrowser": "Подключиться к ${product}",
"useQuickConnect": "Использовать Quick Connect",
"quickConnectInstructions": "Откройте Quick Connect в Jellyfin и введите этот код.",
"quickConnectWaiting": "Ожидание подтверждения…",
@@ -64,7 +64,20 @@
"loading": "Загрузка...",
"fullscreen": "Полноэкранный режим",
"exitFullscreen": "Выйти из полноэкранного режима",
"pressBackAgainToExit": "Нажмите ещё раз для выхода"
"pressBackAgainToExit": "Нажмите ещё раз для выхода",
"ratingSource": {
"critic": "Критики",
"audience": "Зрители",
"imdb": "IMDb",
"tmdb": "TMDB",
"rottenTomatoes": "Rotten Tomatoes",
"simkl": "Simkl",
"mal": "MyAnimeList",
"anilist": "AniList",
"trakt": "Trakt",
"rottenTomatoesCritic": "Критики Rotten Tomatoes",
"rottenTomatoesAudience": "Зрители Rotten Tomatoes"
}
},
"screens": {
"licenses": "Лицензии",
@@ -107,6 +120,7 @@
"darkTheme": "Тёмная",
"oledTheme": "OLED",
"libraryDensity": "Плотность библиотеки",
"displayScale": "Масштаб отображения",
"compact": "Компактный",
"comfortable": "Комфортный",
"tvCornerSpotlightBackdrop": "Фоновое изображение в углу",
@@ -156,6 +170,11 @@
"bufferSizeMB": "${size}МБ",
"bufferSizeAuto": "Авто (Рекомендуется)",
"bufferSizeWarning": "Доступно памяти: ${heap} МБ. Буфер размером ${size} МБ может повлиять на воспроизведение.",
"playbackBuffer": "Буфер воспроизведения",
"playbackBufferAuto": "Авто (рекомендуется)",
"playbackBufferLarge": "Большой",
"playbackBufferExtraLarge": "Очень большой",
"playbackBufferDescription": "Буферизирует больше при нестабильном соединении. Также ограничено размером буфера.",
"defaultQualityTitle": "Качество по умолчанию",
"musicQualityTitle": "Качество музыки",
"subtitleStyling": "Стиль субтитров",
@@ -189,9 +208,9 @@
"debugLoggingDescription": "Включить подробное журналирование для устранения неполадок",
"viewLogs": "Просмотр логов",
"viewLogsDescription": "Просмотр логов приложения",
"clearImageCache": "",
"clearImageCacheDescription": "",
"clearImageCacheSuccess": "",
"clearImageCache": "Очистить кэш изображений",
"clearImageCacheDescription": "Очистить кэшированные обложки и миниатюры. Изображения могут загружаться медленнее, пока не будут загружены снова.",
"clearImageCacheSuccess": "Кэш изображений успешно очищен",
"resetSettings": "Сбросить настройки",
"resetSettingsDescription": "Восстановить настройки по умолчанию. Это нельзя отменить.",
"resetSettingsSuccess": "Настройки успешно сброшены",
@@ -281,30 +300,6 @@
"downmixCenterBoostShort": "дБ",
"audioDownmixNormalize": "Нормализация громкости при микшировании",
"audioDownmixNormalizeDescription": "Снижает уровень микса во избежание клиппинга. Отключите, чтобы сохранить исходную громкость (возможны искажения в громких сценах).",
"atmosDiagnostics": "Тест вывода Atmos",
"atmosDiagnosticsDescription": "Диагностика вывода Dolby Atmos воспроизведением тестовых сигналов через системный проигрыватель",
"atmosTestHlsAtmos": "Atmos-поток Apple",
"atmosTestHlsAtmosDescription": "Заведомо рабочий поток Dolby Atmos. Ресивер должен показать Dolby Atmos.",
"atmosTestHlsControl": "Surround-поток Apple",
"atmosTestHlsControlDescription": "Контрольный поток без Atmos. Ресивер должен показать объёмный звук без Atmos.",
"atmosTestRawStream": "Сырой поток EAC3",
"atmosTestRawStreamDescription": "Транслирует тестовый файл точно так же, как Atmos-воспроизведение в проигрывателе. Требуется URL тестового файла.",
"atmosTestRawFile": "Сырой файл EAC3",
"atmosTestRawFileDescription": "Воспроизводит тестовый файл с известной длиной. Требуется URL тестового файла.",
"atmosTestAsbarNative": "Рендерер сэмпл-буфера (нативный)",
"atmosTestAsbarNativeDescription": "Передаёт неизменённый сжатый звук файла прямо в системный рендерер. Требуется URL тестового файла.",
"atmosTestAsbarGenerated": "Рендерер сэмпл-буфера (пересобранный)",
"atmosTestAsbarGeneratedDescription": "То же, но с описанием звука, собранным как при воспроизведении. Требуется URL тестового файла.",
"atmosTestSessionMode": "Использовать режим воспроизведения фильмов",
"atmosTestSessionModeDescription": "Выключено — режим, описанный Dolby. Включено — прежний режим.",
"atmosTestShowRoutePicker": "Выбрать выход AirPlay",
"atmosTestHideRoutePicker": "Скрыть выбор выхода AirPlay",
"atmosTestRoutePickerDescription": "Отправляет тест на приёмник AirPlay. Только AirPlay сообщает определённый режим звука.",
"atmosTestStop": "Остановить тест",
"atmosTestUrl": "URL тестового файла",
"atmosTestUrlDescription": "HTTP-URL сырого файла .ec3 Dolby Atmos (например, извлечённого через ffmpeg)",
"atmosTestUrlMissing": "Сначала укажите URL тестового файла",
"atmosTestStatus": "Статус",
"dvConversionMode": "Преобразование Dolby Vision",
"dvConversionModeDescription": "Выберите, как ExoPlayer обрабатывает файлы Dolby Vision Profile 7.",
"dvConversionAuto": "Авто",
@@ -328,6 +323,8 @@
"showNavBarLabels": "Показывать подписи панели навигации",
"showNavBarLabelsDescription": "Отображать текстовые подписи под иконками панели навигации",
"startupSection": "Начальный раздел",
"showExploreTab": "",
"showExploreTabDescription": "",
"liveTvDefaultFavorites": "Избранные каналы по умолчанию",
"liveTvDefaultFavoritesDescription": "Показывать только избранные каналы при открытии ТВ",
"display": "Экран",
@@ -383,118 +380,118 @@
},
"fileInfo": {
"title": "Информация о файле",
"overview": "",
"overview": "Обзор",
"video": "Видео",
"audio": "Аудио",
"subtitles": "Субтитры",
"images": "",
"dataStreams": "",
"lyrics": "",
"images": "Встроенные изображения",
"dataStreams": "Потоки данных",
"lyrics": "Текст песни",
"file": "Файл",
"attachments": "",
"delivery": "",
"versionCounter": "",
"fileCounter": "",
"noStreams": "",
"copyPath": "",
"pathCopied": "",
"attachments": "Вложения",
"delivery": "Доставка",
"versionCounter": "Версия ${index} из ${count}",
"fileCounter": "Файл ${index} из ${count}",
"noStreams": "Сервер не сообщил о потоках для этого файла.",
"copyPath": "Копировать путь",
"pathCopied": "Путь к файлу скопирован",
"codec": "Кодек",
"codecTag": "",
"codecTag": "Тег кодека",
"resolution": "Разрешение",
"codedResolution": "",
"codedResolution": "Кодированное разрешение",
"bitrate": "Битрейт",
"frameRate": "Частота кадров",
"rotation": "",
"comment": "",
"audioDescription": "",
"headerCompression": "",
"sidecarFile": "",
"transportTimestamp": "",
"displayOffset": "",
"previewFailureCode": "",
"previewRetries": "",
"rotation": "Поворот",
"comment": "Комментарий",
"audioDescription": "Аудиоописание",
"headerCompression": "Сжатие заголовков",
"sidecarFile": "Файл-спутник",
"transportTimestamp": "Метка времени транспорта",
"displayOffset": "Смещение отображения",
"previewFailureCode": "Код ошибки превью",
"previewRetries": "Повторные попытки превью",
"aspectRatio": "Соотношение сторон",
"pixelAspectRatio": "",
"pixelAspectRatio": "Попиксельное соотношение сторон",
"profile": "Профиль",
"level": "",
"level": "Уровень",
"bitDepth": "Глубина цвета",
"pixelFormat": "",
"pixelFormat": "Формат пикселей",
"colorSpace": "Цветовое пространство",
"colorRange": "Цветовой диапазон",
"colorPrimaries": "Основные цвета",
"colorTransfer": "",
"colorTransfer": "Передача цвета",
"chromaSubsampling": "Субдискретизация цветности",
"chromaLocation": "",
"scanType": "",
"interlaced": "",
"anamorphic": "",
"referenceFrames": "",
"dynamicRange": "",
"dolbyVision": "",
"dolbyVisionLevel": "",
"dolbyVisionVersion": "",
"dolbyVisionLayers": "",
"baseLayerCompatibility": "",
"avcBitstream": "",
"nalLengthSize": "",
"scalingMatrix": "",
"streamIdentifier": "",
"streamIndex": "",
"streamId": "",
"language": "",
"languageCode": "",
"streamTitle": "",
"chromaLocation": "Расположение цветности",
"scanType": "Тип развёртки",
"interlaced": "Чересстрочный",
"anamorphic": "Анаморфный",
"referenceFrames": "Опорные кадры",
"dynamicRange": "Динамический диапазон",
"dolbyVision": "Dolby Vision",
"dolbyVisionLevel": "Уровень Dolby Vision",
"dolbyVisionVersion": "Версия Dolby Vision",
"dolbyVisionLayers": "Слои Dolby Vision",
"baseLayerCompatibility": "Совместимость базового слоя",
"avcBitstream": "Битовый поток AVC",
"nalLengthSize": "Размер длины NAL",
"scalingMatrix": "Пользовательская матрица масштабирования",
"streamIdentifier": "Идентификатор потока",
"streamIndex": "Индекс потока",
"streamId": "ID потока",
"language": "Язык",
"languageCode": "Код языка",
"streamTitle": "Название дорожки",
"channels": "Каналы",
"sampleRate": "",
"spatialAudio": "",
"textBased": "",
"subtitleFormat": "",
"provider": "",
"matchScore": "",
"externalDelivery": "",
"sidecarPath": "",
"sourceStream": "",
"temporary": "",
"timeBase": "",
"sampleRate": "Частота дискретизации",
"spatialAudio": "Пространственное аудио",
"textBased": "Текстовый",
"subtitleFormat": "Формат файла-спутника",
"provider": "Провайдер",
"matchScore": "Оценка совпадения",
"externalDelivery": "Может передаваться отдельно",
"sidecarPath": "Путь к файлу-спутнику",
"sourceStream": "Скопировано из",
"temporary": "Временный",
"timeBase": "Временная база",
"overallBitrate": "Общий битрейт",
"path": "Путь",
"fileName": "",
"fileName": "Имя файла",
"size": "Размер",
"totalSize": "",
"totalSize": "Общий размер",
"container": "Контейнер",
"duration": "Длительность",
"previewThumbnails": "",
"previewIndex": "",
"packetLength": "",
"filePresent": "",
"fileReadable": "",
"streamPath": "",
"previewThumbnails": "Миниатюры превью",
"previewIndex": "Индекс превью",
"packetLength": "Длина пакета",
"filePresent": "Файл присутствует",
"fileReadable": "Доступен для чтения сервером",
"streamPath": "Путь потока",
"optimizedForStreaming": "Оптимизировано для потоковой передачи",
"has64bitOffsets": "64-битные смещения",
"protocol": "",
"mediaType": "",
"sourceKind": "",
"optimizedVersion": "",
"optimizationTarget": "",
"deletedAt": "",
"remoteSource": "",
"infiniteStream": "",
"directPlay": "",
"directStream": "",
"transcoding": "",
"etag": "",
"versionId": "",
"fileId": "",
"defaultAudioTrack": "",
"defaultSubtitleTrack": "",
"subtitlesOff": "",
"flagDefault": "",
"flagForced": "",
"flagSelected": "",
"flagExternal": "",
"flagHearingImpaired": "",
"flagDub": "",
"flagOriginal": ""
"protocol": "Протокол",
"mediaType": "Тип медиа",
"sourceKind": "Тип источника",
"optimizedVersion": "Оптимизированная версия",
"optimizationTarget": "Цель оптимизации",
"deletedAt": "Удалено",
"remoteSource": "Удалённый источник",
"infiniteStream": "Бесконечный поток",
"directPlay": "Прямое воспроизведение",
"directStream": "Прямой поток",
"transcoding": "Транскодирование",
"etag": "ETag",
"versionId": "ID версии",
"fileId": "ID файла",
"defaultAudioTrack": "Аудиодорожка по умолчанию",
"defaultSubtitleTrack": "Дорожка субтитров по умолчанию",
"subtitlesOff": "Выкл.",
"flagDefault": "По умолчанию",
"flagForced": "Принудительная",
"flagSelected": "Выбрана",
"flagExternal": "Внешняя",
"flagHearingImpaired": "Для слабослышащих",
"flagDub": "Дубляж",
"flagOriginal": "Оригинал"
},
"mediaMenu": {
"markAsWatched": "Отметить как просмотренное",
@@ -505,9 +502,41 @@
"shufflePlay": "Случайное воспроизведение",
"shuffleNotAvailableOffline": "Перемешивание недоступно офлайн",
"fileInfo": "Информация о файле",
"deleteFromServer": "Удалить с сервера",
"confirmDelete": "Удалить это медиа и его файлы с сервера?",
"deleteEpisodeFromServer": "Удалить эпизод с сервера",
"deleteSeasonFromServer": "Удалить сезон с сервера",
"deleteShowFromServer": "Удалить сериал с сервера",
"deleteMovieFromServer": "Удалить фильм с сервера",
"deleteEpisodeTitle": "Удалить этот эпизод?",
"deleteSeasonTitle": "Удалить этот сезон?",
"deleteShowTitle": "Удалить этот сериал?",
"deleteMovieTitle": "Удалить этот фильм?",
"deleteEpisodeConfirm": "Удалить эпизод",
"deleteSeasonConfirm": "Удалить сезон",
"deleteShowConfirm": "Удалить сериал",
"deleteMovieConfirm": "Удалить фильм",
"deleteAnyway": "Всё равно удалить",
"confirmDeleteTarget": "Навсегда удалить ${title} с вашего сервера?",
"deleteMultipleWarning": "Это включает все эпизоды и их файлы.",
"deleteEpisodeCountWarning": {
"one": "Это удалит содержащийся в нём ${n} эпизод и его файл.",
"few": "Это удалит содержащиеся в нём ${n} эпизода и их файлы.",
"many": "Это удалит содержащиеся в нём ${n} эпизодов и их файлы.",
"other": "Это удалит содержащиеся в нём ${n} эпизодов и их файлы."
},
"deleteMultiPartWarning": {
"one": "Этот элемент хранится как ${n} файл, который будет удалён.",
"few": "Этот элемент хранится в ${n} файлах, и все они будут удалены.",
"many": "Этот элемент хранится в ${n} файлах, и все они будут удалены.",
"other": "Этот элемент хранится в ${n} файлах, и все они будут удалены."
},
"deleteSharedFileHeading": {
"one": "В этом же файле хранится ещё ${n} эпизод, и он тоже будет удалён:",
"few": "В этом же файле хранятся ещё ${n} эпизода, и они тоже будут удалены:",
"many": "В этом же файле хранятся ещё ${n} эпизодов, и они тоже будут удалены:",
"other": "В этом же файле хранятся ещё ${n} эпизодов, и они тоже будут удалены:"
},
"deleteScopeUnverifiedProbeFailed": "Plezy не смог проверить, какие файлы будут удалены, поэтому может быть удалено больше, чем указанный выше элемент. Отмените и попробуйте снова либо удалите в любом случае.",
"deleteScopeUnverifiedNoFileInfo": "Ваш сервер не предоставил сведения о файлах для этого элемента, поэтому Plezy не может проверить, какие файлы будут удалены. Может быть удалено больше, чем указанный выше элемент.",
"mediaDeletedSuccessfully": "Медиаэлемент успешно удалён",
"mediaFailedToDelete": "Не удалось удалить медиаэлемент",
"rate": "Оценить",
@@ -583,8 +612,9 @@
"playNext": "Следующее",
"playButton": "Воспроизвести",
"pauseButton": "Пауза",
"playbackPaused": "",
"playbackResumed": "",
"playbackPaused": "На паузе",
"playbackResumed": "Воспроизведение",
"loadingVideo": "Загрузка видео",
"showPlaybackControls": "Показать элементы управления воспроизведением",
"hidePlaybackControls": "Скрыть элементы управления воспроизведением",
"seekBackwardButton": "Перемотать назад на ${seconds} с",
@@ -635,6 +665,7 @@
"noChaptersAvailable": "Главы недоступны",
"queue": "Очередь",
"noQueueItems": "В очереди нет элементов",
"noAudioDevicesAvailable": "Нет доступных аудиоустройств",
"searchSubtitles": "Поиск субтитров",
"language": "Язык",
"noSubtitlesFound": "Субтитры не найдены",
@@ -658,7 +689,7 @@
},
"removedFromContinueWatching": "Удалено из «Продолжить просмотр»",
"errorLoading": "Ошибка: ${error}",
"searchPartialResults": "",
"searchPartialResults": "Поиск по некоторым медиасерверам не удался. Показываем доступные результаты.",
"streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.",
"liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.",
"fileInfoNotAvailable": "Информация о файле недоступна",
@@ -697,11 +728,14 @@
"switchingToCompatiblePlayer": "Переключение на совместимый плеер...",
"serverLimitTitle": "Ошибка воспроизведения",
"serverLimitBody": "Ошибка сервера (HTTP 500). Лимит пропускной способности/транскодирования, вероятно, отклонил сессию. Попросите владельца изменить настройки.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"mediaUnreadableTitle": "Файл недоступен",
"mediaUnreadableBody": "Сервер нашёл этот элемент, но не смог прочитать его файл (HTTP 404). Вероятно, файл был перемещён, удалён или его хранилище недоступно. Попросите владельца сервера проверить файл и пересканировать библиотеку.",
"serverBusyTitle": "",
"serverBusyBody": "",
"logsUploaded": "Логи загружены",
"logsUploadFailed": "Не удалось загрузить логи",
"logId": "ID лога"
"logId": "ID лога",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Текст",
@@ -720,6 +754,8 @@
"overrideStrip": "Удалить стили",
"positionTop": "Сверху",
"positionBottom": "Снизу",
"anchorToScreen": "",
"anchorToScreenDescription": "",
"bold": "Жирный",
"italic": "Курсив",
"renderResolution": "Разрешение отрисовки",
@@ -782,6 +818,8 @@
"noConnectionsHint": "Нет соединений — добавьте одно, чтобы использовать этот профиль.",
"noConnections": "Нет соединений",
"plexHomeAccount": "Аккаунт Plex Home",
"plexAccountChip": "Аккаунт Plex: ${account}",
"plexAccountUserChip": "${user} через ${account}",
"connectionDefault": "По умолчанию",
"connectionAs": "как ${displayName}",
"makeDefault": "Сделать по умолчанию",
@@ -815,8 +853,8 @@
"sessionExpiredOne": "Сессия истекла для ${name}",
"sessionExpiredMany": "Сессия истекла для ${count} серверов",
"signInAgain": "Войти снова",
"editJellyfinTitle": "Изменить подключение Jellyfin",
"editJellyfinIntro": "Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой."
"editMediaBrowserTitle": "Изменить подключение ${product}",
"editMediaBrowserIntro": "Добавьте или удалите URL-адреса для ${serverName}. Plezy будет использовать доступный URL с наименьшей задержкой."
},
"discover": {
"title": "Обзор",
@@ -846,7 +884,7 @@
},
"errors": {
"searchFailed": "Ошибка поиска: ${error}",
"searchUnavailable": "",
"searchUnavailable": "Поиску не удалось связаться ни с одним медиасервером.",
"connectionTimeout": "Таймаут подключения при загрузке ${context}",
"connectionFailed": "Не удалось подключиться к медиасерверу",
"unableToLoad": "Не удалось загрузить ${context}. Повторите попытку.",
@@ -974,31 +1012,36 @@
"uploadLogs": "Загрузить логи"
},
"startup": {
"failedTitle": "",
"failedBody": "",
"phaseLabel": "",
"showDetails": "",
"hideDetails": "",
"copyDetails": "",
"detailsCopied": "",
"uploadDetails": "",
"repairStorage": "",
"repairTitle": "",
"repairBodyCommon": "",
"repairBodyOneCredential": "",
"repairBodySessionsAtRisk": "",
"repairConfirm": "",
"repairSucceeded": "",
"repairNeedsRestart": "",
"repairFailed": "",
"repairKeptSignIns": "",
"repairLostSignIns": "",
"repairLostSessions": "",
"backupTitle": "",
"backupWarning": "",
"deleteBackup": "",
"backupDeleted": "",
"previousFailureTitle": ""
"failedTitle": "Plezy не удалось запустить",
"failedBody": "Во время запуска что-то пошло не так. Сведения ниже показывают, что именно не удалось.",
"failedBodyRepairable": "Сохранённый файл настроек Plezy повреждён, и его необходимо пересоздать, прежде чем Plezy сможет запуститься. Повторные попытки не помогут — выберите «Восстановить хранилище».",
"phaseLabel": "Шаг",
"showDetails": "Показать сведения",
"hideDetails": "Скрыть сведения",
"copyDetails": "Копировать сведения",
"detailsCopied": "Сведения скопированы в буфер обмена",
"uploadDetails": "Отправить сведения",
"repairStorage": "Восстановить хранилище",
"repairTitle": "Восстановить сохранённые данные?",
"repairBodyCommon": "Файл настроек Plezy повреждён и не может быть прочитан. Восстановление сбросит все настройки к значениям по умолчанию.",
"repairBodyOneCredential": "Одна сохранённая учётная запись повреждена и не может быть прочитана. Восстановление удалит только её; остальные настройки не будут затронуты.",
"repairBodySignInsKept": "Вход в ваши серверы и профили будет сохранён.",
"repairBodySignInsLost": "Ключ, защищающий сохранённые входы, невозможно восстановить из этого файла, поэтому вам придётся снова войти на каждый сервер и в каждый профиль. На вашем медиасервере ничего не изменится.",
"repairBodySessionsUncertain": "Трекеры (MAL, AniList, Simkl, Trakt) и Seerr хранятся отдельно и могут сохраниться или нет. Plezy точно сообщит, что удалось сохранить.",
"repairConfirm": "Восстановить",
"repairSucceeded": "Хранилище восстановлено",
"repairNeedsRestart": "Хранилище восстановлено — требуется перезапуск",
"restartRequiredBody": "Ваши данные были восстановлены, но Plezy должен запуститься заново, чтобы использовать их. Закройте Plezy и откройте его снова.",
"quitPlezy": "Выйти из Plezy",
"repairFailed": "Не удалось восстановить",
"repairKeptSignIns": "Ваши серверы и профили по-прежнему в системе.",
"repairLostSignIns": "Ключ, защищающий сохранённые входы, не удалось восстановить. Вам придётся снова войти на каждый сервер и в каждый профиль.",
"repairLostSessions": "Потеряно как минимум одно подключение трекера или Seerr — его нужно восстановить.",
"backupTitle": "Сохранена копия повреждённого файла",
"backupWarning": "Она содержит ваши учётные данные для входа. Не загружайте и не передавайте её.",
"deleteBackup": "Удалить копию",
"backupDeleted": "Копия удалена.",
"previousFailureTitle": "В прошлый раз Plezy не удалось запустить"
},
"licenses": {
"relatedPackages": "Связанные пакеты",
@@ -1009,7 +1052,7 @@
"navigation": {
"libraries": "Библиотеки",
"downloads": "Загрузки",
"liveTv": "ТВ в прямом эфире",
"liveTv": "Прямой эфир",
"explore": "Обзор"
},
"explore": {
@@ -1047,7 +1090,10 @@
"characters": "Персонажи",
"addToWatchlist": "Добавить в список для просмотра",
"removeFromWatchlist": "Удалить из списка для просмотра",
"addedToWatchlist": "",
"removedFromWatchlist": "",
"watchlistUpdateFailed": "Не удалось обновить список для просмотра",
"watchlistNoMatch": "",
"notInLibrary": "Нет в вашей библиотеке",
"inTheseLibraries": "В этих библиотеках",
"checkingLibrary": "Проверка вашей библиотеки...",
@@ -1058,153 +1104,140 @@
"searchPrompt": "Ищите фильмы и сериалы в ${source}.",
"searchFailed": "Ошибка поиска. Проверьте подключение и повторите попытку.",
"badge": {
"rankPopular": "",
"rankAiring": "",
"rankRated": "",
"rankFavorited": "",
"rankTrending": "",
"rankSeasonal": "",
"watchingNow": "",
"available": "",
"partiallyAvailable": "",
"availableIn4k": "",
"requested": "",
"pendingApproval": "",
"processing": "",
"declined": "",
"requestFailed": "",
"requested4k": "",
"seasonsAvailable": "",
"nextEpisodeIn": "",
"nextAiringIn": "",
"episodesShort": "",
"minutesPerEpisode": "",
"adult": ""
"rankPopular": "#${n} по популярности",
"rankAiring": "#${n} в эфире",
"rankRated": "#${n} по оценкам",
"rankFavorited": "#${n} по избранному",
"rankTrending": "#${n} в тренде",
"rankSeasonal": "#${n} в сезоне ${season}",
"watchingNow": "${n} смотрят",
"available": "Доступно",
"partiallyAvailable": "Доступно частично",
"availableIn4k": "Доступно в 4K",
"requested": "Запрошено",
"pendingApproval": "Ожидает одобрения",
"processing": "В обработке",
"declined": "Отклонено",
"requestFailed": "Запрос не удался",
"requested4k": "Запрошено в 4K",
"seasonsAvailable": "${available}/${total} сезонов",
"nextEpisodeIn": "Серия ${episode} через ${duration}",
"nextAiringIn": "Далее через ${duration}",
"episodesShort": "${n} эп.",
"minutesPerEpisode": "${n} мин/эп.",
"adult": "18+"
},
"stats": {
"listed": "",
"viewersDay": "",
"viewersWeek": "",
"viewersMonth": "",
"viewersYear": "",
"viewersAllTime": "",
"planning": "",
"favorited": "",
"dropRate": "",
"listed": "${n} в списках",
"viewersDay": "${n} посмотрели сегодня",
"viewersWeek": "${n} посмотрели на этой неделе",
"viewersMonth": "${n} посмотрели в этом месяце",
"viewersYear": "${n} посмотрели в этом году",
"viewersAllTime": "${n} зрителей",
"planning": "${n} планируют посмотреть",
"favorited": "${n} в избранном",
"dropRate": "${percent} бросили",
"comments": {
"one": "",
"few": "",
"many": "",
"other": ""
"one": "${n} комментарий",
"few": "${n} комментария",
"many": "${n} комментариев",
"other": "${n} комментариев"
},
"votes": "",
"watching": "",
"completed": "",
"onHold": "",
"dropped": ""
"votes": "${n} голосов",
"watching": "${n} смотрят",
"completed": "${n} завершили",
"onHold": "${n} отложили",
"dropped": "${n} бросили"
},
"season": {
"winter": "",
"spring": "",
"summer": "",
"fall": "",
"withYear": ""
"winter": "Зима",
"spring": "Весна",
"summer": "Лето",
"fall": "Осень",
"withYear": "${season} ${year}"
},
"format": {
"tv": "",
"tvShort": "",
"movie": "",
"special": "",
"ova": "",
"ona": "",
"music": "",
"other": ""
"tv": "ТВ",
"tvShort": "Короткометражное ТВ",
"movie": "Фильм",
"special": "Спецвыпуск",
"ova": "OVA",
"ona": "ONA",
"music": "Музыка",
"other": "Другое"
},
"sourceMaterial": {
"original": "",
"manga": "",
"lightNovel": "",
"novel": "",
"visualNovel": "",
"game": "",
"webComic": "",
"musicRelease": "",
"otherMedia": ""
"original": "Оригинал",
"manga": "Манга",
"lightNovel": "Ранобэ",
"novel": "Новелла",
"visualNovel": "Визуальная новелла",
"game": "Игра",
"webComic": "Веб-комикс",
"musicRelease": "Музыка",
"otherMedia": "Другое"
},
"creditRole": {
"director": "",
"writer": "",
"producer": "",
"creator": "",
"composer": ""
},
"ratingSource": {
"critic": "",
"audience": "",
"imdb": "",
"tmdb": "",
"rottenTomatoes": "",
"simkl": "",
"mal": "",
"anilist": "",
"trakt": "",
"rottenTomatoesCritic": "",
"rottenTomatoesAudience": ""
},
"broadcast": "",
"broadcastWithZone": "",
"detail": {
"originalTitle": "",
"alsoKnownAs": "",
"studios": "",
"country": "",
"language": "",
"released": "",
"physicalRelease": "",
"ended": "",
"addedOn": "",
"yourRating": "",
"budget": "",
"revenue": "",
"contentAdvisory": "",
"tags": "",
"revealSpoilerTags": "",
"links": "",
"watchOn": "",
"watchTrailer": "",
"openOn": "",
"crew": "",
"ratings": "",
"schedule": "",
"recommendedByUsers": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"recommendedBy": "",
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
"one": "",
"few": "",
"many": "",
"other": ""
"director": "Режиссёр",
"writer": "Сценарист",
"producer": "Продюсер",
"creator": "Создатель",
"composer": "Композитор"
},
"relation": {
"prequel": "",
"sequel": "",
"sideStory": "",
"spinOff": "",
"alternativeVersion": "",
"summary": "",
"parentStory": "",
"adaptation": "",
"other": ""
"prequel": "Приквел",
"sequel": "Сиквел",
"sideStory": "Побочная история",
"spinOff": "Спин-офф",
"alternativeVersion": "Альтернативная версия",
"summary": "Краткое изложение",
"parentStory": "Основная история",
"adaptation": "Адаптация",
"other": "Связано"
},
"broadcast": "Выходит ${day} в ${time}",
"broadcastWithZone": "Выходит ${day} в ${time} ${timezone}",
"detail": {
"originalTitle": "Оригинальное название",
"alsoKnownAs": "Также известен как",
"studios": "Студии",
"country": "Страна",
"language": "Язык",
"released": "Выпущено",
"physicalRelease": "На диске",
"ended": "Завершено",
"addedOn": "Добавлено ${date}",
"yourRating": "Ваша оценка",
"budget": "Бюджет",
"revenue": "Сборы",
"contentAdvisory": "Возрастные ограничения",
"tags": "Теги",
"revealSpoilerTags": "Показать теги со спойлерами",
"links": "Ссылки",
"watchOn": "Смотреть на",
"watchTrailer": "Смотреть трейлер",
"openOn": "Открыть на ${site}",
"crew": "Съёмочная группа",
"ratings": "Рейтинги",
"schedule": "Расписание",
"recommendedByUsers": {
"one": "Рекомендуют ${n} пользователь",
"few": "Рекомендуют ${n} пользователя",
"many": "Рекомендуют ${n} пользователей",
"other": "Рекомендуют ${n} пользователей"
},
"recommendedBy": "Рекомендовано: ${who}",
"favoritedBy": "В избранном у ${who}",
"unairedEpisodes": "${n} ещё не вышли",
"recommendedByPercent": "Рекомендуют ${percent} зрителей",
"relatedTitles": "Связанные названия",
"background": "Фон"
},
"totalResults": {
"one": "${n} результат",
"few": "${n} результата",
"many": "${n} результатов",
"other": "${n} результатов"
}
},
"liveTv": {
@@ -1236,6 +1269,9 @@
"watchChannel": "Смотреть канал",
"favorites": "Избранное",
"reorderFavorites": "Изменить порядок избранного",
"noFavoriteChannels": "",
"noFavoriteChannelsHint": "",
"showAllChannels": "",
"favoritesLoadFailed": "Не удалось загрузить избранное. Проверьте подключение и повторите попытку.",
"favoritesUpdateFailed": "Не удалось обновить избранное. Проверьте подключение и повторите попытку.",
"joinSession": "Присоединиться к текущему сеансу",
@@ -1473,15 +1509,15 @@
"editSyncRule": "Редактировать правило синхронизации",
"removeSyncRule": "Удалить правило синхронизации",
"removeSyncRuleConfirm": "Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.",
"removeListSyncRuleConfirm": "",
"deleteSyncRuleDownloads": "",
"deleteSyncRuleDownloadsDescription": "",
"removeListSyncRuleConfirm": "Прекратить синхронизацию \"${title}\"?",
"deleteSyncRuleDownloads": "Также удалить связанные загрузки",
"deleteSyncRuleDownloadsDescription": "Загрузки, используемые другим правилом синхронизации или профилем, будут сохранены.",
"syncRuleCreated": "Правило синхронизации создано — хранится ${count} непросмотренных эпизодов",
"syncRuleUpdated": "Правило синхронизации обновлено",
"syncRuleRemoved": "Правило синхронизации удалено",
"syncRuleAndDownloadsRemoved": "",
"syncRuleCleanupBusy": "",
"syncRuleCleanupUnavailable": "",
"syncRuleAndDownloadsRemoved": "Правило синхронизации и связанные загрузки удалены",
"syncRuleCleanupBusy": "Правила синхронизации сейчас обновляются. Попробуйте снова чуть позже.",
"syncRuleCleanupUnavailable": "Не удалось надёжно определить связанные загрузки. Переподключите сервер и повторите попытку либо удалите правило без удаления загрузок.",
"syncedNewEpisodes": "Синхронизировано ${count} новых эпизодов для ${title}",
"activeSyncRules": "Правила синхронизации",
"noSyncRules": "Нет правил синхронизации",
@@ -1626,9 +1662,15 @@
"audioSync": "Синхронизация аудио",
"subtitleSync": "Синхронизация субтитров",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудиовыход",
"performanceOverlay": "Оверлей производительности",
"audioPassthrough": "Сквозной вывод аудио",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Объёмный звук",
@@ -1846,11 +1888,12 @@
"mal": "MyAnimeList",
"anilist": "AniList",
"simkl": "Simkl",
"seerr": "Seerr"
"seerr": "Seerr",
"mdblist": "MDBList"
},
"deviceCode": {
"title": "Активируйте Plezy в ${service}",
"body": "Перейдите на ${url} и введите этот код:",
"instructions": "",
"openToActivate": "Открыть ${service} для активации",
"copyCode": "Скопировать код активации",
"waitingForAuthorization": "Ожидание авторизации…",
@@ -1859,7 +1902,9 @@
"oauthProxy": {
"title": "Войти в ${service}",
"body": "Отсканируйте этот QR-код или откройте URL на любом устройстве.",
"openToSignIn": "Открыть ${service} для входа",
"openToSignIn": "Открыть ${service} для входа"
},
"pendingAuth": {
"copyUrl": "Скопировать URL для входа",
"urlCopied": "URL скопирован"
},
@@ -1879,12 +1924,12 @@
}
},
"addServer": {
"addJellyfinTitle": "Добавить сервер Jellyfin",
"addMediaBrowserTitle": "Добавить сервер ${product}",
"serverUrls": "URL-адреса сервера",
"serverUrlsHelper": "Можно указать несколько URL через запятую.",
"findServer": "Найти сервер",
"searchingLocalServers": "Поиск локальных серверов Jellyfin...",
"localServers": "Локальные серверы Jellyfin",
"searchingLocalMediaBrowserServers": "Поиск локальных серверов ${product}...",
"localMediaBrowserServers": "Локальные серверы ${product}",
"username": "Имя пользователя",
"password": "Пароль",
"signIn": "Войти",
@@ -1896,15 +1941,15 @@
"addPlexTitle": "Войти через Plex",
"pinExpired": "Срок действия PIN истёк до входа. Попробуйте снова.",
"failedToRegisterAccount": "Не удалось зарегистрировать учётную запись: ${error}",
"enterJellyfinUrlError": "Введите URL вашего сервера Jellyfin",
"enterMediaBrowserUrlError": "Введите URL вашего сервера ${product}",
"addConnectionTitle": "Добавить подключение",
"addConnectionTitleScoped": "Добавить в ${name}",
"signInWithPlexCard": "Войти через Plex",
"signInWithPlexCardSubtitle": "Авторизуйте это устройство. Общие серверы будут добавлены.",
"signInWithPlexCardSubtitleScoped": "Авторизуйте аккаунт Plex. Пользователи Home станут профилями.",
"connectToJellyfinCard": "Подключиться к Jellyfin",
"connectToJellyfinCardSubtitle": "Введите URL сервера, имя пользователя и пароль.",
"connectToJellyfinCardSubtitleScoped": "Войдите на сервер Jellyfin. Привязывается к ${name}.",
"connectToMediaBrowserCard": "Подключиться к ${product}",
"connectToMediaBrowserCardSubtitle": "Введите URL сервера, имя пользователя и пароль.",
"connectToMediaBrowserCardSubtitleScoped": "Войдите на свой сервер ${product}. Будет привязано к ${name}.",
"borrowFromAnotherProfile": "Использовать подключение другого профиля",
"borrowFromAnotherProfileSubtitle": "Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN."
}
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 32942 (1497 per locale)
/// Strings: 38345 (1742 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+372 -249
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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