Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfdb38707d | ||
|
|
e9a213807f | ||
|
|
bcd6fe9906 | ||
|
|
c27dc0a1a7 | ||
|
|
f5665df43f | ||
|
|
f1d4be70e2 | ||
|
|
85d1672909 | ||
|
|
5c08b29756 | ||
|
|
672047924c | ||
|
|
e92cc3cebf | ||
|
|
8e1be64d8d | ||
|
|
5a3a1f3c70 | ||
|
|
0ee5672426 | ||
|
|
df0570b861 | ||
|
|
8d284efa39 | ||
|
|
716fe9b51b | ||
|
|
9d13584c00 | ||
|
|
8e5279a487 | ||
|
|
8ad13e94cf | ||
|
|
e0a364e26a | ||
|
|
63f2bedf2c | ||
|
|
ff461d9f71 | ||
|
|
de76c0a515 | ||
|
|
291a22a4a4 | ||
|
|
0b4fd9e8f3 | ||
|
|
ce9556db22 | ||
|
|
9d51f5aadd | ||
|
|
fe79817e76 | ||
|
|
f4ce60611b | ||
|
|
8740a19f36 | ||
|
|
bb3762ed63 | ||
|
|
7437b43207 | ||
|
|
e6be5f9fef | ||
|
|
f0debe2c32 | ||
|
|
109f1eda4d | ||
|
|
be99f27b92 | ||
|
|
3b76cf3948 | ||
|
|
b7a438789f | ||
|
|
33c33c3d57 | ||
|
|
636fd48f40 | ||
|
|
5f397a99d9 | ||
|
|
3364b3c22c | ||
|
|
24a041977b | ||
|
|
e3703892b3 | ||
|
|
feb34caeb7 | ||
|
|
4816e3928f | ||
|
|
660e375248 | ||
|
|
f5ccaab3ab | ||
|
|
f63d0fe49e | ||
|
|
f5488cb7ff | ||
|
|
db4f7a643b | ||
|
|
094be1fa3e | ||
|
|
309a107912 | ||
|
|
21cf1ff8d4 | ||
|
|
f63a4b039a | ||
|
|
961e9c0326 | ||
|
|
4607d165fd | ||
|
|
3a56218a12 | ||
|
|
7ce5a443fd | ||
|
|
f93952ba6f | ||
|
|
1b6a811c07 | ||
|
|
9d51a040c3 | ||
|
|
23b8befe11 | ||
|
|
26dbce0277 | ||
|
|
edaff1fbfc | ||
|
|
eaa1736c4e | ||
|
|
541fc2c097 | ||
|
|
b97a22c213 | ||
|
|
80d3537975 | ||
|
|
8879941d29 | ||
|
|
3b0b407cd0 | ||
|
|
05fd622968 | ||
|
|
f36e20bcad | ||
|
|
d7c4ea0a8b | ||
|
|
dcc06768e0 | ||
|
|
ddc3a4d77c | ||
|
|
58d5d3c4ef | ||
|
|
4872adcde3 | ||
|
|
61ae314c94 | ||
|
|
d82a5c5cd7 | ||
|
|
fdd4c661fe | ||
|
|
439ae1d733 | ||
|
|
a759e8b3c6 | ||
|
|
74d3af3ae1 | ||
|
|
8624c37041 | ||
|
|
7a19f4149e | ||
|
|
860ce1e11a | ||
|
|
2b4875d389 | ||
|
|
e7aa1e4782 | ||
|
|
bbaf5f0f9e | ||
|
|
0f5e5c8b6e | ||
|
|
a9f0532f5f | ||
|
|
fb0613e3db | ||
|
|
9c08c78f6d | ||
|
|
95b013e155 | ||
|
|
d83d0790ba | ||
|
|
bbed260169 | ||
|
|
35061f9f68 | ||
|
|
1d9ffb7427 | ||
|
|
bac2a0d201 | ||
|
|
2a7e5f4f9c | ||
|
|
bc0d14a749 | ||
|
|
f78f65faf5 | ||
|
|
957711a650 | ||
|
|
2cb2c3eb95 |
+47
-18
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+92
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+299
@@ -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
|
||||
}
|
||||
}
|
||||
+152
@@ -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
|
||||
}
|
||||
}
|
||||
+311
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+302
@@ -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?>>()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+694
-82
File diff suppressed because it is too large
Load Diff
+718
-84
File diff suppressed because it is too large
Load Diff
+716
-82
File diff suppressed because it is too large
Load Diff
+716
-82
File diff suppressed because it is too large
Load Diff
+372
-249
File diff suppressed because it is too large
Load Diff
+716
-82
File diff suppressed because it is too large
Load Diff
+722
-88
File diff suppressed because it is too large
Load Diff
+716
-82
File diff suppressed because it is too large
Load Diff
+718
-84
File diff suppressed because it is too large
Load Diff
+710
-82
File diff suppressed because it is too large
Load Diff
+694
-82
File diff suppressed because it is too large
Load Diff
+710
-82
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
Reference in New Issue
Block a user