Compare commits
279
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98bd085bff | ||
|
|
23f9afa5b1 | ||
|
|
efb0e885ae | ||
|
|
5fb4c78385 | ||
|
|
5a666fd10f | ||
|
|
56312a9eb2 | ||
|
|
976bdbd021 | ||
|
|
274b31ebac | ||
|
|
e1ef52aba1 | ||
|
|
9db6f54b7a | ||
|
|
39ee089212 | ||
|
|
89d54d8f1d | ||
|
|
44773fd742 | ||
|
|
4f4a7338e0 | ||
|
|
10f9139ac6 | ||
|
|
3c1ab3cc6c | ||
|
|
c597390e1c | ||
|
|
2806115015 | ||
|
|
d9493c6157 | ||
|
|
7cfb890046 | ||
|
|
c6458295a1 | ||
|
|
6e2dda092d | ||
|
|
45e8da1610 | ||
|
|
ad026cecc1 | ||
|
|
ffc95d6b2c | ||
|
|
a3cbf323b3 | ||
|
|
2f2016fdbe | ||
|
|
ff1064d35f | ||
|
|
861111a191 | ||
|
|
463ad6b933 | ||
|
|
1ee648f442 | ||
|
|
ed71b200b9 | ||
|
|
a1d8ab5210 | ||
|
|
a13febf67b | ||
|
|
a97be5823f | ||
|
|
c9de78697b | ||
|
|
9ac8002b53 | ||
|
|
942065e39d | ||
|
|
94565b4ee0 | ||
|
|
0c5308cfa1 | ||
|
|
a18ecb5c4a | ||
|
|
f7060478d1 | ||
|
|
3f03262833 | ||
|
|
3c6d398598 | ||
|
|
8f893e7a67 | ||
|
|
465da3e82a | ||
|
|
04615ce2fc | ||
|
|
a1c98b9d27 | ||
|
|
85bf948004 | ||
|
|
c564635ad9 | ||
|
|
fce4193f7e | ||
|
|
628fd21ec3 | ||
|
|
d2c4e8345f | ||
|
|
ae555ed5d7 | ||
|
|
779c532fc7 | ||
|
|
595cd9fe24 | ||
|
|
75279ca196 | ||
|
|
bd2d19a1c1 | ||
|
|
66c6b137a5 | ||
|
|
805632efc1 | ||
|
|
fe9d9d5e7d | ||
|
|
1995fc6b75 | ||
|
|
defb0f4983 | ||
|
|
371eab8637 | ||
|
|
f357be4077 | ||
|
|
0fdc22b4fa | ||
|
|
fb85bb279a | ||
|
|
cc4eff98c0 | ||
|
|
0fbf2e205c | ||
|
|
a9f2c1ff99 | ||
|
|
fe4e53f912 | ||
|
|
fed572d19e | ||
|
|
adef523894 | ||
|
|
84bc4b2618 | ||
|
|
ead637cc92 | ||
|
|
eed6071b94 | ||
|
|
6da1f83891 | ||
|
|
8b5ee4f3dd | ||
|
|
a6cca37174 | ||
|
|
fff1b3090d | ||
|
|
c3305d2919 | ||
|
|
b457290a6b | ||
|
|
a11da3fec8 | ||
|
|
99c21ce6ae | ||
|
|
5b6016d88b | ||
|
|
6aaea847a7 | ||
|
|
a615ec6b97 | ||
|
|
c4059d0ead | ||
|
|
f95465067e | ||
|
|
e6a0840fbe | ||
|
|
cb55134cef | ||
|
|
5d83d4833c | ||
|
|
f2bf43a8bc | ||
|
|
b4b36c7e18 | ||
|
|
be01631cab | ||
|
|
02a5f8ad2c | ||
|
|
0964ab8ba2 | ||
|
|
89b8af62ea | ||
|
|
5842d9c1ec | ||
|
|
07463eea04 | ||
|
|
627374d4b5 | ||
|
|
c4b043cba9 | ||
|
|
aa9118a799 | ||
|
|
fe954eef5f | ||
|
|
66d0ef054e | ||
|
|
8f13a56e1a | ||
|
|
8061c4f5f2 | ||
|
|
802d47e980 | ||
|
|
1a9d2686c9 | ||
|
|
95800d8a72 | ||
|
|
3f55cc461e | ||
|
|
c201bc2f03 | ||
|
|
75c095a5b2 | ||
|
|
974806fe80 | ||
|
|
6f64ca2952 | ||
|
|
a9a7498991 | ||
|
|
37446e2d8e | ||
|
|
72f68bdfa7 | ||
|
|
24d7681d92 | ||
|
|
595a846bb9 | ||
|
|
7652d060e2 | ||
|
|
7fc559e64b | ||
|
|
ed1d0f1b10 | ||
|
|
5591a0094d | ||
|
|
6ab46ced54 | ||
|
|
a029b1a7c8 | ||
|
|
ca7f90d1bd | ||
|
|
4fcb221643 | ||
|
|
0f5cd11c08 | ||
|
|
7d0e04bcc0 | ||
|
|
09e0b06e49 | ||
|
|
5c08862194 | ||
|
|
de91db47c7 | ||
|
|
d7422c81cb | ||
|
|
da21481ff3 | ||
|
|
e5dc9c3f89 | ||
|
|
d7500c25c1 | ||
|
|
d1ccd236fd | ||
|
|
5961bf8881 | ||
|
|
0a75e2345e | ||
|
|
70ffe8ff15 | ||
|
|
938ddc5942 | ||
|
|
ec530c6713 | ||
|
|
a53041a352 | ||
|
|
294419109b | ||
|
|
61268fab73 | ||
|
|
8b7917fcc4 | ||
|
|
2563d624ca | ||
|
|
54af977f39 | ||
|
|
21f48382bc | ||
|
|
424c14a344 | ||
|
|
fcb167e661 | ||
|
|
32b3fb64a2 | ||
|
|
71cbb83c06 | ||
|
|
33d80b6d0e | ||
|
|
a9cbb72c63 | ||
|
|
7c496413f8 | ||
|
|
04673a8c8a | ||
|
|
958c27350d | ||
|
|
591f33aa2c | ||
|
|
dcf2314d9d | ||
|
|
4c74d6a1d5 | ||
|
|
5842d30ae8 | ||
|
|
5e19d9dad4 | ||
|
|
b0b4d5d6db | ||
|
|
a55ec642b3 | ||
|
|
aedca9b416 | ||
|
|
d9a5fb034e | ||
|
|
92cc49a265 | ||
|
|
1e83e4e6c2 | ||
|
|
e71580def9 | ||
|
|
2f47ed23cd | ||
|
|
b66eec827d | ||
|
|
df0d71e6f8 | ||
|
|
088886bfb6 | ||
|
|
e0122feb2b | ||
|
|
08a3a61c2a | ||
|
|
4cc8b4c0d9 | ||
|
|
7a4cdc8b5e | ||
|
|
20896a3d48 | ||
|
|
fc96ddc1ab | ||
|
|
4522f42bbf | ||
|
|
a4b88df965 | ||
|
|
810ef002ad | ||
|
|
cb900c255a | ||
|
|
43ab083cda | ||
|
|
3fb90a4948 | ||
|
|
65713858ea | ||
|
|
744e72d76b | ||
|
|
761303de11 | ||
|
|
4a1111f435 | ||
|
|
4b6533d25b | ||
|
|
05ef93c55b | ||
|
|
8d2eca42d4 | ||
|
|
0f3d06668f | ||
|
|
7131be4cc0 | ||
|
|
436fd7d2d5 | ||
|
|
c198bab04c | ||
|
|
2a6f752860 | ||
|
|
16c0e00302 | ||
|
|
6d75e58f52 | ||
|
|
ad1a12c998 | ||
|
|
9fe6dbe9f2 | ||
|
|
304d8a5e33 | ||
|
|
986237491f | ||
|
|
35eef09f72 | ||
|
|
8461e6ba5f | ||
|
|
93d3ec65e2 | ||
|
|
35dce90ea9 | ||
|
|
483691d43a | ||
|
|
b4a84c6a28 | ||
|
|
c5e7ce648b | ||
|
|
7cfd68c491 | ||
|
|
137f2a0e95 | ||
|
|
1bdeca3b5f | ||
|
|
cff6666219 | ||
|
|
b0798cde9f | ||
|
|
417f3a5e36 | ||
|
|
d4d44e6c72 | ||
|
|
658469f954 | ||
|
|
efd33b3a4e | ||
|
|
86498547ba | ||
|
|
5fe1e6dc65 | ||
|
|
1df47ed8e1 | ||
|
|
a7527718df | ||
|
|
7cd5bb64bb | ||
|
|
b38df008ec | ||
|
|
ceab5a5b59 | ||
|
|
6eb0805f8d | ||
|
|
a33c423985 | ||
|
|
1a26b0dd08 | ||
|
|
4cc065b73a | ||
|
|
071ae8e1c2 | ||
|
|
7658587584 | ||
|
|
49fce5ace9 | ||
|
|
057ff98ec1 | ||
|
|
00a6e056b0 | ||
|
|
78fa2c29e3 | ||
|
|
0e1af9faaf | ||
|
|
99b0528623 | ||
|
|
f5c7b95b02 | ||
|
|
d78a7d925e | ||
|
|
62c25e493e | ||
|
|
e543f417ce | ||
|
|
4c2f533eef | ||
|
|
fb048097ab | ||
|
|
4eb3ab4632 | ||
|
|
a910aa95cb | ||
|
|
3703ac47c3 | ||
|
|
c390e0fb0c | ||
|
|
3665d416c4 | ||
|
|
2e1a7130fc | ||
|
|
55382deaf9 | ||
|
|
4436efea72 | ||
|
|
8e9c81fe47 | ||
|
|
093c0596f1 | ||
|
|
908b4a8dac | ||
|
|
8033a308ce | ||
|
|
68c328b469 | ||
|
|
01f04eda42 | ||
|
|
0f016e4e13 | ||
|
|
953acedcab | ||
|
|
be9197eda6 | ||
|
|
a2b3377d91 | ||
|
|
e1b488f860 | ||
|
|
a79084b0a5 | ||
|
|
75704c7a9b | ||
|
|
f2ce587a85 | ||
|
|
947fddf540 | ||
|
|
50a9365be8 | ||
|
|
06ecba5fb5 | ||
|
|
096e285ab0 | ||
|
|
aeb6108654 | ||
|
|
f622ba8efe | ||
|
|
e47b4e0a73 | ||
|
|
9dd8aa4679 | ||
|
|
3f6e4c0716 | ||
|
|
49c117db83 | ||
|
|
d77836ef22 |
+2
-2
@@ -3,5 +3,5 @@
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
!scripts/maestro/maestro_fixtures.py
|
||||
!scripts/maestro/maestro_real_jellyfin.py
|
||||
|
||||
@@ -77,11 +77,12 @@ body:
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Are you using Jellyfin or Plex?
|
||||
description: Are you using Jellyfin, Plex or Emby?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
- Emby
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ body:
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
- Emby
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: >-
|
||||
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
|
||||
in the stable manifest) and `channel: master` would clone master HEAD, whose
|
||||
engine is not the patched revision install-patched-engine.ps1 asserts
|
||||
(4c525dac). This file is the only place that pin lives: the tag is fetched so
|
||||
(5d531788). This file is the only place that pin lives: the tag is fetched so
|
||||
the SDK reports its own version, then verified against the immutable commit
|
||||
and against the version Flutter itself reports, so a moved tag fails the job
|
||||
instead of quietly changing SDKs.
|
||||
@@ -16,8 +16,8 @@ runs:
|
||||
- name: Clone Flutter from its immutable commit
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "3.44.0"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
$version = "3.47.1"
|
||||
$expectedCommit = "6655482ec06e547f90abf8ae7590466f4415978d"
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
name: Build
|
||||
run-name: ${{ inputs.release_tag != '' && format('Release {0}', inputs.release_tag) || format('Build {0}', github.sha) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Tag for a draft release; omit to build Actions artifacts only
|
||||
default: ''
|
||||
type: string
|
||||
build_android:
|
||||
description: Build Android
|
||||
default: true
|
||||
@@ -26,7 +31,7 @@ on:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
FLUTTER_VERSION: "3.47.1"
|
||||
# Shared by every release build command; SENTRY_DIST stays per-platform.
|
||||
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
|
||||
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
|
||||
@@ -67,7 +72,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
@@ -860,7 +865,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }}
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux && inputs.release_tag != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -879,6 +884,21 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate release tag
|
||||
if: ${{ inputs.release_tag != '' }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Release tag must use semantic versioning: $RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$RELEASE_TAG" != "$VERSION" ]]; then
|
||||
echo "Release tag $RELEASE_TAG does not match pubspec version $VERSION." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download Android artifacts
|
||||
if: ${{ inputs.build_android }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -1043,5 +1063,8 @@ jobs:
|
||||
files: ${{ steps.release-files.outputs.files }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
name: ${{ inputs.release_tag }}
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
FLUTTER_VERSION: "3.47.1"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
run: scripts/codegen.sh --check
|
||||
|
||||
- name: Verify translation hygiene
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
run: python3 scripts/checks/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
@@ -75,10 +75,10 @@ jobs:
|
||||
xargs -0 -r dart format --output=none --set-exit-if-changed
|
||||
|
||||
- name: Verify icon consistency
|
||||
run: dart run scripts/check_icon_consistency.dart
|
||||
run: dart run scripts/checks/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/check_analyzer.dart
|
||||
run: dart run scripts/checks/check_analyzer.dart
|
||||
|
||||
- name: Check for unused code
|
||||
run: |
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -317,7 +317,8 @@ jobs:
|
||||
mpv_property_result_contract_test \
|
||||
hdr_metadata_test \
|
||||
plane_geometry_test \
|
||||
video_params_test
|
||||
video_params_test \
|
||||
plane_render_executor_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
|
||||
@@ -10,7 +10,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
|
||||
COPY scripts/maestro/maestro_fixtures.py scripts/maestro/maestro_real_jellyfin.py scripts/maestro/prepare_maestro_media.py ./
|
||||
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
|
||||
|
||||
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
|
||||
|
||||
+11
-7
@@ -16,6 +16,10 @@
|
||||
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
|
||||
- Test your changes thoroughly
|
||||
|
||||
### AI-assisted contributions
|
||||
|
||||
AI-assisted pull requests must state in the PR description which model(s) were used (e.g. Claude Sonnet 4.5, GPT-5, Gemini 3 Pro).
|
||||
|
||||
### Code Quality Checks
|
||||
|
||||
The project includes automated CI checks that run on all pull requests:
|
||||
@@ -51,22 +55,22 @@ Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Androi
|
||||
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/run_maestro.py media # Codec playback and track selection
|
||||
python3 scripts/maestro/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/maestro/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/maestro/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
python3 scripts/maestro/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
```
|
||||
|
||||
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
|
||||
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
|
||||
|
||||
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
|
||||
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/maestro/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
|
||||
independent tests. A manual-only classification must state why the flow cannot run automatically.
|
||||
|
||||
@@ -75,7 +79,7 @@ manual target rather than an automatic group. Run them only against the pre-seed
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
python3 scripts/maestro/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
```
|
||||
|
||||
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
|
||||
@@ -102,7 +106,7 @@ Update a production image only through a reviewed change:
|
||||
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
|
||||
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
|
||||
with the prior digest and pre-change backup, not by changing the image reference alone.
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
|
||||
5. Run `python3 scripts/checks/check_container_image_pins.py`, `python3 scripts/checks/test_check_container_image_pins.py`, and
|
||||
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
|
||||
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
|
||||
a digest is unavailable.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.13.0"
|
||||
sha256 "045f632883b3418a509c95170fc7767b619280873a66c8d96aa71e01cd35408d"
|
||||
version "2.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -148,7 +148,7 @@ Package managers:
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
- Flutter SDK 3.44.0+
|
||||
- Flutter SDK 3.47.0+
|
||||
- A Plex account, or a Jellyfin or Emby server with user credentials
|
||||
|
||||
### Setup
|
||||
@@ -190,7 +190,7 @@ scripts/setup_hooks.sh
|
||||
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic
|
||||
python3 scripts/maestro/run_maestro.py basic
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -4,6 +4,13 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -58,12 +58,18 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
val mpvVersion = "v1.2.2"
|
||||
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
// Dev-only escape hatch: point the build at a locally built libmpv AAR
|
||||
// (libmpv-android's build-local.sh output) to test fork patches before a
|
||||
// release is pinned. The URL + sha256 above stay authoritative otherwise.
|
||||
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
@@ -76,6 +82,8 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
inputs.property("localOverride", localMpvAar ?: "")
|
||||
localMpvAar?.let { inputs.file(it) }
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
@@ -83,15 +91,20 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
if (localMpvAar != null) {
|
||||
File(localMpvAar).copyTo(stagedAar, overwrite = true)
|
||||
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
|
||||
} else {
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
|
||||
Vendored
+17
@@ -18,3 +18,20 @@
|
||||
|
||||
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
|
||||
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
|
||||
|
||||
# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
|
||||
# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
|
||||
# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
|
||||
# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
|
||||
# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
|
||||
# extractorOutput the same way to wrap LATM tracks.
|
||||
#
|
||||
# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
|
||||
# them in its companion object, so the throw surfaces as ExceptionInInitializerError
|
||||
# while constructing the extractor — every MKV direct-play, release builds only. Only
|
||||
# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
|
||||
# shrinking through the compile-time subclass references.
|
||||
-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
|
||||
private androidx.media3.extractor.ExtractorOutput extractorOutput;
|
||||
private androidx.media3.common.util.ParsableByteArray subtitleSample;
|
||||
}
|
||||
|
||||
Binary file not shown.
+171
@@ -0,0 +1,171 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
|
||||
*
|
||||
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
|
||||
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
|
||||
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
|
||||
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
|
||||
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
|
||||
* be retired. The second test drives the production wrapper stack and requires the seek to
|
||||
* actually land.
|
||||
*/
|
||||
class MatroskaLateTracksSeekDeviceTest {
|
||||
private companion object {
|
||||
const val TAG = "MkvLateTracksSeek"
|
||||
const val SEEK_TARGET_MS = 500L
|
||||
const val SETTLE_MS = 2_000L
|
||||
}
|
||||
|
||||
private class Session(
|
||||
val handler: Handler,
|
||||
val thread: HandlerThread,
|
||||
val player: AtomicReference<ExoPlayer?>,
|
||||
val fixture: File
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stockMatroskaExtractorSnapsSeeksToStart() {
|
||||
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
|
||||
assertFalse(
|
||||
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
|
||||
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
|
||||
result.seekable
|
||||
)
|
||||
assertTrue(
|
||||
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrappedExtractorKeepsSeekPosition() {
|
||||
val result = runSeekScenario("wrapped") {
|
||||
val assHandler = AssHandler()
|
||||
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
|
||||
}
|
||||
assertTrue("wrapped extractor must report the item seekable", result.seekable)
|
||||
assertTrue(
|
||||
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
|
||||
|
||||
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
|
||||
val session = openPaused(label, extractorFactory)
|
||||
try {
|
||||
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
|
||||
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
|
||||
// The masked position is the seek target; the snap surfaces once the period resolves the
|
||||
// seek, so give the load a moment before sampling the settled position.
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
|
||||
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
|
||||
return ScenarioResult(seekable, positionAfterSeekMs)
|
||||
} finally {
|
||||
teardown(session)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixture = copyFixture(context)
|
||||
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
val ready = CountDownLatch(1)
|
||||
val failed = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
val exo = ExoPlayer.Builder(context).build()
|
||||
player.set(exo)
|
||||
exo.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) ready.countDown()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Log.e(TAG, "$label player error", error)
|
||||
failed.set(true)
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
ExtractorsFactory { arrayOf(extractorFactory()) }
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.playWhenReady = false
|
||||
exo.prepare()
|
||||
}
|
||||
|
||||
val session = Session(handler, thread, player, fixture)
|
||||
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
|
||||
teardown(session)
|
||||
error("$label playback never became ready")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
|
||||
val value = AtomicReference<T>()
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.let { value.set(read(it)) }
|
||||
done.countDown()
|
||||
}
|
||||
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
|
||||
return checkNotNull(value.get())
|
||||
}
|
||||
|
||||
private fun teardown(session: Session) {
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.release()
|
||||
done.countDown()
|
||||
}
|
||||
done.await(10, TimeUnit.SECONDS)
|
||||
session.thread.quitSafely()
|
||||
session.thread.join(5_000)
|
||||
session.fixture.delete()
|
||||
}
|
||||
|
||||
private fun copyFixture(context: android.content.Context): File {
|
||||
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("ffmpeg/matroska_tracks_at_end.mkv")
|
||||
.use { input -> output.outputStream().use { input.copyTo(it) } }
|
||||
return output
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -107,7 +107,7 @@ class TrueHdSpeedTransitionTest {
|
||||
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
|
||||
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
|
||||
// therefore the 192kHz carrier rate with no decoder instantiated.
|
||||
if (carrierEncoding != TrueHdMatPacker.CARRIER_SAMPLE_RATE || decoderBefore != null) {
|
||||
if (carrierEncoding != IecCarrier.SAMPLE_RATE || decoderBefore != null) {
|
||||
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
|
||||
teardown(handler, player, thread, fixture)
|
||||
return
|
||||
@@ -137,12 +137,12 @@ class TrueHdSpeedTransitionTest {
|
||||
|
||||
assertEquals(
|
||||
"returning to 1x must put TrueHD back on the carrier",
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
rateRestored
|
||||
)
|
||||
assertNotEquals(
|
||||
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
encodingAfter
|
||||
)
|
||||
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
|
||||
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
|
||||
@Test
|
||||
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
if (!supportsTrueHdMatCarrier()) {
|
||||
if (!supportsIecCarrier(context)) {
|
||||
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
||||
return
|
||||
}
|
||||
@@ -297,13 +297,13 @@ class TrueHdSpeedTransitionTest {
|
||||
// AudioTrack is ever opened and the rate sequence alone cannot tell the two apart.
|
||||
assertTrue(
|
||||
"the carrier must have been entered and then left at runtime, saw: $diagnostics",
|
||||
diagnostics.any { it.contains("MAT/IEC 61937 carrier") } &&
|
||||
diagnostics.any { it.contains("via IEC 61937 carrier") } &&
|
||||
diagnostics.any { it.contains("44.1kHz-family rate its container did not") }
|
||||
)
|
||||
assertTrue("a decoder must take the stream over instead of the carrier", decoder != null)
|
||||
assertNotEquals(
|
||||
"the stream must not still be riding the carrier",
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
rate
|
||||
)
|
||||
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.shared.DeviceQuirks
|
||||
import com.edde746.plezy.shared.MediaCodecQuery
|
||||
import com.edde746.plezy.shared.ThemeHelper
|
||||
import com.edde746.plezy.watchnext.WatchNextPlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
@@ -735,6 +736,7 @@ class MainActivity : FlutterActivity() {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getVideoDecodeCapabilities" -> result.success(MediaCodecQuery.hardwareVideoDecodeSupport())
|
||||
"getBackgroundWorkSignals" -> result.success(
|
||||
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
|
||||
)
|
||||
@@ -882,7 +884,8 @@ class MainActivity : FlutterActivity() {
|
||||
} catch (e: IllegalStateException) {
|
||||
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
|
||||
} catch (e: Exception) {
|
||||
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error")))
|
||||
Log.w(TAG, "Failed to enter PiP", e)
|
||||
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to e.message))
|
||||
}
|
||||
}
|
||||
"setAutoPipReady" -> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
@@ -30,6 +31,38 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT
|
||||
|
||||
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
|
||||
|
||||
/**
|
||||
* The DTS-family mimes the bundled FFmpeg decoder claims (`FfmpegLibrary` maps both to `dca`).
|
||||
*
|
||||
* DTS Express (`audio/vnd.dts.hd;profile=lbr`) and DTS:X (`audio/vnd.dts.uhd`) are deliberately
|
||||
* excluded: FFmpeg does not claim them, so hiding their platform decoders would leave those
|
||||
* streams with no decoder at all.
|
||||
*/
|
||||
internal fun isFfmpegDtsMimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_DTS || mimeType == MimeTypes.AUDIO_DTS_HD
|
||||
|
||||
/**
|
||||
* Whether a DTS stream should decode in the app's FFmpeg decoder instead of a platform
|
||||
* MediaCodec decoder.
|
||||
*
|
||||
* Platform DTS decoders cannot be trusted with decode. On Amlogic-based Google TV boxes (the
|
||||
* Onn family) DTS decode is license-gated in firmware, so `c2.amlogic.audio.decoder.dtshd`
|
||||
* initialises, drains and advances the playback position while rendering silence — and collapses
|
||||
* 5.1 to stereo where it does produce sound (#1995). FFmpeg decodes the whole `dca` family to
|
||||
* full multichannel PCM everywhere, which is what mpv and Kodi ship on the same hardware.
|
||||
*
|
||||
* Scoped to streams that are actually going to decode: [directOutputBlocked] (passthrough off,
|
||||
* downmix, normalization, the AudioTrack-failure blocklist) or a route that cannot bitstream DTS
|
||||
* in any shape ([routeCanBitstreamDts] false — the Android TV default leaves passthrough on, so
|
||||
* the setting alone cannot identify the decode path). Bitstream-capable routes are left exactly
|
||||
* alone: media3 selects direct output before it ever consults the decoder list, and leaving the
|
||||
* platform decoder visible there keeps the hardware-decoder tunneling gate unchanged.
|
||||
*/
|
||||
internal fun shouldForceFfmpegDtsDecode(
|
||||
mimeType: String,
|
||||
directOutputBlocked: () -> Boolean,
|
||||
routeCanBitstreamDts: () -> Boolean
|
||||
): Boolean = isFfmpegDtsMimeType(mimeType) && (directOutputBlocked() || !routeCanBitstreamDts())
|
||||
|
||||
/**
|
||||
* Linear PCM output encodings, i.e. the sink decoded the bitstream instead of
|
||||
* passing it through. Mirrors the platform's `AudioFormat.ENCODING_PCM_*` set.
|
||||
@@ -43,36 +76,88 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv `audio-spdif` codec names and the exact platform encoding a route must
|
||||
* advertise to carry that bitstream.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
"ac3" to C.ENCODING_AC3,
|
||||
"eac3" to C.ENCODING_E_AC3,
|
||||
"dts" to C.ENCODING_DTS,
|
||||
"dts-hd" to C.ENCODING_DTS_HD,
|
||||
"truehd" to C.ENCODING_DOLBY_TRUEHD
|
||||
/** The IEC 61937 track shape a codec's spdif burst rides. */
|
||||
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
|
||||
|
||||
private class MpvSpdifCodec(
|
||||
val name: String,
|
||||
val encoding: Int,
|
||||
val shape: MpvIecShape,
|
||||
/** Whether the fork's audiotrack AO opens this codec as a raw bitstream track first. */
|
||||
val raw: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
|
||||
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
|
||||
* bitstream, and the track shape the fork's `ao_audiotrack` opens for it.
|
||||
*
|
||||
* Since the raw-passthrough patch (0102), the AO feeds AC3, E-AC3 and the DTS core to a raw
|
||||
* `ENCODING_AC3`/`E_AC3`/`DTS` track at 48kHz/stereo — the transport ExoPlayer and Kodi use —
|
||||
* and only falls back to the IEC 61937 carrier when the route rejects the raw encoding
|
||||
* outright. Raw tracks keep the platform's Dolby/DTS transcoder in the path; a pre-packed IEC
|
||||
* track bypasses it and drains into silence on routes whose sink cannot decode the codec
|
||||
* itself (#2177's Shield in front of a Dolby-Digital-only Sonos). TrueHD (MAT) and DTS-HD MA
|
||||
* stay on the 8-channel 192kHz IEC carrier the multichannel patch (0101) added: their raw
|
||||
* forms are not recoverable from the burst stream.
|
||||
*
|
||||
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
|
||||
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
|
||||
* still chosen per file for tracks that are not HD (`ad_spdif.c:240-249`, `:400-418`). HRA rides
|
||||
* a 2ch/192kHz burst under the same name, so gating it on the 8-channel carrier is the
|
||||
* conservative choice.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
|
||||
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K, raw = true),
|
||||
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
|
||||
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
|
||||
* advertises the codec's encoding *and* a transport the AO's ladder can open exists —
|
||||
* [supportsRawTrack] for the raw bitstream track it tries first, or [supportsShape] for the
|
||||
* IEC 61937 burst it falls back to. Plain `dts` is dropped whenever `dts-hd` qualifies, which
|
||||
* already covers the core burst.
|
||||
*
|
||||
* mpv force-passes through every codec named here and has no decode fallback, so an
|
||||
* unsupported name leaves the file rendering video against a dead audio output (#1703).
|
||||
*
|
||||
* The gate is the exact encoding rather than media3's passthrough probe on purpose.
|
||||
* That probe answers DTS-HD by downgrading to the DTS core (and E-AC3 JOC to E-AC3)
|
||||
* for receivers that decode only the base layer, and it also rejects channel counts
|
||||
* above the route's PCM maximum, which does not apply to an IEC 61937 carrier. mpv
|
||||
* additionally treats `dts,dts-hd` as `dts-hd` alone, so accepting the downgrade would
|
||||
* name DTS-HD MA to a core-only receiver and lose DTS as well.
|
||||
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
|
||||
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
|
||||
* channel counts above the route's PCM maximum, neither of which describes what a
|
||||
* passthrough track carries.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
|
||||
.filter { (_, encoding) -> supportsEncoding(encoding) }
|
||||
.joinToString(",") { (codec, _) -> codec }
|
||||
internal fun mpvSpdifCodecs(
|
||||
supportsEncoding: (Int) -> Boolean,
|
||||
supportsShape: (MpvIecShape) -> Boolean,
|
||||
supportsRawTrack: (Int) -> Boolean = { false }
|
||||
): String {
|
||||
val carried = MPV_SPDIF_CODECS.filter {
|
||||
supportsEncoding(it.encoding) &&
|
||||
((it.raw && supportsRawTrack(it.encoding)) || supportsShape(it.shape))
|
||||
}
|
||||
val dtsHd = carried.any { it.name == "dts-hd" }
|
||||
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
|
||||
}
|
||||
|
||||
/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */
|
||||
/**
|
||||
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
|
||||
*
|
||||
* Two conditions per codec, both required:
|
||||
* - The route must accept a track shape the AO's ladder actually opens. For AC3, E-AC3 and the
|
||||
* DTS core that is the raw bitstream track probed by [supportsMpvRawTrack], with the IEC
|
||||
* stereo shapes ([supportsMpvIecShape], [supportsMpvHighRateIecShape]) as the AO's fallback
|
||||
* transport; TrueHD and DTS-HD MA need the 192kHz/7.1 carrier ([supportsIecCarrier]).
|
||||
* Advertising the raw encoding only says the receiver decodes it, not that the HAL takes the
|
||||
* track: #1991's Shield strands playback on every mpv IEC attempt while bitstreaming AC3 raw.
|
||||
* The probes are independent, so none of them may veto the whole list: a route that takes the
|
||||
* 192kHz carrier but no raw track still bitstreams TrueHD and DTS-HD MA.
|
||||
* - The receiver must decode the codec itself — the raw encoding on the current
|
||||
* [AudioCapabilities] — because passthrough is transport, not transcoding. (On a raw track
|
||||
* the platform may additionally transcode, which is exactly why the AO prefers it.)
|
||||
*/
|
||||
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
|
||||
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -88,11 +173,93 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
|
||||
return ""
|
||||
}
|
||||
return mpvSpdifCodecs(capabilities::supportsEncoding)
|
||||
// Every probe costs real route calls and may be shared by more than one codec, so probe once.
|
||||
val shapeProbed = HashMap<MpvIecShape, Boolean>(3)
|
||||
val rawProbed = HashMap<Int, Boolean>(3)
|
||||
val codecs = mpvSpdifCodecs(
|
||||
capabilities::supportsEncoding,
|
||||
{ shape -> shapeProbed.getOrPut(shape) { routeTakesIecShape(context, shape) } },
|
||||
{ encoding -> rawProbed.getOrPut(encoding) { supportsMpvRawTrack(context, encoding) } }
|
||||
)
|
||||
if (codecs.isEmpty()) {
|
||||
Log.i(TAG, "Route takes no passthrough track mpv can fill; mpv will decode instead of bitstreaming")
|
||||
} else {
|
||||
Log.i(TAG, "mpv will bitstream: $codecs")
|
||||
}
|
||||
return codecs
|
||||
}
|
||||
|
||||
private fun routeTakesIecShape(context: Context, shape: MpvIecShape): Boolean = when (shape) {
|
||||
MpvIecShape.STEREO_48K -> supportsMpvIecShape(context)
|
||||
MpvIecShape.STEREO_192K -> supportsMpvHighRateIecShape(context)
|
||||
MpvIecShape.SURROUND_192K -> supportsIecCarrier(context)
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for an AC3 or DTS-core burst: stereo at the mixer rate. */
|
||||
private const val MPV_IEC_SAMPLE_RATE = 48_000
|
||||
private const val MPV_IEC_CHANNEL_COUNT = 2
|
||||
private const val MPV_IEC_HIGH_SAMPLE_RATE = 192_000
|
||||
|
||||
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this route can carry TrueHD as MAT inside IEC 61937 (#1804).
|
||||
* Whether the route takes a raw bitstream `AudioTrack` for [encoding] at the shape the fork's
|
||||
* `ao_audiotrack` opens first: the codec frame rate with a stereo mask (Kodi's raw shape; the
|
||||
* HAL reads the real channel layout from the bitstream). Same probe tiering as the IEC shapes,
|
||||
* except below API 29, where no runtime oracle exists for raw tracks and media3 gates its raw
|
||||
* path on the advertised encoding alone — which [supportedMpvSpdifCodecs] already requires via
|
||||
* [AudioCapabilities]. #1991's API 28 Shield bitstreams AC3 exactly this way.
|
||||
*/
|
||||
internal fun supportsMpvRawTrack(context: Context, encoding: Int): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeDirectBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, encoding) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
|
||||
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
|
||||
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_HIGH_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
|
||||
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
|
||||
*
|
||||
* This is Kodi's test, and deliberately not media3's. Kodi asks the AudioTrack layer whether it can
|
||||
* size a buffer for one exact tuple — `getMinBufferSize(rate, mask, encoding) > 0` — and gates the
|
||||
@@ -100,8 +267,8 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
* layer about the encoding, which on the boxes measured for this issue answers "TrueHD is
|
||||
* offload-capable" and says nothing about whether a raw TrueHD track will ever drain.
|
||||
*
|
||||
* Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle
|
||||
* proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
|
||||
* Both are consulted: `getMinBufferSize` proves a track can be built, and a route oracle proves
|
||||
* the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
|
||||
* not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to
|
||||
* initialise.
|
||||
*
|
||||
@@ -112,72 +279,105 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
* for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way
|
||||
* and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails
|
||||
* AudioTrack initialisation, which the audio recovery path answers by force-decoding.
|
||||
* - Below API 29 there is no oracle at all, so the carrier is not offered and TrueHD decodes as
|
||||
* before.
|
||||
* - API 24–28: no runtime oracle exists, so the HDMI `AudioDeviceInfo` must explicitly advertise
|
||||
* IEC 61937 at 192kHz/8ch. Shield Experience 8.x is API 28, and the previous flat `false` on
|
||||
* this tier force-decoded TrueHD on routes that genuinely carry it (#1991). A route that
|
||||
* advertises and still refuses the track fails AudioTrack initialisation into the same
|
||||
* recovery path as the tier above.
|
||||
*/
|
||||
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
|
||||
internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeCarrierBuffer = {
|
||||
try {
|
||||
AudioTrack.getMinBufferSize(
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
|
||||
AudioFormat.ENCODING_IEC61937
|
||||
) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
},
|
||||
// The SDK_INT guards repeat trueHdMatCarrierSupported's tiering only because lint's NewApi
|
||||
canSizeBuffer = { canSizeIecBuffer(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported()
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && iecCarrierDirectPlaybackSupported()
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
|
||||
* API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
|
||||
* [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32.
|
||||
* [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only
|
||||
* consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`)
|
||||
* on 33+, [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32, and
|
||||
* [hdmiRouteAdvertised] (explicit HDMI `AudioDeviceInfo` advertisement) on 24–28 (#1991).
|
||||
*/
|
||||
internal fun trueHdMatCarrierSupported(
|
||||
internal fun iecRouteSupported(
|
||||
sdkInt: Int,
|
||||
canSizeCarrierBuffer: () -> Boolean,
|
||||
canSizeBuffer: () -> Boolean,
|
||||
bitstreamSupported: () -> Boolean,
|
||||
directPlaybackSupported: () -> Boolean
|
||||
directPlaybackSupported: () -> Boolean,
|
||||
hdmiRouteAdvertised: () -> Boolean
|
||||
): Boolean = when {
|
||||
sdkInt < Build.VERSION_CODES.Q -> false
|
||||
!canSizeCarrierBuffer() -> false
|
||||
// ENCODING_IEC61937 itself only exists from API 24.
|
||||
sdkInt < Build.VERSION_CODES.N -> false
|
||||
!canSizeBuffer() -> false
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
|
||||
else -> directPlaybackSupported()
|
||||
sdkInt >= Build.VERSION_CODES.Q -> directPlaybackSupported()
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
|
||||
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
|
||||
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private fun iecCarrierBitstreamSupported(): Boolean = try {
|
||||
val support = AudioManager.getDirectPlaybackSupport(iecCarrierProbeFormat(), movieAudioAttributes())
|
||||
private fun iecBitstreamSupported(format: AudioFormat): Boolean = try {
|
||||
val support = AudioManager.getDirectPlaybackSupport(format, movieAudioAttributes())
|
||||
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
|
||||
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses.
|
||||
private fun iecCarrierDirectPlaybackSupported(): Boolean = try {
|
||||
AudioTrack.isDirectPlaybackSupported(iecCarrierProbeFormat(), movieAudioAttributes())
|
||||
private fun iecDirectPlaybackSupported(format: AudioFormat): Boolean = try {
|
||||
AudioTrack.isDirectPlaybackSupported(format, movieAudioAttributes())
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
|
||||
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
|
||||
false
|
||||
}
|
||||
|
||||
/** The exact tuple the carrier's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
|
||||
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
|
||||
/**
|
||||
* Whether an HDMI output *explicitly* advertises IEC 61937 at [sampleRate]/[channelCount] —
|
||||
* the only oracle below API 29 (#1991).
|
||||
*
|
||||
* Empty `AudioDeviceInfo` capability arrays mean "unspecified" and deliberately fail this
|
||||
* check: an unvouched IEC track that initialises on a route that then renders it as PCM plays
|
||||
* the carrier as full-scale noise, which the AudioTrack-init recovery path cannot catch.
|
||||
*/
|
||||
private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCount: Int): Boolean = try {
|
||||
val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device ->
|
||||
(device.type == AudioDeviceInfo.TYPE_HDMI || device.type == AudioDeviceInfo.TYPE_HDMI_ARC) &&
|
||||
device.encodings.contains(AudioFormat.ENCODING_IEC61937) &&
|
||||
device.sampleRates.contains(sampleRate) &&
|
||||
device.channelCounts.contains(channelCount)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "HDMI route inspection failed; not offering IEC 61937 output", error)
|
||||
false
|
||||
}
|
||||
|
||||
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat =
|
||||
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
|
||||
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(encoding)
|
||||
.setChannelMask(channelMask)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
|
||||
|
||||
+62
-5
@@ -8,14 +8,21 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.SeekPoint
|
||||
import androidx.media3.extractor.TrackAwareSeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Extractor wrapper that enables approximate seeking for MKV files without Cues.
|
||||
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
|
||||
*
|
||||
* When the underlying extractor reports an [SeekMap.Unseekable] seek map (i.e. the MKV
|
||||
* has no Cues element), this wrapper replaces it with a proportional byte-position
|
||||
* estimate and scans for the nearest Cluster boundary after seeking.
|
||||
* Two repairs, in priority order:
|
||||
* - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a
|
||||
* proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking.
|
||||
* - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska
|
||||
* seek map while parsing Cues, which for files whose Tracks element follows the Clusters is
|
||||
* before any track is known — the primary seek track stays unset even though the Cues parsed):
|
||||
* wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on
|
||||
* this output.
|
||||
*/
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
class CuelessSeekExtractorWrapper(
|
||||
@@ -38,6 +45,9 @@ class CuelessSeekExtractorWrapper(
|
||||
private var isApproximateSeeking = false
|
||||
private var pendingSeekTimeUs: Long = C.TIME_UNSET
|
||||
|
||||
/** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */
|
||||
private val registeredTracks = CopyOnWriteArrayList<Pair<Int, Int>>()
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
@@ -125,7 +135,13 @@ class CuelessSeekExtractorWrapper(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
if (registeredTracks.none { it.first == id }) {
|
||||
registeredTracks.add(id to type)
|
||||
}
|
||||
return delegate.track(id, type)
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
@@ -137,6 +153,11 @@ class CuelessSeekExtractorWrapper(
|
||||
delegate.seekMap(ApproximateSeekMap(durationUs))
|
||||
return
|
||||
}
|
||||
} else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) {
|
||||
Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking")
|
||||
isApproximateSeeking = false
|
||||
delegate.seekMap(TrackCueSeekMap(seekMap))
|
||||
return
|
||||
}
|
||||
// File has real Cues or unknown duration — pass through
|
||||
isApproximateSeeking = false
|
||||
@@ -166,4 +187,40 @@ class CuelessSeekExtractorWrapper(
|
||||
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports
|
||||
* unseekable overall. The per-track queries read the live cue data (populated once the Cues
|
||||
* element parsed), so they resolve correctly even when the map was constructed before the
|
||||
* Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377).
|
||||
*/
|
||||
private inner class TrackCueSeekMap(
|
||||
private val delegate: TrackAwareSeekMap
|
||||
) : SeekMap {
|
||||
|
||||
override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null
|
||||
|
||||
override fun getDurationUs(): Long = delegate.durationUs
|
||||
|
||||
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
|
||||
if (delegate.isSeekable) return delegate.getSeekPoints(timeUs)
|
||||
val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs)
|
||||
return delegate.getSeekPoints(timeUs, trackId)
|
||||
}
|
||||
|
||||
/** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */
|
||||
private fun seekableTrackId(): Int? {
|
||||
var audio: Int? = null
|
||||
var fallback: Int? = null
|
||||
for ((id, type) in registeredTracks) {
|
||||
if (!delegate.isSeekable(id)) continue
|
||||
when (type) {
|
||||
C.TRACK_TYPE_VIDEO -> return id
|
||||
C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id
|
||||
else -> if (fallback == null) fallback = id
|
||||
}
|
||||
}
|
||||
return audio ?: fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,17 @@ object DoviBridge {
|
||||
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device can natively render single-layer Dolby Vision
|
||||
* Profile 5 (IPT-PQ-c2): it needs both a decoder advertising DvheStn and a
|
||||
* Dolby Vision display pipeline. P5 has no compatible base layer, so a
|
||||
* device that fails either check decodes it as plain HEVC with garbage
|
||||
* colors; callers route those sessions to software decode + gpu-next,
|
||||
* where libplacebo applies the RPU reshaping instead.
|
||||
*/
|
||||
fun canPlayDolbyVisionP5(context: Context): Boolean = deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn) &&
|
||||
displaySupportsDolbyVision(context)
|
||||
|
||||
fun displaySupportsDolbyVision(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
|
||||
@@ -145,6 +156,17 @@ object DoviBridge {
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Whether the active display advertises any HDR output type. */
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val display = getCurrentDisplay(context) ?: return false
|
||||
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
|
||||
Log.w(TAG, "Display HDR support: failed to query HDR types", error)
|
||||
return false
|
||||
}
|
||||
return hdrTypes.isNotEmpty()
|
||||
}
|
||||
|
||||
fun describeDisplayHdrCapabilities(context: Context): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
|
||||
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Packs DTS-HD (Master Audio) access units into IEC 61937 "DTS type IV" bursts (#1988).
|
||||
*
|
||||
* Some HDMI routes advertise `ENCODING_DTS_HD` but cannot actually carry Master Audio: Amazon
|
||||
* specifies the Fire TV Stick 4K Max as "DTS-HD passthrough, basic profile", and Android has one
|
||||
* ambiguous encoding constant for both profiles until API 34. Handing media3's raw path full MA
|
||||
* frames there initialises an AudioTrack that drains normally and renders silence. The same
|
||||
* devices do bitstream the 192kHz/7.1 `ENCODING_IEC61937` carrier — the split Kodi models with
|
||||
* its "AudioTrack (IEC)" sink, and the one TrueHD already rides here (#1804, #1863) — so DTS-HD
|
||||
* is packed onto that carrier instead.
|
||||
*
|
||||
* The algorithm is a port of FFmpeg's `spdif_header_dts`/`spdif_header_dts4`
|
||||
* (libavformat/spdifenc.c, n8.1) at `dtshd_rate=768000`, the rate that fills the 8-channel/192kHz
|
||||
* carrier; Kodi's `CAEBitstreamPacker::PackDTSHD` produces the same bytes.
|
||||
*
|
||||
* Output is one complete IEC 61937 burst per access unit:
|
||||
*
|
||||
* - 8-byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x11|subtype<<8 Pd=aligned bytes`
|
||||
* - 10-byte DTS-HD start code, 16-bit big-endian payload size, then the access unit, all 16-bit
|
||||
* byte-swapped
|
||||
* - zero padding to the burst repetition period the core frame duration maps to
|
||||
* (512 samples at 48kHz — the shape of essentially all Master Audio — is 32768 bytes)
|
||||
*
|
||||
* Unlike MAT's fixed frames, a Master Audio peak can genuinely exceed the carrier. FFmpeg answers
|
||||
* by stripping such units to the always-fitting core substream and holding that for
|
||||
* `dtshd_fallback_time` (60s) so receivers do not flap between core and MA decoding; that
|
||||
* behavior is ported as-is and pinned by the golden fixture.
|
||||
*
|
||||
* Not thread safe; the sink drives it from the playback thread only.
|
||||
*/
|
||||
internal class DtsHdIecPacker : IecCarrierPacker {
|
||||
|
||||
internal companion object {
|
||||
private const val BURST_HEADER_SIZE = 8
|
||||
private const val SYNCWORD1 = 0xF872
|
||||
private const val SYNCWORD2 = 0x4E1F
|
||||
private const val IEC61937_DTSHD = 0x11
|
||||
|
||||
/** Core and extension-substream sync words, big-endian raw framing. */
|
||||
private const val SYNC_CORE = 0x7FFE8001
|
||||
private const val SYNC_EXSS = 0x64582025
|
||||
|
||||
/**
|
||||
* Little-endian and 14-bit core framings, from S/PDIF and DTS-in-WAV captures. They have no
|
||||
* period mapping here (FFmpeg's HD path refuses them too), so they latch the stream
|
||||
* unsupported and it decodes.
|
||||
*/
|
||||
private const val SYNC_CORE_LE = 0xFE7F0180.toInt()
|
||||
private const val SYNC_CORE_14B_BE = 0x1FFFE800
|
||||
private const val SYNC_CORE_14B_LE = 0xFF1F00E8.toInt()
|
||||
|
||||
/**
|
||||
* The carrier's IEC 60958 frame rate as FFmpeg's two-channel model counts it: the burst
|
||||
* repetition period is `period = 768000 * coreSamples / coreRate` IEC 60958 frames of 4 bytes,
|
||||
* which is the same byte rate as [IecCarrier]'s 8 channels at 192kHz.
|
||||
*/
|
||||
private const val CARRIER_IEC958_RATE = 768_000
|
||||
|
||||
/** Core SFREQ index to Hz (`ff_dca_sample_rates`); zero marks invalid indices. */
|
||||
private val CORE_SAMPLE_RATES = intArrayOf(
|
||||
0, 8000, 16000, 32000, 0, 0, 11025, 22050, 44100, 0, 0, 12000, 24000, 48000, 96000, 192000
|
||||
)
|
||||
|
||||
/** Precedes every burst payload; `spdifenc.c`'s `dtshd_start_code`. */
|
||||
private val START_CODE = byteArrayOf(0x01, 0, 0, 0, 0, 0, 0, 0, 0xFE.toByte(), 0xFE.toByte())
|
||||
|
||||
/** Seconds of core-only output after an overflow; FFmpeg's `dtshd_fallback_time` default. */
|
||||
private const val HD_STRIP_SECONDS = 60
|
||||
|
||||
/** Minimum bytes needed to read a core header through its SFREQ field. */
|
||||
private const val MIN_CORE_HEADER_LENGTH = 9
|
||||
|
||||
/** IEC 61937-11 subtype for a burst repetition period in IEC 60958 frames, or -1. */
|
||||
private fun subtypeForPeriod(period: Long): Int = when (period) {
|
||||
512L -> 0
|
||||
1024L -> 1
|
||||
2048L -> 2
|
||||
4096L -> 3
|
||||
8192L -> 4
|
||||
16384L -> 5
|
||||
else -> -1
|
||||
}
|
||||
|
||||
private fun readSyncWord(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 24) or
|
||||
((data[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((data[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 3].toInt() and 0xFF)
|
||||
|
||||
/** Reads [count] (≤ 24) bits big-endian starting at absolute bit [bitPosition]. */
|
||||
private fun readBits(data: ByteArray, bitPosition: Int, count: Int): Int {
|
||||
var result = 0
|
||||
var position = bitPosition
|
||||
var remaining = count
|
||||
while (remaining > 0) {
|
||||
val byte = data[position ushr 3].toInt() and 0xFF
|
||||
val bitsLeftInByte = 8 - (position and 7)
|
||||
val take = minOf(bitsLeftInByte, remaining)
|
||||
result = (result shl take) or ((byte shr (bitsLeftInByte - take)) and ((1 shl take) - 1))
|
||||
position += take
|
||||
remaining -= take
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** `NBLKS + 1`: PCM blocks of 32 samples in the core frame at [offset]. */
|
||||
private fun coreBlocks(data: ByteArray, offset: Int): Int {
|
||||
val word = ((data[offset + 4].toInt() and 0xFF) shl 8) or (data[offset + 5].toInt() and 0xFF)
|
||||
return ((word shr 2) and 0x7F) + 1
|
||||
}
|
||||
|
||||
/** `FSIZE + 1`: the core frame's byte length. */
|
||||
private fun coreFrameSize(data: ByteArray, offset: Int): Int {
|
||||
val bits = ((data[offset + 5].toInt() and 0xFF) shl 16) or
|
||||
((data[offset + 6].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 7].toInt() and 0xFF)
|
||||
return ((bits shr 4) and 0x3FFF) + 1
|
||||
}
|
||||
|
||||
/** The core `SFREQ` field mapped to Hz; 0 for reserved indices. */
|
||||
private fun coreSampleRate(data: ByteArray, offset: Int): Int = CORE_SAMPLE_RATES[((data[offset + 8].toInt() and 0xFF) shr 2) and 0x0F]
|
||||
|
||||
/**
|
||||
* Total byte length of the extension substream at [offset] (`nuBits4ExSSFsize + 1`), or 0
|
||||
* when the header does not fit in [limit].
|
||||
*
|
||||
* Header layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
|
||||
* bHeaderSizeType(1), then a header-size field of 8 or 12 bits and this size field of 16 or
|
||||
* 20 bits — at most 75 bits, so 10 bytes cover every shape.
|
||||
*/
|
||||
private fun extensionSubstreamSize(data: ByteArray, offset: Int, limit: Int): Int {
|
||||
if (offset + 10 > limit) return 0
|
||||
val base = offset shl 3
|
||||
val wide = readBits(data, base + 42, 1) == 1
|
||||
val sizeFieldPosition = base + 43 + if (wide) 12 else 8
|
||||
return readBits(data, sizeFieldPosition, if (wide) 20 else 16) + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Burst geometry learned from the stream's core framing; zero until the first unit packs. */
|
||||
private var burstBytes = 0
|
||||
|
||||
private var burstBacking = Array(2) { ByteArray(0) }
|
||||
private var burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]) }
|
||||
private var burstIndex = 0
|
||||
|
||||
/** Bytes each reusable buffer was dirtied to, so padding only clears what a prior burst wrote. */
|
||||
private val dirtyEnd = intArrayOf(0, 0)
|
||||
|
||||
/** Assembles start code + size + access unit before the byte swap; sized with the bursts. */
|
||||
private var payloadScratch = ByteArray(0)
|
||||
|
||||
/** Access units still to strip to their core after an overflow. */
|
||||
private var hdStripRemaining = 0
|
||||
|
||||
/**
|
||||
* Latched when a burst had to be stripped to the core substream, so the sink can log the
|
||||
* downgrade once. Cleared by [reset].
|
||||
*/
|
||||
var strippedToCore = false
|
||||
private set
|
||||
|
||||
override var unsupportedStream = false
|
||||
private set
|
||||
|
||||
override fun reset() {
|
||||
// Buffers and the learned burst geometry survive; only per-stream state drops. The flag is
|
||||
// re-learned from the next unit; leaving it latched would silence every later stream.
|
||||
hdStripRemaining = 0
|
||||
strippedToCore = false
|
||||
unsupportedStream = false
|
||||
}
|
||||
|
||||
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
|
||||
if (offset + 4 > limit) return 0
|
||||
when (readSyncWord(data, offset)) {
|
||||
SYNC_CORE -> Unit
|
||||
SYNC_EXSS -> {
|
||||
// A stray HD frame without its core, seen at stream starts. Its size is walkable, so it
|
||||
// is consumed as a unit and dropped by packAccessUnit; FFmpeg discards these too.
|
||||
val size = extensionSubstreamSize(data, offset, limit)
|
||||
return if (size < 4 || offset + size > limit) 0 else size
|
||||
}
|
||||
SYNC_CORE_LE, SYNC_CORE_14B_BE, SYNC_CORE_14B_LE -> {
|
||||
unsupportedStream = true
|
||||
return 0
|
||||
}
|
||||
else -> return 0
|
||||
}
|
||||
|
||||
if (offset + MIN_CORE_HEADER_LENGTH > limit) return 0
|
||||
val coreSize = coreFrameSize(data, offset)
|
||||
var end = offset + coreSize
|
||||
if (coreSize < MIN_CORE_HEADER_LENGTH || end > limit) return 0
|
||||
// Master Audio glues one or more extension substreams to the core; they belong to this unit.
|
||||
while (end + 4 <= limit && readSyncWord(data, end) == SYNC_EXSS) {
|
||||
val size = extensionSubstreamSize(data, end, limit)
|
||||
if (size < 4 || end + size > limit) return 0
|
||||
end += size
|
||||
}
|
||||
return end - offset
|
||||
}
|
||||
|
||||
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
|
||||
if (length < MIN_CORE_HEADER_LENGTH) return null
|
||||
when (readSyncWord(data, offset)) {
|
||||
SYNC_CORE -> Unit
|
||||
// The stray leading HD frame accessUnitLength admitted; there is no core to derive a
|
||||
// period from, so it is dropped rather than carried.
|
||||
SYNC_EXSS -> return null
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val blocks = coreBlocks(data, offset)
|
||||
val coreSize = coreFrameSize(data, offset)
|
||||
val sampleRate = coreSampleRate(data, offset)
|
||||
if (sampleRate == 0) {
|
||||
unsupportedStream = true
|
||||
return null
|
||||
}
|
||||
|
||||
val coreSamples = blocks shl 5
|
||||
val periodProduct = CARRIER_IEC958_RATE.toLong() * coreSamples
|
||||
val period = periodProduct / sampleRate
|
||||
val newSubtype = if (periodProduct % sampleRate != 0L) -1 else subtypeForPeriod(period)
|
||||
if (newSubtype < 0) {
|
||||
// 44.1kHz-family cores and exotic frame lengths map to no IEC 61937-11 repetition period at
|
||||
// this carrier rate; FFmpeg refuses them the same way, so the stream decodes instead.
|
||||
unsupportedStream = true
|
||||
return null
|
||||
}
|
||||
setBurstGeometry((period * 4).toInt())
|
||||
|
||||
// FFmpeg's overflow answer: a Master Audio peak the carrier cannot hold strips this and the
|
||||
// next ~60 seconds of units to the always-fitting core substream, so the receiver does not
|
||||
// flap between core and MA decoding.
|
||||
if (START_CODE.size + 2 + length > burstBytes - BURST_HEADER_SIZE) {
|
||||
hdStripRemaining = sampleRate * HD_STRIP_SECONDS / coreSamples
|
||||
}
|
||||
var payloadSize = length
|
||||
if (hdStripRemaining > 0 && coreSize <= length) {
|
||||
payloadSize = coreSize
|
||||
hdStripRemaining--
|
||||
strippedToCore = true
|
||||
}
|
||||
val payloadBytes = START_CODE.size + 2 + payloadSize
|
||||
if (payloadBytes > burstBytes - BURST_HEADER_SIZE) {
|
||||
// Even the bare core overflows this period (only possible for very short core frames);
|
||||
// nothing can be carried.
|
||||
unsupportedStream = true
|
||||
return null
|
||||
}
|
||||
|
||||
START_CODE.copyInto(payloadScratch, 0)
|
||||
payloadScratch[START_CODE.size] = (payloadSize ushr 8).toByte()
|
||||
payloadScratch[START_CODE.size + 1] = (payloadSize and 0xFF).toByte()
|
||||
data.copyInto(payloadScratch, START_CODE.size + 2, offset, offset + payloadSize)
|
||||
// A final lone byte goes out MSB-aligned; its swap partner must be zero, not stale scratch.
|
||||
if (payloadBytes and 1 == 1) payloadScratch[payloadBytes] = 0
|
||||
|
||||
burstIndex = burstIndex xor 1
|
||||
val backing = burstBacking[burstIndex]
|
||||
putLittleEndianShort(backing, 0, SYNCWORD1)
|
||||
putLittleEndianShort(backing, 2, SYNCWORD2)
|
||||
putLittleEndianShort(backing, 4, IEC61937_DTSHD or (newSubtype shl 8))
|
||||
// Aligned so (Pd & 0xF) == 0x8, which some receivers reportedly require; FFmpeg and Kodi
|
||||
// both apply the same quirk.
|
||||
putLittleEndianShort(backing, 6, ((payloadBytes + 0x17) and 0x0F.inv()) - BURST_HEADER_SIZE)
|
||||
|
||||
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
|
||||
var source = 0
|
||||
var destination = BURST_HEADER_SIZE
|
||||
val swappedPayload = payloadBytes + (payloadBytes and 1)
|
||||
while (source < swappedPayload) {
|
||||
backing[destination] = payloadScratch[source + 1]
|
||||
backing[destination + 1] = payloadScratch[source]
|
||||
source += 2
|
||||
destination += 2
|
||||
}
|
||||
if (destination < dirtyEnd[burstIndex]) {
|
||||
java.util.Arrays.fill(backing, destination, dirtyEnd[burstIndex], 0)
|
||||
}
|
||||
dirtyEnd[burstIndex] = destination
|
||||
|
||||
val burst = burstBuffers[burstIndex]
|
||||
burst.limit(burstBytes)
|
||||
burst.position(0)
|
||||
return burst
|
||||
}
|
||||
|
||||
private fun setBurstGeometry(newBurstBytes: Int) {
|
||||
if (newBurstBytes == burstBytes) return
|
||||
burstBytes = newBurstBytes
|
||||
burstBacking = Array(2) { ByteArray(newBurstBytes) }
|
||||
burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN) }
|
||||
dirtyEnd[0] = 0
|
||||
dirtyEnd[1] = 0
|
||||
payloadScratch = ByteArray(newBurstBytes)
|
||||
}
|
||||
|
||||
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private var trackSelector: DefaultTrackSelector? = null
|
||||
private var tunnelingUserEnabled: Boolean = true
|
||||
private var tunnelingDisabledForAudioCodec: Boolean = false
|
||||
|
||||
// Tunnelled playback is driven by the audio codec's clock, so without a
|
||||
// hardware audio decoder media3 never tunnels regardless of the flag —
|
||||
// used to skip selector churn for no-op flips (see
|
||||
// updateCurrentTunnelingState). True until an evaluation says otherwise.
|
||||
private var selectedAudioHasHwDecoder: Boolean = true
|
||||
private var tunnelingDisabledForVideoCodec: Boolean = false
|
||||
private var tunnelingDisabledForDecodedPcm: Boolean = false
|
||||
private var tunnelingDisabledForAudioRecovery: Boolean = false
|
||||
@@ -314,6 +320,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private var lastAudioRecoveryReason: String? = null
|
||||
private var lastAudioSinkError: String? = null
|
||||
private var loggedEwasteEac3Workaround: Boolean = false
|
||||
private val loggedDtsAppDecoderMimes = mutableSetOf<String>()
|
||||
private var lastTrueHdDirectOutputLogKey: String? = null
|
||||
private var loggedDecodedPcmTunnelingGuard: Boolean = false
|
||||
private var hasRenderedVideoFrameForMedia: Boolean = false
|
||||
@@ -564,8 +571,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
fun initialize(
|
||||
bufferSizeBytes: Int? = null,
|
||||
bufferSizeAuto: Boolean = false,
|
||||
tunnelingEnabled: Boolean = true,
|
||||
audioPassthroughEnabled: Boolean = false,
|
||||
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
|
||||
@@ -734,8 +739,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
// composition object and blanks palette-only fade updates (#1953).
|
||||
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
|
||||
|
||||
// Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
|
||||
// wrap MP4 extractors with DV converter when enabled.
|
||||
// Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM
|
||||
// variant, wrap MP4 extractors with the DV converter when enabled.
|
||||
// Reads this.dvMode each time (not captured) so DV7→8.1 retry can
|
||||
// change mode and reload without reinitializing the player.
|
||||
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
|
||||
@@ -780,21 +785,15 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
// Buffer budget. `bufferSizeBytes` carries the user's explicit Buffer Size choice; on
|
||||
// Auto it still arrives (Dart derives it for mpv's demuxer, which shares the property)
|
||||
// but `bufferSizeAuto` says to ignore it here, because mpv's demuxer and ExoPlayer's
|
||||
// sample allocator have different shapes and different failure modes.
|
||||
// Buffer budget. Derived natively from device memory (LoadControlPolicy);
|
||||
// mpv's demuxer sizes itself the same way in MpvPlayerCore.
|
||||
val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
val availableMB = (memoryInfo.availMem / (1024 * 1024)).toInt()
|
||||
val largeHeapMB = activityManager.largeMemoryClass
|
||||
|
||||
val targetBufferBytes = if (!bufferSizeAuto && bufferSizeBytes != null && bufferSizeBytes > 0) {
|
||||
bufferSizeBytes
|
||||
} else {
|
||||
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
}
|
||||
val targetBufferBytes = LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
|
||||
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
|
||||
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
|
||||
@@ -817,8 +816,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
emitLog(
|
||||
"info",
|
||||
"init",
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
|
||||
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit " +
|
||||
"(heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
|
||||
"(${resolvedTier.name.lowercase()}), " +
|
||||
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
|
||||
@@ -2318,7 +2317,20 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// DTS that is going to decode must not decode in a platform codec: on license-gated
|
||||
// Amlogic boxes (the Onn family) the platform decoder drains normally while rendering
|
||||
// silence (#1995). Bitstream-capable routes are untouched — media3 selects direct output
|
||||
// before consulting the decoder list, and the visible platform decoder keeps the
|
||||
// tunneling gate as it was.
|
||||
val forceDts = shouldForceFfmpegDtsDecode(
|
||||
mimeType,
|
||||
directOutputBlocked = { shouldBlockDirectAudioOutput(dtsProbeFormat(mimeType), "decoder selection") },
|
||||
routeCanBitstreamDts = { routeCanBitstreamDts(mimeType) }
|
||||
)
|
||||
if (forceDts && loggedDtsAppDecoderMimes.add(mimeType)) {
|
||||
emitLog("info", "decoder", "Using app decoder for $mimeType; the stream will decode and platform DTS decoders render silence on license-gated devices")
|
||||
}
|
||||
return forceDts
|
||||
}
|
||||
|
||||
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
|
||||
@@ -2427,6 +2439,33 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current route can bitstream [mimeType] at all: media3's raw direct path
|
||||
* ([AudioCapabilities]) or, for DTS-HD, the IEC 61937 carrier ([IecCarrierSink]). When this
|
||||
* is false the stream decodes regardless of the passthrough setting.
|
||||
*/
|
||||
private fun routeCanBitstreamDts(mimeType: String): Boolean {
|
||||
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
|
||||
val audioAttributes = buildMovieAudioAttributes()
|
||||
return try {
|
||||
AudioCapabilities
|
||||
.getCapabilities(activity, audioAttributes, null)
|
||||
.isPassthroughPlaybackSupported(dtsProbeFormat(mimeType), audioAttributes)
|
||||
} catch (e: Exception) {
|
||||
// An unanswerable probe biases toward FFmpeg decode. A wrong "can't bitstream" is benign
|
||||
// (bypass still wins before decoder selection); a wrong "can" leaves the silent platform
|
||||
// decode path reachable.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** DTS selection probe at the family's common shape; decoder selection only knows the mime. */
|
||||
private fun dtsProbeFormat(mimeType: String): Format = Format.Builder()
|
||||
.setSampleMimeType(mimeType)
|
||||
.setChannelCount(6)
|
||||
.setSampleRate(48_000)
|
||||
.build()
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isDirectPlaybackSupportedApi29(
|
||||
@@ -2658,6 +2697,18 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean {
|
||||
if (shouldTunnel == currentTunneledPlayback) return false
|
||||
// A switch to "off" that the selected audio decoder could never have
|
||||
// honored anyway is transparent to media3: tunnelled playback needs a
|
||||
// tunneling-capable audio codec (it owns the AV-sync clock), so a
|
||||
// software decoder already ignored the flag. Writing the selector
|
||||
// parameter regardless forces a renderer rebuild that can tear down a
|
||||
// live codec mid-queueInputBuffer — observed as
|
||||
// "queueInputBuffer ... Released state" right after tracks arrive from
|
||||
// the ffmpeg demuxer. Record the state without the churn.
|
||||
if (!shouldTunnel && !selectedAudioHasHwDecoder) {
|
||||
currentTunneledPlayback = false
|
||||
return false
|
||||
}
|
||||
currentTunneledPlayback = shouldTunnel
|
||||
val speed = exoPlayer?.playbackParameters?.speed ?: 1f
|
||||
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
|
||||
@@ -2685,6 +2736,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
val newDisabled = !hasHardwareAudioDecoder(mimeType)
|
||||
selectedAudioHasHwDecoder = !newDisabled
|
||||
if (newDisabled != tunnelingDisabledForAudioCodec) {
|
||||
tunnelingDisabledForAudioCodec = newDisabled
|
||||
emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}")
|
||||
@@ -4070,6 +4122,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
@@ -4077,11 +4130,17 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, matchResolution, onComplete)
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
// HDR content on an HDR display means the decoder's dataspace put the
|
||||
// display into HDR signaling; defer the rate restore past the HDR exit
|
||||
// (see FrameRateManager.clearVideoFrameRate).
|
||||
val transfer = currentVideoFormat?.colorInfo?.colorTransfer
|
||||
val hdrActive = (transfer == C.COLOR_TRANSFER_ST2084 || transfer == C.COLOR_TRANSFER_HLG) &&
|
||||
DoviBridge.displaySupportsHdr(activity)
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrActive)
|
||||
}
|
||||
|
||||
private fun computeFrameRate(timestamps: LongArray): Float {
|
||||
@@ -4172,7 +4231,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
"audioMimeType" to audioFormat?.sampleMimeType,
|
||||
"audioSampleRate" to audioFormat?.sampleRate,
|
||||
"audioChannels" to audioFormat?.channelCount,
|
||||
"audioBitrate" to audioFormat?.bitrate,
|
||||
"audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 },
|
||||
"audioDecoderName" to audioDecoderInitName,
|
||||
"audioOutputEncoding" to audioTrackConfig?.encoding,
|
||||
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.mpv.MpvPlayerCore
|
||||
@@ -40,6 +38,11 @@ class ExoPlayerPlugin :
|
||||
private val mainHandler get() = channels.mainHandler
|
||||
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||
private var playerCore: ExoPlayerCore? = null
|
||||
|
||||
// The Dart instanceId that created the current core. A `dispose` carrying a
|
||||
// different token lost the ownership race to a successor and must not tear
|
||||
// down that successor's session; it is acknowledged without touching it.
|
||||
private var coreInstanceId: Long? = null
|
||||
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
|
||||
private var usingMpvFallback: Boolean = false
|
||||
private var fallbackInProgress: Boolean = false
|
||||
@@ -108,8 +111,6 @@ class ExoPlayerPlugin :
|
||||
|
||||
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
|
||||
|
||||
private var configuredBufferSizeBytes: Int? = null
|
||||
|
||||
private var sessionGeneration = 0
|
||||
private var mediaGeneration = 0
|
||||
private var fallbackMediaGeneration: Int? = null
|
||||
@@ -119,7 +120,8 @@ class ExoPlayerPlugin :
|
||||
private var mpvForwardGeneration: Int? = null
|
||||
private var mpvSignalGate: MpvSignalGate? = null
|
||||
private var mpvCoreNeedsReplacement = false
|
||||
internal var createMpvCore: (Activity) -> MpvPlayerCore = { MpvPlayerCore(it) }
|
||||
internal var createMpvCore: (Activity) -> MpvPlayerCore =
|
||||
{ MpvPlayerCore(it, hardwareDecoding = fallbackHardwareDecoding()) }
|
||||
internal var initializeMpvCore: (MpvPlayerCore, (Boolean) -> Unit) -> Unit = { core, onInitialized ->
|
||||
core.initialize(onInitialized)
|
||||
}
|
||||
@@ -139,6 +141,12 @@ class ExoPlayerPlugin :
|
||||
// every codec in audio-spdif with no decode fallback, so the fallback core's value is
|
||||
// derived from the audio route at the moment mpv actually starts (#1703).
|
||||
private var audioPassthroughRequested = false
|
||||
|
||||
// `dv-conversion-mode` is not an mpv property and never reaches
|
||||
// pendingMpvProperties: Dart routes it through setDvConversionMode (see
|
||||
// PlayerAndroid.setProperty), so the fallback core has to be seeded from the
|
||||
// last configured value or it loses the fork's P7/P5 handling entirely.
|
||||
private var dvConversionMode = "auto"
|
||||
private var currentExternalSubtitles: List<Map<String, Any?>>? = null
|
||||
|
||||
// FlutterPlugin
|
||||
@@ -158,6 +166,7 @@ class ExoPlayerPlugin :
|
||||
val exoCore = playerCore
|
||||
val fallbackCore = mpvCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
mpvCore = null
|
||||
usingMpvFallback = false
|
||||
fallbackInProgress = false
|
||||
@@ -174,6 +183,7 @@ class ExoPlayerPlugin :
|
||||
currentExternalSubtitles = null
|
||||
pendingMpvProperties.clear()
|
||||
audioPassthroughRequested = false
|
||||
dvConversionMode = "auto"
|
||||
if (clearActivity) {
|
||||
activity = null
|
||||
activityBinding = null
|
||||
@@ -224,7 +234,7 @@ class ExoPlayerPlugin :
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"open" -> handleOpen(call, result)
|
||||
"play" -> handlePlay(result)
|
||||
"pause" -> handlePause(result)
|
||||
@@ -250,10 +260,6 @@ class ExoPlayerPlugin :
|
||||
)
|
||||
"getStats" -> handleGetStats(result)
|
||||
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
|
||||
"getHeapSize" -> {
|
||||
val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
result.success(am?.largeMemoryClass ?: 0)
|
||||
}
|
||||
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
|
||||
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
|
||||
"setVideoZoom" -> handleSetVideoZoom(call, result)
|
||||
@@ -291,20 +297,14 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
|
||||
// Auto sizing is decided natively (LoadControlPolicy). `bufferSizeBytes` still arrives
|
||||
// on Auto because Dart derives one for mpv's demuxer, which shares the property, and
|
||||
// the fallback replay below needs it.
|
||||
val bufferSizeAuto = call.argument<Boolean>("bufferSizeAuto") ?: false
|
||||
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
|
||||
val dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
|
||||
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: false
|
||||
dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
|
||||
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
|
||||
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
|
||||
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
|
||||
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
|
||||
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
|
||||
configuredBufferSizeBytes = bufferSizeBytes
|
||||
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
|
||||
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
|
||||
audioPassthroughRequested = audioPassthroughEnabled
|
||||
@@ -343,9 +343,8 @@ class ExoPlayerPlugin :
|
||||
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
|
||||
}
|
||||
playerCore = core
|
||||
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
|
||||
val success = core.initialize(
|
||||
bufferSizeBytes = bufferSizeBytes,
|
||||
bufferSizeAuto = bufferSizeAuto,
|
||||
tunnelingEnabled = tunnelingEnabled,
|
||||
audioPassthroughEnabled = audioPassthroughEnabled,
|
||||
bufferTier = bufferTier
|
||||
@@ -373,8 +372,15 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<Number>("instanceId")?.toLong()
|
||||
runOnMain {
|
||||
val owner = coreInstanceId
|
||||
if ((playerCore != null || mpvCore != null) && token != null && owner != null && token != owner) {
|
||||
Log.d(TAG, "Ignoring stale dispose (token=$token, core owner=$owner)")
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
teardownSession(clearActivity = false)
|
||||
Log.d(TAG, "Disposed")
|
||||
result.success(null)
|
||||
@@ -1015,14 +1021,19 @@ class ExoPlayerPlugin :
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
|
||||
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
||||
val matchResolution = call.argument<Boolean>("matchResolution") ?: false
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
||||
Log.d(
|
||||
TAG,
|
||||
"setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, " +
|
||||
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution"
|
||||
)
|
||||
val core = activeSurfaceCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
@@ -1123,12 +1134,24 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
if (usingMpvFallback) {
|
||||
result.success(false)
|
||||
// The fallback core owns the property now; dropping the write would
|
||||
// strand it on the mode ExoPlayer started with.
|
||||
val core = mpvCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
dvConversionMode = mode
|
||||
core.setProperty("dv-conversion-mode", mode) { outcome ->
|
||||
completeMpvPropertyResult(result, outcome, successValue = true)
|
||||
}
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
val handled = playerCore?.setDebugDvConversionMode(mode) == true
|
||||
if (handled) {
|
||||
// Keep the fallback seed on the value ExoPlayer is actually running.
|
||||
dvConversionMode = mode
|
||||
result.success(true)
|
||||
} else {
|
||||
result.error("INVALID_ARGS", "Invalid DV conversion mode: $mode", null)
|
||||
@@ -1301,6 +1324,16 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode intent for a fallback mpv core, read from the `hwdec` value Dart
|
||||
* already wrote for this session. It picks the core's initial vo chain
|
||||
* ([MpvPlayerCore.initialVideoOutput]), so a software-decoding session must
|
||||
* not inherit the hardware default: only gpu-next applies Dolby Vision RPU
|
||||
* reshaping, and a session that asked for software decode is usually the
|
||||
* one that needs it.
|
||||
*/
|
||||
private fun fallbackHardwareDecoding(): Boolean = pendingMpvProperties["hwdec"]?.let { it != "no" } ?: true
|
||||
|
||||
/**
|
||||
* Configure a freshly initialized MPV fallback core: replay the properties
|
||||
* and observers Dart registered against the ExoPlayer session, then resume
|
||||
@@ -1310,15 +1343,13 @@ class ExoPlayerPlugin :
|
||||
private fun prepareMpvFallback(core: MpvPlayerCore) {
|
||||
val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList()
|
||||
val observedProps = observedProperties.toList()
|
||||
val bufferSize = configuredBufferSizeBytes
|
||||
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
|
||||
core.setProperty("vo", "gpu")
|
||||
// hwdec is not seeded here — Dart's write in pendingMpvProperties is the
|
||||
// single source of the fallback core's chain.
|
||||
core.setProperty("ao", "audiotrack")
|
||||
|
||||
if (bufferSize != null && bufferSize > 0) {
|
||||
core.setProperty("demuxer-max-bytes", bufferSize.toString())
|
||||
}
|
||||
// Dart routes dv-conversion-mode through setDvConversionMode, so unlike
|
||||
// hwdec it never reaches pendingMpvProperties.
|
||||
core.setProperty("dv-conversion-mode", dvConversionMode)
|
||||
|
||||
for ((propName, propValue) in pendingProps) {
|
||||
core.setProperty(propName, propValue) { outcome ->
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* The IEC 61937 carrier tuple every packed bitstream rides: 192kHz, 7.1, PCM-16 shaped.
|
||||
*
|
||||
* This is the high-bitrate HDMI shape Kodi uses for both TrueHD/MAT and DTS-HD Master Audio, and
|
||||
* the one [supportsIecCarrier] probes. The 44.1kHz family would need a 176.4kHz sibling, which is
|
||||
* deliberately not built; streams from that family decode instead.
|
||||
*/
|
||||
internal object IecCarrier {
|
||||
const val SAMPLE_RATE = 192_000
|
||||
const val CHANNEL_COUNT = 8
|
||||
const val BYTES_PER_FRAME = CHANNEL_COUNT * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one codec's bitstream into access units and packs them into IEC 61937 bursts for
|
||||
* [IecCarrierSink].
|
||||
*
|
||||
* Implementations are not thread safe; the sink drives them from the playback thread only.
|
||||
*/
|
||||
internal interface IecCarrierPacker {
|
||||
|
||||
/**
|
||||
* Length in bytes of the access unit starting at [offset], or 0 when no unit boundary is
|
||||
* recognised there. May latch [unsupportedStream] when the boundary is recognisable but names a
|
||||
* framing this carrier cannot ride.
|
||||
*/
|
||||
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int
|
||||
|
||||
/**
|
||||
* Packs one access unit, returning a completed burst or null when this unit did not finish one.
|
||||
*
|
||||
* Returned buffers are owned by the packer and reused, alternating between two so a burst handed
|
||||
* downstream stays valid while the next one fills. Callers must submit or copy it before the
|
||||
* second following call.
|
||||
*/
|
||||
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer?
|
||||
|
||||
/**
|
||||
* True when the bitstream announced a shape this carrier cannot ride. The packer emits nothing
|
||||
* in that state; the sink latches the stream onto the decoder instead.
|
||||
*/
|
||||
val unsupportedStream: Boolean
|
||||
|
||||
/** Drops all carrier state. Called on flush/seek: bursts must not straddle a discontinuity. */
|
||||
fun reset()
|
||||
}
|
||||
+106
-65
@@ -16,13 +16,16 @@ import java.nio.ByteBuffer
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Routes Dolby TrueHD through a MAT/IEC 61937 carrier, and everything else through the normal sink
|
||||
* (#1804).
|
||||
* Routes Dolby TrueHD (#1804) and DTS-HD Master Audio (#1988) through an IEC 61937 carrier, and
|
||||
* everything else through the normal sink.
|
||||
*
|
||||
* Android will not bitstream raw TrueHD on the TV routes measured for this issue: the platform
|
||||
* reports `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1
|
||||
* as bitstream-capable. Kodi models the same split and packs the carrier itself; media3 only ever
|
||||
* hands Android raw TrueHD, at the stream rate. This sink adds the missing path.
|
||||
* Android will not bitstream raw TrueHD on the TV routes measured for #1804: the platform reports
|
||||
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
|
||||
* bitstream-capable. Raw `ENCODING_DTS_HD` is worse on Fire OS: the route advertises it, the
|
||||
* AudioTrack initialises and drains, and the receiver hears silence, because the encoding only
|
||||
* means "basic profile" there (#1988). Kodi models the same split and packs the carrier itself;
|
||||
* media3 only ever hands Android the raw encodings, at the stream rate. This sink adds the
|
||||
* missing path, with one [IecCarrierPacker] per codec.
|
||||
*
|
||||
* **Why two delegates rather than one sink with the processors held inactive.** The carrier is a
|
||||
* bit-exact byte stream that happens to be shaped like PCM. Any sample mutation — downmix, Sonic,
|
||||
@@ -39,7 +42,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* delegates so either can be activated later; per-stream calls go to the active one alone.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class TrueHdCarrierSink(
|
||||
internal class IecCarrierSink(
|
||||
private val defaultSink: AudioSink,
|
||||
private val carrierSink: AudioSink,
|
||||
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
|
||||
@@ -49,39 +52,39 @@ internal class TrueHdCarrierSink(
|
||||
private val log: ((String, String, String) -> Unit)? = null
|
||||
) : AudioSink {
|
||||
|
||||
private companion object {
|
||||
/** One MAT frame is exactly one carrier period: 3840 frames at 192kHz. */
|
||||
const val CARRIER_BURST_DURATION_US =
|
||||
TrueHdMatPacker.MAT_PKT_OFFSET.toLong() / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME *
|
||||
1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
|
||||
}
|
||||
private val matPacker = TrueHdMatPacker()
|
||||
private val dtsHdPacker = DtsHdIecPacker()
|
||||
|
||||
private val packer = TrueHdMatPacker()
|
||||
/** The packer for the configured stream; chosen by mime type in [configure]. */
|
||||
private var activePacker: IecCarrierPacker = matPacker
|
||||
|
||||
private var active: AudioSink = defaultSink
|
||||
private var carrierActive = false
|
||||
private var loggedCoreStrip = false
|
||||
|
||||
/** A burst the delegate refused; it must be placed before any further input is consumed. */
|
||||
private var pendingBurst: ByteBuffer? = null
|
||||
private var pendingBurstTimeUs: Long = 0
|
||||
|
||||
/**
|
||||
* Anchor for carrier timestamps, and how many bursts have been emitted since it.
|
||||
* Anchor for carrier timestamps, and how many carrier frames have been emitted since it.
|
||||
*
|
||||
* Each MAT frame is exactly one carrier period of audio, so timestamps are derived from the
|
||||
* cadence rather than from whichever access unit happened to close the frame. Handing the sink
|
||||
* the closing unit's own presentation time drifts against the time it derives from written
|
||||
* frames, which it reports as a discontinuity on nearly every frame.
|
||||
* Every burst is a whole number of carrier frames, so timestamps are derived from the cadence
|
||||
* rather than from whichever access unit happened to close the burst. Handing the sink the
|
||||
* closing unit's own presentation time drifts against the time it derives from written frames,
|
||||
* which it reports as a discontinuity on nearly every burst. Counting frames rather than bursts
|
||||
* keeps the arithmetic exact for burst durations that are not whole microseconds (a DTS-HD
|
||||
* burst is 10666.67us).
|
||||
*/
|
||||
private var carrierAnchorUs: Long = C.TIME_UNSET
|
||||
private var burstsSinceAnchor: Long = 0
|
||||
private var carrierFramesSinceAnchor: Long = 0
|
||||
|
||||
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var sinkListener: AudioSink.Listener? = null
|
||||
|
||||
/**
|
||||
* Latched when a stream's bitstream contradicts the rate its container announced, which selection
|
||||
* was made from.
|
||||
* Latched when a stream's bitstream contradicts the shape its container announced, which
|
||||
* selection was made from.
|
||||
*
|
||||
* Deliberately outlives [flush] and [reset]: media3 resets every renderer disabled by a new
|
||||
* selection before enabling the replacement (ExoPlayerImplInternal.enableRenderers), and both
|
||||
@@ -114,11 +117,11 @@ internal class TrueHdCarrierSink(
|
||||
* [directOutputBlocked] already reports.
|
||||
*
|
||||
* The rate family is decided from the format rather than from the packer, which only learns it
|
||||
* from a major sync once buffers are already flowing — far too late for a selection that happens
|
||||
* before configure.
|
||||
* from the bitstream once buffers are already flowing — far too late for a selection that
|
||||
* happens before configure.
|
||||
*/
|
||||
private fun shouldUseCarrier(format: Format): Boolean {
|
||||
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false
|
||||
if (!isTrueHd(format) && !isDtsHd(format)) return false
|
||||
if (mismatchGeneration == mediaGeneration.get()) return false
|
||||
if (!isCarrierRateFamily(format.sampleRate)) return false
|
||||
if (playbackParameters.speed != 1f) return false
|
||||
@@ -136,15 +139,15 @@ internal class TrueHdCarrierSink(
|
||||
private fun carrierFormat(): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setPcmEncoding(C.ENCODING_PCM_16BIT)
|
||||
.setChannelCount(TrueHdMatPacker.CARRIER_CHANNEL_COUNT)
|
||||
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
|
||||
.setChannelCount(IecCarrier.CHANNEL_COUNT)
|
||||
.setSampleRate(IecCarrier.SAMPLE_RATE)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* TrueHD is deliberately binary: the carrier, or decoded PCM. Never media3's own raw TrueHD path.
|
||||
*
|
||||
* That path builds an `ENCODING_DOLBY_TRUEHD` track at the *stream* rate, which is the
|
||||
* configuration this issue is about — one box takes a single write and never advances its
|
||||
* configuration #1804 is about — one box takes a single write and never advances its
|
||||
* playback head, another freezes for ten seconds, and the third declines it and decodes anyway.
|
||||
* Even Kodi's raw fallback is a different thing: it only offers raw TrueHD after verifying it at
|
||||
* 192kHz, which media3 never requests. So when the carrier is unavailable — no IEC route, a speed
|
||||
@@ -153,15 +156,30 @@ internal class TrueHdCarrierSink(
|
||||
*/
|
||||
private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD
|
||||
|
||||
/**
|
||||
* DTS-HD is binary only while the carrier route exists: the carrier, or decoded PCM. Falling
|
||||
* through to media3's raw `ENCODING_DTS_HD` path on such a route would land on exactly the
|
||||
* silent configuration #1988 is about — the route that advertises the carrier and raw DTS-HD at
|
||||
* once is the one whose raw path renders silence. Without a carrier route the raw path is the
|
||||
* pre-carrier behavior and is left alone: those routes never had the carrier to lose, and some
|
||||
* of them bitstream raw DTS-HD genuinely.
|
||||
*
|
||||
* DTS Express deliberately stays off the carrier: its mime carries a `;profile=lbr` suffix, so
|
||||
* the equality below excludes it and it keeps decoding as before.
|
||||
*/
|
||||
private fun isDtsHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_DTS_HD
|
||||
|
||||
override fun supportsFormat(format: Format): Boolean = when {
|
||||
shouldUseCarrier(format) -> true
|
||||
isTrueHd(format) -> false
|
||||
isDtsHd(format) && carrierRouteAvailable() -> false
|
||||
else -> defaultSink.supportsFormat(format)
|
||||
}
|
||||
|
||||
override fun getFormatSupport(format: Format): Int = when {
|
||||
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
|
||||
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
else -> defaultSink.getFormatSupport(format)
|
||||
}
|
||||
|
||||
@@ -173,16 +191,18 @@ internal class TrueHdCarrierSink(
|
||||
"info",
|
||||
"audio",
|
||||
if (useCarrier) {
|
||||
"TrueHD via MAT/IEC 61937 carrier at ${TrueHdMatPacker.CARRIER_SAMPLE_RATE}Hz/" +
|
||||
"${TrueHdMatPacker.CARRIER_CHANNEL_COUNT}ch"
|
||||
"${carrierCodecName(inputFormat)} via IEC 61937 carrier at ${IecCarrier.SAMPLE_RATE}Hz/" +
|
||||
"${IecCarrier.CHANNEL_COUNT}ch"
|
||||
} else {
|
||||
"Leaving the MAT carrier; audio returns to the normal sink"
|
||||
"Leaving the IEC 61937 carrier; audio returns to the normal sink"
|
||||
}
|
||||
)
|
||||
}
|
||||
carrierActive = useCarrier
|
||||
configuredGeneration = mediaGeneration.get()
|
||||
activePacker = if (isDtsHd(inputFormat)) dtsHdPacker else matPacker
|
||||
active = if (useCarrier) carrierSink else defaultSink
|
||||
loggedCoreStrip = false
|
||||
discardCarrierState()
|
||||
|
||||
if (useCarrier) {
|
||||
@@ -197,6 +217,8 @@ internal class TrueHdCarrierSink(
|
||||
}
|
||||
}
|
||||
|
||||
private fun carrierCodecName(format: Format): String = if (isDtsHd(format)) "DTS-HD" else "TrueHD (MAT)"
|
||||
|
||||
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
|
||||
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
||||
|
||||
@@ -217,37 +239,30 @@ internal class TrueHdCarrierSink(
|
||||
|
||||
var offset = 0
|
||||
while (offset < remaining.size) {
|
||||
val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size)
|
||||
val length = activePacker.accessUnitLength(remaining, offset, remaining.size)
|
||||
if (activePacker.unsupportedStream) return latchMismatch()
|
||||
if (length == 0) {
|
||||
// Not a unit boundary we recognise. Consuming the tail keeps the stream moving; trying to
|
||||
// resynchronise mid-carrier would splice a frame.
|
||||
buffer.position(buffer.limit())
|
||||
return true
|
||||
}
|
||||
val burst = packer.packAccessUnit(remaining, offset, length)
|
||||
if (packer.unsupportedRateFamily) {
|
||||
// Selection is made from Format.sampleRate, so the bitstream disagrees with its container.
|
||||
// The packer emits nothing in that state; consuming here would turn the stream into
|
||||
// silence. Leave this unit in the buffer, latch the carrier off, and ask for reselection so
|
||||
// the decoder takes over and receives it.
|
||||
if (mismatchGeneration != configuredGeneration) {
|
||||
mismatchGeneration = configuredGeneration
|
||||
log?.invoke(
|
||||
"warn",
|
||||
"audio",
|
||||
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
|
||||
"leaving the carrier so it decodes"
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
return false
|
||||
}
|
||||
val burst = activePacker.packAccessUnit(remaining, offset, length)
|
||||
if (activePacker.unsupportedStream) return latchMismatch()
|
||||
offset += length
|
||||
buffer.position(buffer.position() + length)
|
||||
if (burst == null) continue
|
||||
if (activePacker === dtsHdPacker && dtsHdPacker.strippedToCore && !loggedCoreStrip) {
|
||||
loggedCoreStrip = true
|
||||
log?.invoke(
|
||||
"warn",
|
||||
"audio",
|
||||
"DTS-HD MA bitrate exceeds the IEC 61937 carrier; sending the DTS core substream for ~60s"
|
||||
)
|
||||
}
|
||||
|
||||
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
|
||||
burstsSinceAnchor++
|
||||
val burstTimeUs = carrierAnchorUs + carrierFramesSinceAnchor * 1_000_000L / IecCarrier.SAMPLE_RATE
|
||||
carrierFramesSinceAnchor += burst.remaining().toLong() / IecCarrier.BYTES_PER_FRAME
|
||||
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
|
||||
pendingBurst = burst
|
||||
pendingBurstTimeUs = burstTimeUs
|
||||
@@ -257,10 +272,35 @@ internal class TrueHdCarrierSink(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The bitstream announced a shape the carrier cannot ride, which selection could not see in the
|
||||
* container's Format. The packer emits nothing in that state; consuming here would turn the
|
||||
* stream into silence. Leave the unit in the buffer, latch the carrier off, and ask for
|
||||
* reselection so the decoder takes over and receives it.
|
||||
*/
|
||||
private fun latchMismatch(): Boolean {
|
||||
if (mismatchGeneration != configuredGeneration) {
|
||||
mismatchGeneration = configuredGeneration
|
||||
log?.invoke(
|
||||
"warn",
|
||||
"audio",
|
||||
if (activePacker === dtsHdPacker) {
|
||||
"DTS-HD bitstream cannot ride the 192kHz carrier (44.1kHz-family core or non-standard framing); " +
|
||||
"leaving the carrier so it decodes"
|
||||
} else {
|
||||
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
|
||||
"leaving the carrier so it decodes"
|
||||
}
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getCurrentPositionUs(sourceEnded: Boolean): Long = active.getCurrentPositionUs(sourceEnded)
|
||||
|
||||
override fun playToEndOfStream() {
|
||||
// A partially filled MAT frame cannot be emitted; up to 20ms is dropped at the end of a stream.
|
||||
// A partially filled burst cannot be emitted; up to one burst is dropped at the end of a stream.
|
||||
active.playToEndOfStream()
|
||||
}
|
||||
|
||||
@@ -285,11 +325,12 @@ internal class TrueHdCarrierSink(
|
||||
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
|
||||
|
||||
private fun discardCarrierState() {
|
||||
packer.reset()
|
||||
matPacker.reset()
|
||||
dtsHdPacker.reset()
|
||||
pendingBurst = null
|
||||
// Re-anchor on the next burst: after a seek the carrier restarts from a new media time.
|
||||
carrierAnchorUs = C.TIME_UNSET
|
||||
burstsSinceAnchor = 0
|
||||
carrierFramesSinceAnchor = 0
|
||||
}
|
||||
|
||||
// --- Persistent state: mirrored, so either delegate can be activated later ---
|
||||
@@ -321,23 +362,23 @@ internal class TrueHdCarrierSink(
|
||||
if (isUnitSpeed) playbackParameters else PlaybackParameters.DEFAULT
|
||||
)
|
||||
|
||||
// Crossing 1x changes whether TrueHD may ride the carrier, but nothing re-asks on its own:
|
||||
// the renderer only consults the sink when capabilities are invalidated. Rebuilding the track
|
||||
// selector parameters is not enough either — DefaultTrackSelector skips invalidation when the
|
||||
// rebuilt parameters compare equal. This is the path media3 itself uses for a route change,
|
||||
// and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and TrueHD moves
|
||||
// between the carrier and the decoder.
|
||||
// Crossing 1x changes whether a bitstream may ride the carrier, but nothing re-asks on its
|
||||
// own: the renderer only consults the sink when capabilities are invalidated. Rebuilding the
|
||||
// track selector parameters is not enough either — DefaultTrackSelector skips invalidation
|
||||
// when the rebuilt parameters compare equal. This is the path media3 itself uses for a route
|
||||
// change, and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and the
|
||||
// stream moves between the carrier and the decoder.
|
||||
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
|
||||
log?.invoke(
|
||||
"info",
|
||||
"audio",
|
||||
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the TrueHD carrier"
|
||||
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the bitstream carrier"
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether TrueHD is currently riding the carrier. Read by the core when speed changes. */
|
||||
/** Whether the configured stream is currently riding the carrier. */
|
||||
val isCarrierActive: Boolean
|
||||
get() = carrierActive
|
||||
|
||||
@@ -345,7 +386,7 @@ internal class TrueHdCarrierSink(
|
||||
* While the carrier is live the delegate is deliberately pinned to 1x, but the player polls this
|
||||
* through the media clock and adopts whatever it reads. Reporting the delegate's value would push
|
||||
* the pinned 1x back and silently undo the user's speed change, so the requested parameters are
|
||||
* reported instead; the reselection triggered above then moves TrueHD onto the decoder, which
|
||||
* reported instead; the reselection triggered above then moves the stream onto the decoder, which
|
||||
* really can apply them.
|
||||
*/
|
||||
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
|
||||
@@ -407,7 +448,7 @@ internal class TrueHdCarrierSink(
|
||||
|
||||
/**
|
||||
* Called by the owner immediately before a new media item is set, which is the only point where
|
||||
* "this stream lied about its rate" stops being true and the carrier can be offered again.
|
||||
* "this stream lied about its shape" stops being true and the carrier can be offered again.
|
||||
*/
|
||||
fun beginMediaItem() {
|
||||
mediaGeneration.incrementAndGet()
|
||||
@@ -151,14 +151,14 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
)
|
||||
}
|
||||
|
||||
private var trueHdCarrierSink: TrueHdCarrierSink? = null
|
||||
private var iecCarrierSink: IecCarrierSink? = null
|
||||
|
||||
/**
|
||||
* Clears per-stream carrier state that must survive renderer resets but not a new media item.
|
||||
* Call before setting a new source; see [TrueHdCarrierSink.beginMediaItem].
|
||||
* Call before setting a new source; see [IecCarrierSink.beginMediaItem].
|
||||
*/
|
||||
fun beginMediaItem() {
|
||||
trueHdCarrierSink?.beginMediaItem()
|
||||
iecCarrierSink?.beginMediaItem()
|
||||
}
|
||||
|
||||
override fun buildAudioSink(
|
||||
@@ -204,17 +204,17 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
audioDiagnosticsLogger
|
||||
)
|
||||
|
||||
return TrueHdCarrierSink(
|
||||
return IecCarrierSink(
|
||||
defaultSink = processedSink,
|
||||
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
||||
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
|
||||
carrierRouteAvailable = { supportsIecCarrier(context) },
|
||||
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
||||
log = audioDiagnosticsLogger
|
||||
).also { trueHdCarrierSink = it }
|
||||
).also { iecCarrierSink = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The delegate that carries packed TrueHD (#1804).
|
||||
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
|
||||
*
|
||||
* Deliberately separate from the processed sink, and deliberately barren: an empty
|
||||
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.nio.ByteBuffer
|
||||
*
|
||||
* Not thread safe; the sink drives it from the playback thread only.
|
||||
*/
|
||||
internal class TrueHdMatPacker {
|
||||
internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
|
||||
internal companion object {
|
||||
/** Payload bytes in one MAT frame. */
|
||||
@@ -33,11 +33,6 @@ internal class TrueHdMatPacker {
|
||||
/** Bytes from the start of one burst to the next, including preamble and trailing gap. */
|
||||
const val MAT_PKT_OFFSET = 61440
|
||||
|
||||
/** Carrier the packed stream must be played at. */
|
||||
const val CARRIER_SAMPLE_RATE = 192_000
|
||||
const val CARRIER_CHANNEL_COUNT = 8
|
||||
const val CARRIER_BYTES_PER_FRAME = CARRIER_CHANNEL_COUNT * 2
|
||||
|
||||
private const val BURST_HEADER_SIZE = 8
|
||||
private const val SYNCWORD1 = 0xF872
|
||||
private const val SYNCWORD2 = 0x4E1F
|
||||
@@ -99,7 +94,7 @@ internal class TrueHdMatPacker {
|
||||
private var samplesPerFrame = 0
|
||||
|
||||
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
|
||||
fun reset() {
|
||||
override fun reset() {
|
||||
matBufferIndex = 0
|
||||
matBufferFilled = 0
|
||||
previousTiming = 0
|
||||
@@ -107,7 +102,7 @@ internal class TrueHdMatPacker {
|
||||
samplesPerFrame = 0
|
||||
// The family is re-learned from the next major sync. Leaving it latched here would make every
|
||||
// later stream on this packer emit nothing.
|
||||
unsupportedRateFamily = false
|
||||
unsupportedStream = false
|
||||
java.util.Arrays.fill(matBuffers[0], 0)
|
||||
java.util.Arrays.fill(matBuffers[1], 0)
|
||||
}
|
||||
@@ -119,9 +114,11 @@ internal class TrueHdMatPacker {
|
||||
* whole path is built around. Rather than carry a second carrier configuration for a combination
|
||||
* that is essentially absent from real media, the sink reads this and falls back to decoding.
|
||||
*/
|
||||
var unsupportedRateFamily: Boolean = false
|
||||
override var unsupportedStream: Boolean = false
|
||||
private set
|
||||
|
||||
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int = Companion.accessUnitLength(data, offset, limit)
|
||||
|
||||
/**
|
||||
* Packs one access unit, returning a completed burst when this unit finished a MAT frame.
|
||||
*
|
||||
@@ -137,7 +134,7 @@ internal class TrueHdMatPacker {
|
||||
* At most one burst can complete per access unit: padding is bounded below half a MAT frame and
|
||||
* an access unit is far smaller, so a single unit cannot span two frame boundaries.
|
||||
*/
|
||||
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
|
||||
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
|
||||
if (length < MIN_ACCESS_UNIT_LENGTH) return null
|
||||
|
||||
if (hasMajorSync(data, offset, offset + length)) {
|
||||
@@ -148,11 +145,11 @@ internal class TrueHdMatPacker {
|
||||
else -> return null
|
||||
}
|
||||
// Bit 3 selects the 44.1kHz family, which rides a 176.4kHz carrier we do not build.
|
||||
unsupportedRateFamily = (rateBits and 8) != 0
|
||||
if (unsupportedRateFamily) return null
|
||||
unsupportedStream = (rateBits and 8) != 0
|
||||
if (unsupportedStream) return null
|
||||
samplesPerFrame = 40 shl (rateBits and 3)
|
||||
}
|
||||
if (unsupportedRateFamily || samplesPerFrame == 0) return null
|
||||
if (unsupportedStream || samplesPerFrame == 0) return null
|
||||
|
||||
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
|
||||
var paddingRemaining = 0
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
@@ -8,6 +11,9 @@ import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
import java.io.EOFException
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Inflater
|
||||
|
||||
/**
|
||||
* Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
|
||||
@@ -16,8 +22,19 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
* stripping). This subclass intercepts the compression algorithm during track
|
||||
* header parsing:
|
||||
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
|
||||
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
|
||||
* - Skips ContentCompSettings for zlib tracks (not applicable)
|
||||
* - For text subtitle tracks, inflates the block payload *before* the parent
|
||||
* parses it (see below). For every other zlib track, wraps TrackOutputs with
|
||||
* ZlibInflatingTrackOutput to decompress per-sample data.
|
||||
*
|
||||
* Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput
|
||||
* level: MatroskaExtractor rewrites their samples in-place before any TrackOutput
|
||||
* runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to
|
||||
* the still-compressed payload and truncates the sample at the first NUL byte,
|
||||
* which deflate streams routinely contain (#2023). AssTrackOutput additionally
|
||||
* feeds that same internal buffer straight to libass. So for zlib text tracks the
|
||||
* frame payload is inflated at the block level, before the parent's sample
|
||||
* assembly, and the TrackOutput wrapper is left inactive.
|
||||
*
|
||||
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
|
||||
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
|
||||
@@ -36,15 +53,43 @@ class ZlibMatroskaExtractor(
|
||||
private const val ID_TRACK_ENTRY = 0xAE
|
||||
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
|
||||
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
|
||||
private const val ID_CONTENT_COMPRESSION = 0x5034
|
||||
private const val ID_SIMPLE_BLOCK = 0xA3
|
||||
private const val ID_BLOCK = 0xA1
|
||||
|
||||
/**
|
||||
* Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix
|
||||
* plus NUL truncation) before any TrackOutput runs — the authority is the
|
||||
* prefix list in MatroskaExtractor.writeSampleData.
|
||||
*/
|
||||
private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT")
|
||||
|
||||
/** Subtitle blocks are tiny; anything above this passes through untouched. */
|
||||
private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024
|
||||
}
|
||||
|
||||
private var zlibOutput: ZlibExtractorOutputWrapper? = null
|
||||
private var latmOutput: LatmExtractorOutputWrapper? = null
|
||||
private var currentTrackUsesZlib = false
|
||||
|
||||
/** Track numbers whose blocks are inflated before the parent parses them. */
|
||||
private val zlibTextTrackNumbers = mutableSetOf<Int>()
|
||||
private val blockInflater = Inflater()
|
||||
private var blockBuf = ByteArray(0)
|
||||
private val peekBuf = ByteArray(8)
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
|
||||
// ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for
|
||||
// zlib tracks entirely — the mere presence of ContentCompression means zlib
|
||||
// until an explicit ContentCompAlgo says otherwise. media3 ignores an empty
|
||||
// ContentCompression and would silently emit compressed samples.
|
||||
if (id == ID_CONTENT_COMPRESSION) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise")
|
||||
}
|
||||
|
||||
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
|
||||
// LATM layers (zlib outermost so inflation runs before LATM parsing).
|
||||
if (id == ID_SEGMENT && zlibOutput == null) {
|
||||
@@ -59,12 +104,17 @@ class ZlibMatroskaExtractor(
|
||||
}
|
||||
|
||||
override fun integerElement(id: Int, value: Long) {
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM) {
|
||||
if (value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
}
|
||||
// Explicit non-zlib algorithm: header stripping (3) is handled by the
|
||||
// parent; anything else makes the parent throw, matching stock behavior.
|
||||
currentTrackUsesZlib = false
|
||||
}
|
||||
super.integerElement(id, value)
|
||||
}
|
||||
@@ -76,27 +126,47 @@ class ZlibMatroskaExtractor(
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) &&
|
||||
zlibTextTrackNumbers.isNotEmpty() &&
|
||||
contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES &&
|
||||
peekBlockTrackNumber(input) in zlibTextTrackNumbers
|
||||
) {
|
||||
inflateTextBlock(id, contentSize, input)
|
||||
return
|
||||
}
|
||||
super.binaryElement(id, contentSize, input)
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
var zlibTextTrackNumber: Int? = null
|
||||
if (id == ID_TRACK_ENTRY) {
|
||||
// Must mark before super — the track output is created inside super's
|
||||
// endMasterElement, and the x-unknown format must never reach the queue.
|
||||
// Must inspect before super — the track output is created inside super's
|
||||
// endMasterElement, and the current track is cleared afterwards.
|
||||
val track = getCurrentTrack(id)
|
||||
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
|
||||
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
|
||||
latmOutput?.markNextTrackLatm()
|
||||
}
|
||||
if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) {
|
||||
zlibTextTrackNumber = track.number
|
||||
}
|
||||
}
|
||||
|
||||
val wasZlib = currentTrackUsesZlib
|
||||
super.endMasterElement(id)
|
||||
|
||||
if (id == ID_TRACK_ENTRY && wasZlib) {
|
||||
zlibOutput?.activateLast()
|
||||
currentTrackUsesZlib = false
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
if (zlibTextTrackNumber != null) {
|
||||
// Text subtitle samples are rewritten inside the parent before any
|
||||
// TrackOutput runs, so the TrackOutput wrapper stays inactive and the
|
||||
// block payload is inflated in binaryElement instead.
|
||||
zlibTextTrackNumbers.add(zlibTextTrackNumber)
|
||||
Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level")
|
||||
} else {
|
||||
zlibOutput?.activateLast()
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +176,64 @@ class ZlibMatroskaExtractor(
|
||||
super.seek(position, timeUs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Peeks the EBML varint at the block start — the block's track number — without
|
||||
* consuming input. Returns null for malformed varints or truncated input; the
|
||||
* parent then produces the canonical failure for the untouched stream.
|
||||
*/
|
||||
private fun peekBlockTrackNumber(input: ExtractorInput): Int? {
|
||||
try {
|
||||
input.peekFully(peekBuf, 0, 1)
|
||||
val first = peekBuf[0].toInt() and 0xFF
|
||||
if (first == 0) return null
|
||||
val length = Integer.numberOfLeadingZeros(first) - 23
|
||||
var value = (first and (0xFF ushr length)).toLong()
|
||||
if (length > 1) {
|
||||
input.peekFully(peekBuf, 1, length - 1)
|
||||
for (i in 1 until length) {
|
||||
value = (value shl 8) or (peekBuf[i].toLong() and 0xFF)
|
||||
}
|
||||
}
|
||||
return if (value <= Int.MAX_VALUE) value.toInt() else null
|
||||
} catch (_: EOFException) {
|
||||
return null
|
||||
} finally {
|
||||
input.resetPeekPosition()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers one text-subtitle block, inflates its frame payload, and hands the
|
||||
* parent a block whose payload is plaintext. A block that cannot be rewritten
|
||||
* (laced, corrupt, or over-bound) passes through byte-identical.
|
||||
*/
|
||||
private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
val basePosition = input.position
|
||||
if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2))
|
||||
input.readFully(blockBuf, 0, contentSize)
|
||||
val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater)
|
||||
if (rewritten == null) {
|
||||
Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)")
|
||||
super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition))
|
||||
} else {
|
||||
super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition))
|
||||
}
|
||||
}
|
||||
|
||||
private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit)
|
||||
|
||||
private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader {
|
||||
private var position = 0
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position == limit) return C.RESULT_END_OF_INPUT
|
||||
val count = minOf(length, limit - position)
|
||||
System.arraycopy(data, position, buffer, offset, count)
|
||||
position += count
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
|
||||
* Tracks are created inactive; activateLast() enables inflation for the most recently
|
||||
@@ -138,3 +266,50 @@ class ZlibMatroskaExtractor(
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */
|
||||
internal const val MIN_BLOCK_BYTES = 5
|
||||
|
||||
/**
|
||||
* Rewrites one unlaced Matroska block whose frame payload is zlib-compressed:
|
||||
* returns the unchanged block header followed by the inflated payload, or null
|
||||
* when the block must pass through unchanged — lacing (never produced for text
|
||||
* subtitle tracks), a truncated or corrupt deflate stream, or an inflated size
|
||||
* beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening.
|
||||
*/
|
||||
internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? {
|
||||
if (limit < MIN_BLOCK_BYTES) return null
|
||||
val first = data[0].toInt() and 0xFF
|
||||
if (first == 0) return null // track-number varint longer than 8 bytes
|
||||
val varintLength = Integer.numberOfLeadingZeros(first) - 23
|
||||
val headerLength = varintLength + 3 // varint + 2-byte timecode + flags
|
||||
if (limit <= headerLength) return null
|
||||
if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced
|
||||
|
||||
val payloadLength = limit - headerLength
|
||||
val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024)
|
||||
val maxInflatedBytes = 16 * 1024 * 1024
|
||||
inflater.reset()
|
||||
inflater.setInput(data, headerLength, payloadLength)
|
||||
var buf = ByteArray(maxOf(4096, payloadLength * 4))
|
||||
var written = 0
|
||||
try {
|
||||
while (true) {
|
||||
if (written == buf.size) {
|
||||
if (buf.size >= maxInflatedBytes) return null
|
||||
buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2))
|
||||
}
|
||||
val count = inflater.inflate(buf, written, buf.size - written)
|
||||
written += count
|
||||
if (written > ratioBound) return null
|
||||
if (inflater.finished()) break
|
||||
if (count == 0) return null // truncated stream or preset-dictionary request
|
||||
}
|
||||
} catch (_: DataFormatException) {
|
||||
return null
|
||||
}
|
||||
return ByteArray(headerLength + written).also {
|
||||
System.arraycopy(data, 0, it, 0, headerLength)
|
||||
System.arraycopy(buf, 0, it, headerLength, written)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
/**
|
||||
* Demuxer cache budget derived from the device heap class.
|
||||
*
|
||||
* mpv has no device-memory awareness: `demuxer-max-bytes` defaults to a fixed
|
||||
* 150 MiB forward (+50 MiB back) on every device and the demuxer fills
|
||||
* whatever it is allowed, which crowds a 1 GB TV box until Android's
|
||||
* low-memory killer takes the whole app. Tiered off
|
||||
* [android.app.ActivityManager.getLargeMemoryClass], the same signal
|
||||
* `stream_buffer_sizing.dart` and ExoPlayer's `LoadControlPolicy` use.
|
||||
*
|
||||
* Applied as pre-init *options* in [MpvPlayerCore], so a `demuxer-max-bytes`
|
||||
* line in the user's mpv.conf still wins.
|
||||
*/
|
||||
data class DemuxerBudget(val aheadBytes: Long, val backBytes: Long) {
|
||||
companion object {
|
||||
private const val MIB = 1024L * 1024L
|
||||
|
||||
/** Null for an unknown class (<= 0): callers keep mpv's own defaults. */
|
||||
fun forHeapClassMB(largeMemoryClassMB: Int): DemuxerBudget? = when {
|
||||
largeMemoryClassMB <= 0 -> null
|
||||
largeMemoryClassMB <= 256 -> DemuxerBudget(aheadBytes = 32 * MIB, backBytes = 16 * MIB)
|
||||
largeMemoryClassMB <= 512 -> DemuxerBudget(aheadBytes = 64 * MIB, backBytes = 32 * MIB)
|
||||
else -> DemuxerBudget(aheadBytes = 100 * MIB, backBytes = 48 * MIB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.opengl.EGL14
|
||||
|
||||
/**
|
||||
* Which mpv `egl-output-format` this device can pair with a BT.2020 PQ window
|
||||
* surface, or null when HDR GL output is unavailable. 10-bit fixed point is
|
||||
* preferred; fp16 is the fallback because some drivers (Tegra among them)
|
||||
* expose the PQ colorspace but no 1010102 window config.
|
||||
*
|
||||
* Both halves are required by the mpv side: the fork's android GL context asks
|
||||
* for `EGL_EXT_gl_colorspace_bt2020_pq` on the window surface, and the app
|
||||
* pairs it with an `egl-output-format` that makes mpv's EGL config selection
|
||||
* fail outright when no matching config exists - so this probe must be
|
||||
* consulted before those options are ever set.
|
||||
*/
|
||||
internal object EglHdrCaps {
|
||||
private const val EGL_COLOR_COMPONENT_TYPE_EXT = 0x3339
|
||||
private const val EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT = 0x333B
|
||||
|
||||
private object Unprobed
|
||||
|
||||
@Volatile private var cached: Any? = Unprobed
|
||||
|
||||
fun pqOutputFormat(): String? {
|
||||
val value = cached
|
||||
if (value !== Unprobed) return value as String?
|
||||
return probe().also { cached = it }
|
||||
}
|
||||
|
||||
private fun probe(): String? {
|
||||
val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (display == EGL14.EGL_NO_DISPLAY) return null
|
||||
val version = IntArray(2)
|
||||
// Deliberately no eglTerminate: the default display is process-global and
|
||||
// Flutter's renderer shares it; terminating would invalidate its state.
|
||||
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
|
||||
val extensions = EGL14.eglQueryString(display, EGL14.EGL_EXTENSIONS) ?: return null
|
||||
if (!extensions.contains("EGL_EXT_gl_colorspace_bt2020_pq")) return null
|
||||
if (hasWindowConfig(display, intArrayOf(EGL14.EGL_RED_SIZE, 10, EGL14.EGL_GREEN_SIZE, 10, EGL14.EGL_BLUE_SIZE, 10, EGL14.EGL_ALPHA_SIZE, 2))) {
|
||||
return "rgb10_a2"
|
||||
}
|
||||
if (extensions.contains("EGL_EXT_pixel_format_float") &&
|
||||
hasWindowConfig(
|
||||
display,
|
||||
intArrayOf(
|
||||
EGL14.EGL_RED_SIZE,
|
||||
16,
|
||||
EGL14.EGL_GREEN_SIZE,
|
||||
16,
|
||||
EGL14.EGL_BLUE_SIZE,
|
||||
16,
|
||||
EGL_COLOR_COMPONENT_TYPE_EXT,
|
||||
EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "rgba16f"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasWindowConfig(display: android.opengl.EGLDisplay, extra: IntArray): Boolean {
|
||||
val attribs = intArrayOf(
|
||||
EGL14.EGL_SURFACE_TYPE,
|
||||
EGL14.EGL_WINDOW_BIT,
|
||||
EGL14.EGL_RENDERABLE_TYPE,
|
||||
EGL14.EGL_OPENGL_ES2_BIT
|
||||
) + extra + intArrayOf(EGL14.EGL_NONE)
|
||||
val numConfigs = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(display, attribs, 0, null, 0, 0, numConfigs, 0)) return false
|
||||
return numConfigs[0] > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
/**
|
||||
* Pure policy for when an mpv session must leave the video plane
|
||||
* (vo=mediacodec) for a GL video output, and which one. Kept free of player
|
||||
* and platform state so the routing matrix is unit-testable.
|
||||
*/
|
||||
internal object GpuVoPolicy {
|
||||
/**
|
||||
* Single-layer Dolby Vision Profile 5 (IPT-PQ-c2) has no compatible base
|
||||
* layer: on a device without native DV support it decodes as plain HEVC
|
||||
* with garbage colors, and the video plane applies no reshaping. gpu-next
|
||||
* (libplacebo) under software decode is the only Android path that
|
||||
* composites the RPU metadata (#1902). [dvProfile] comes from mpv's
|
||||
* track-list — the bitstream's DOVI configuration record — never from
|
||||
* server metadata, which mis-tags DV routinely. Only `auto` routes; the
|
||||
* other modes are explicit user choices.
|
||||
*/
|
||||
fun needsDvReshaping(dvProfile: Long?, conversionMode: String, canPlayP5Natively: Boolean): Boolean = dvProfile == 5L && conversionMode == "auto" && !canPlayP5Natively
|
||||
|
||||
/**
|
||||
* Whether an HDR signal has nowhere to tone-map: the video plane hands
|
||||
* PQ/HLG straight to a display pipeline that advertises no HDR output, so
|
||||
* it renders washed out (#2121). The GL vo tone-maps in the render chain.
|
||||
*/
|
||||
fun needsHdrToneMapping(gamma: String?, displaySupportsHdr: Boolean): Boolean = (gamma == "pq" || gamma == "hlg") && !displaySupportsHdr
|
||||
|
||||
/**
|
||||
* Whether the decoder is handing mpv software frames, from `hwdec-current`.
|
||||
*
|
||||
* The plane refuses every format but MediaCodec buffers, so a per-file
|
||||
* decode fallback (AV1 on Tegra, Hi10 without a profile match) has to move
|
||||
* to a GL vo. Routing on this gets there before mpv fails the chain and
|
||||
* [REASON_CHAIN_FAILURE] has to catch it.
|
||||
*/
|
||||
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
|
||||
|
||||
/**
|
||||
* The vo a session with these active requirements should run, or null for
|
||||
* the video plane.
|
||||
*
|
||||
* dv-reshape is the only reason that needs gpu-next, since libplacebo is
|
||||
* what composites the RPU. Everything else takes gpu, the battle-tested
|
||||
* GLES renderer on the Android device zoo. (The magenta field gpu-next
|
||||
* used to render for 10-bit software frames on Tegra was its AV1 film
|
||||
* grain shader overrunning the driver's uniform register budget; grain is
|
||||
* decoder-applied now, but gpu-next buys this path nothing over gpu.)
|
||||
*/
|
||||
fun targetFor(reasons: Set<String>): String? = when {
|
||||
reasons.isEmpty() -> null
|
||||
REASON_DV_RESHAPE in reasons -> "gpu-next"
|
||||
else -> "gpu"
|
||||
}
|
||||
|
||||
const val REASON_DV_RESHAPE = "dv-reshape"
|
||||
const val REASON_SHADERS = "shaders"
|
||||
const val REASON_CHAIN_FAILURE = "chain-failure"
|
||||
const val REASON_HDR_SDR = "hdr-sdr"
|
||||
const val REASON_SW_DECODE = "sw-decode"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.AudioAttributes
|
||||
@@ -15,6 +16,7 @@ import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import com.edde746.plezy.exoplayer.DoviBridge
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
@@ -39,20 +41,65 @@ import kotlinx.coroutines.sync.withLock
|
||||
class MpvPlayerCore private constructor(
|
||||
private val context: Context,
|
||||
private val audioOnly: Boolean,
|
||||
private val hardwareDecoding: Boolean,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback,
|
||||
SurfacePlayerCore {
|
||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||
constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean = false,
|
||||
hardwareDecoding: Boolean = true
|
||||
) : this(context, audioOnly, hardwareDecoding, null, false)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?
|
||||
) : this(context, audioOnly, propertyWriter, true)
|
||||
) : this(context, audioOnly, true, propertyWriter, true)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerCore"
|
||||
|
||||
/**
|
||||
* The initial `vo` chain, decided by whether this session will hardware-
|
||||
* decode.
|
||||
*
|
||||
* Hardware sessions use the fork vo=mediacodec: decoded buffers go from
|
||||
* MediaCodec straight to the compositor with per-frame presentation
|
||||
* timestamps - no GLES pass, 10-bit and the decoder's dataspace
|
||||
* (HDR10/HLG) intact - and subtitles/OSD render on the sibling OSD
|
||||
* surface. The plane takes decoder buffers only and refuses the rest, so
|
||||
* a per-file decode fallback moves to a GL vo
|
||||
* ([GpuVoPolicy.needsSoftwareRender], with the chain-failure watchdog as
|
||||
* the backstop). gpu stays in the chain for preinit failure.
|
||||
*
|
||||
* Software sessions run gpu,gpu-next: gpu is the battle-tested GLES
|
||||
* renderer on the Android device zoo, and with film grain applied by the
|
||||
* decoder nothing else on this path needs libplacebo. Dolby Vision RPU
|
||||
* reshaping (#1902) is the one exception - it needs gpu-next, and the
|
||||
* [GpuVoPolicy.REASON_DV_RESHAPE] observer moves the session there when a
|
||||
* DV profile that needs reshaping appears. gpu-next under *hardware*
|
||||
* decode is broken on Tegra (samplerExternalOES double declaration
|
||||
* rejected by the GLES linker, blue screen on the Shield, #2010);
|
||||
* vo=mediacodec sidesteps that entire class by never touching GLES.
|
||||
*/
|
||||
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "mediacodec,gpu" else "gpu,gpu-next"
|
||||
|
||||
/**
|
||||
* The `-append` list-option suffixes are not exposed through the property
|
||||
* interface, so the app's decoder options replace the whole list. FFmpeg
|
||||
* keeps the last duplicate key, so any user mpv.conf entries go first.
|
||||
*/
|
||||
internal fun mergeDecoderOptions(current: String?, ours: String): String = if (current.isNullOrBlank()) ours else "$current,$ours"
|
||||
|
||||
/**
|
||||
* Whether content with this transfer is worth an HDR (BT.2020 PQ) GL
|
||||
* surface. PQ and HLG both render into a PQ target; everything else -
|
||||
* including unknown - stays on the default sRGB surface, which renders
|
||||
* every content correctly (HDR arrives tone-mapped, as before).
|
||||
*/
|
||||
internal fun wantsHdrSurface(transfer: String?): Boolean = transfer == "smpte2084" || transfer == "arib-std-b67"
|
||||
}
|
||||
|
||||
/** Video-only paths. The plugin always constructs video cores with the
|
||||
@@ -61,7 +108,57 @@ class MpvPlayerCore private constructor(
|
||||
get() = context as Activity
|
||||
|
||||
private var surfaceView: SurfaceView? = null
|
||||
private var osdSurfaceView: SurfaceView? = null
|
||||
private var surfaceContainer: android.widget.FrameLayout? = null
|
||||
|
||||
@Volatile private var pendingOsdSurface: Surface? = null
|
||||
|
||||
@Volatile private var attachedOsdSurface: Surface? = null
|
||||
|
||||
/** Active reasons the session must render off the plane. */
|
||||
private val gpuVoReasons = LinkedHashSet<String>()
|
||||
|
||||
/** The GL vo this session is running, or null for the video plane. Non-null
|
||||
* gates off the plane-only machinery: OSD attach, aspect-fitted layout,
|
||||
* chain-failure watchdog. Written under [gpuVoReasons]. */
|
||||
@Volatile private var activeGpuVoTarget: String? = null
|
||||
|
||||
/** Whether the per-file DV policy is holding hwdec at `no`; the session's
|
||||
* own hwdec value is parked in [hwdecBeforeDvReshape] meanwhile. */
|
||||
@Volatile private var dvReshapeActive: Boolean = false
|
||||
|
||||
private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference<String?>()
|
||||
|
||||
/** Last `dv-conversion-mode` Dart applied; input to the per-file DV
|
||||
* routing policy. */
|
||||
@Volatile private var currentDvConversionMode: String = "auto"
|
||||
|
||||
/** Whether this core already decided its GL surface colorspace; set by the
|
||||
* first `content-color-transfer` announcement ([applyContentColorTransfer]). */
|
||||
@Volatile private var hdrSurfaceDecided: Boolean = false
|
||||
|
||||
/** Whether this session outputs HDR to an HDR-capable display — via the PQ
|
||||
* GL surface or the MediaCodec plane's decoder dataspace. Gates the
|
||||
* deferred display-mode restore on teardown (see
|
||||
* [FrameRateManager.clearVideoFrameRate]). */
|
||||
@Volatile private var hdrDisplayActive: Boolean = false
|
||||
|
||||
@Volatile private var videoDisplayWidth: Int = 0
|
||||
|
||||
@Volatile private var videoDisplayHeight: Int = 0
|
||||
|
||||
/** Latest `panscan` (0..1) and `video-zoom` (log2) the app applied. The
|
||||
* plane owns scaling, so these are view geometry here; see
|
||||
* [applyVideoRectLayout]. */
|
||||
@Volatile private var videoPanscan: Float = 0f
|
||||
|
||||
@Volatile private var videoZoomLog2: Float = 0f
|
||||
|
||||
/** Hardware sessions render through the fork vo=mediacodec (see
|
||||
* [initialVideoOutput]); the OSD surface and video-rect layout exist only
|
||||
* there. */
|
||||
private val usesMediaCodecVo: Boolean
|
||||
get() = !audioOnly && hardwareDecoding
|
||||
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
|
||||
@Volatile private var disposing: Boolean = false
|
||||
@@ -232,6 +329,22 @@ class MpvPlayerCore private constructor(
|
||||
lastAppliedSurfaceSize = null
|
||||
lastKnownSurfaceWidth = 0
|
||||
lastKnownSurfaceHeight = 0
|
||||
// Video-output state, so a re-initialized core does not start stranded
|
||||
// off the plane with a stale reason set.
|
||||
synchronized(gpuVoReasons) {
|
||||
gpuVoReasons.clear()
|
||||
activeGpuVoTarget = null
|
||||
}
|
||||
hwdecBeforeDvReshape.set(null)
|
||||
dvReshapeActive = false
|
||||
attachedOsdSurface = null
|
||||
videoDisplayWidth = 0
|
||||
videoDisplayHeight = 0
|
||||
videoPanscan = 0f
|
||||
videoZoomLog2 = 0f
|
||||
currentDvConversionMode = "auto"
|
||||
hdrSurfaceDecided = false
|
||||
hdrDisplayActive = false
|
||||
if (!audioOnly) ensurePlaceholderSurface()
|
||||
|
||||
// Initialize audio focus handling. mpv has none built in, so both modes
|
||||
@@ -259,6 +372,10 @@ class MpvPlayerCore private constructor(
|
||||
surfaceContainer = PlayerSurfaceHost.createContainer(activity)
|
||||
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
|
||||
surfaceContainer!!.addView(surfaceView)
|
||||
if (usesMediaCodecVo) {
|
||||
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
|
||||
surfaceContainer!!.addView(osdSurfaceView)
|
||||
}
|
||||
|
||||
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
|
||||
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
|
||||
@@ -267,6 +384,7 @@ class MpvPlayerCore private constructor(
|
||||
ensureFlutterOverlayOnTop()
|
||||
val sv = surfaceView
|
||||
if (sv != null) applySurfaceSize(sv.width, sv.height)
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
|
||||
|
||||
@@ -280,6 +398,11 @@ class MpvPlayerCore private constructor(
|
||||
return@launch
|
||||
}
|
||||
val displayFpsOverride = currentDisplayFpsOverride()
|
||||
// Both core kinds cap their demuxer cache off the device heap class;
|
||||
// rationale on DemuxerBudget. Null (unknown class) keeps mpv defaults.
|
||||
val demuxerBudget = DemuxerBudget.forHeapClassMB(
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
|
||||
)
|
||||
val p = MpvPlayer.create(context.applicationContext) {
|
||||
if (audioOnly) {
|
||||
// Pure audio core (all set before mpv_initialize, mirroring the
|
||||
@@ -293,14 +416,26 @@ class MpvPlayerCore private constructor(
|
||||
setOption("audio-display", "no")
|
||||
setOption("gapless-audio", "weak")
|
||||
} else {
|
||||
setOption("vo", "gpu")
|
||||
// vo choice is decode-path-dependent; rationale on
|
||||
// initialVideoOutput.
|
||||
setOption("vo", initialVideoOutput(hardwareDecoding))
|
||||
setOption("gpu-context", "android")
|
||||
setOption("opengl-es", "yes")
|
||||
// Keep AV1 film grain inside the decoder (dav1d). `auto` hands it
|
||||
// to any vo claiming VO_CAP_FILM_GRAIN, and gpu-next claims it on
|
||||
// GLES where libplacebo's raster grain fallback fetches luma by
|
||||
// fragcoord (bottom-up) but chroma by uv: the luma renders
|
||||
// upside-down (measured on a Shield Pro; desktop GL is unaffected
|
||||
// because grain runs as a compute pass there).
|
||||
setOption("vd-lavc-film-grain", "cpu")
|
||||
if (displayFpsOverride != null) {
|
||||
setOption("display-fps-override", displayFpsOverride)
|
||||
}
|
||||
}
|
||||
if (demuxerBudget != null) {
|
||||
setOption("demuxer-max-bytes", demuxerBudget.aheadBytes.toString())
|
||||
setOption("demuxer-max-back-bytes", demuxerBudget.backBytes.toString())
|
||||
}
|
||||
setOption("ao", "audiotrack,opensles")
|
||||
// Pause on the last frame at EOF instead of unloading the file, so a
|
||||
// seek after the video ends still works (matches Linux/Windows).
|
||||
@@ -312,6 +447,13 @@ class MpvPlayerCore private constructor(
|
||||
// builtin script during mpv_initialize, hence an option here.
|
||||
setOption("ytdl", "no")
|
||||
}
|
||||
if (demuxerBudget != null) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Demuxer budget: ${demuxerBudget.aheadBytes / (1024 * 1024)}MB ahead, " +
|
||||
"${demuxerBudget.backBytes / (1024 * 1024)}MB back"
|
||||
)
|
||||
}
|
||||
if (displayFpsOverride != null) {
|
||||
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
|
||||
}
|
||||
@@ -331,6 +473,12 @@ class MpvPlayerCore private constructor(
|
||||
collectEvents(p)
|
||||
collectPropertyChanges(p)
|
||||
collectLogMessages(p)
|
||||
if (usesMediaCodecVo) {
|
||||
collectVideoDimensions(p)
|
||||
collectShaderState(p)
|
||||
collectHdrToneMapState(p)
|
||||
collectDecoderState(p)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Initialized successfully")
|
||||
onResult(true)
|
||||
@@ -367,9 +515,27 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
is MpvEvent.StartFile -> {
|
||||
endFileDiagnostics.onStartFile()
|
||||
// The trigger is per-file (an exotic pixel format, a gralloc
|
||||
// refusal for that stream), so give the plane back to the next
|
||||
// file. A genuine failure re-arms it, costing one switch per bad
|
||||
// file instead of the whole session's HDR/10-bit scanout.
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, false)
|
||||
delegate?.onEvent("start-file", null)
|
||||
}
|
||||
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
|
||||
is MpvEvent.FileLoaded -> {
|
||||
if (usesMediaCodecVo) {
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
try {
|
||||
applyDvReshapePolicy(p)
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled DV routing policy")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DV routing policy failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate?.onEvent("file-loaded", null)
|
||||
}
|
||||
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
|
||||
else -> {}
|
||||
}
|
||||
@@ -403,6 +569,18 @@ class MpvPlayerCore private constructor(
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.logFlow.collect { msg ->
|
||||
endFileDiagnostics.onLogMessage(msg)
|
||||
// A chain-init failure is the one runtime signal that frames cannot
|
||||
// reach the video plane at all (exotic pixel formats, gralloc
|
||||
// refusal). mpv is pinned in the fork, so the log line is a stable
|
||||
// contract.
|
||||
if (usesMediaCodecVo &&
|
||||
activeGpuVoTarget == null &&
|
||||
msg.prefix.startsWith("cplayer") &&
|
||||
msg.text.contains("Could not initialize video chain")
|
||||
) {
|
||||
Log.w(TAG, "Video chain init failed under vo=mediacodec; leaving the video plane")
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, true)
|
||||
}
|
||||
emitLog(msg.level.name.lowercase(), msg.prefix, msg.text)
|
||||
}
|
||||
}
|
||||
@@ -454,6 +632,300 @@ class MpvPlayerCore private constructor(
|
||||
detachSurfaceInternal(reason = "surfaceDestroyed")
|
||||
}
|
||||
|
||||
// OSD surface (the vo=mediacodec subtitle/OSD plane)
|
||||
|
||||
private val osdSurfaceCallback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
if (disposing) return
|
||||
pendingOsdSurface = holder.surface.takeIf { it.isValid }
|
||||
Log.d(TAG, "OSD surface created")
|
||||
if (player != null && hasAttachedRealSurface()) {
|
||||
refreshVideoOutput("osdSurfaceCreated")
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
Log.d(TAG, "OSD surface destroyed")
|
||||
pendingOsdSurface = null
|
||||
val wasAttached = attachedOsdSurface != null
|
||||
attachedOsdSurface = null
|
||||
val p = player ?: return
|
||||
if (disposing || !wasAttached) return
|
||||
// Ordered against every other mpv write: a detach that overtook the
|
||||
// re-attach of a recreated surface would clear the option the attach
|
||||
// just set, and nothing re-arms it — subtitles would stay dead for the
|
||||
// rest of the session.
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
try {
|
||||
p.detachOsdSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to detach OSD surface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the OSD Surface to the vo. The vo reads the option when it is
|
||||
* created, so this has to run before the `vo` or `wid` write that creates
|
||||
* it, never after.
|
||||
*/
|
||||
private fun attachOsdSurfaceIfNeeded(p: MpvPlayer) {
|
||||
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
|
||||
val osd = pendingOsdSurface?.takeIf { it.isValid } ?: return
|
||||
if (osd === attachedOsdSurface) return
|
||||
p.attachOsdSurface(osd)
|
||||
attachedOsdSurface = osd
|
||||
Log.d(TAG, "Attached OSD surface for vo=mediacodec")
|
||||
}
|
||||
|
||||
private fun collectVideoDimensions(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeInt("dwidth").collect { value ->
|
||||
val w = value.toInt()
|
||||
if (w > 0 && w != videoDisplayWidth) {
|
||||
videoDisplayWidth = w
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeInt("dheight").collect { value ->
|
||||
val h = value.toInt()
|
||||
if (h > 0 && h != videoDisplayHeight) {
|
||||
videoDisplayHeight = h
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbiter for this session's video output: the active [GpuVoPolicy] reasons
|
||||
* decide whether the session runs on the video plane or on a GL vo.
|
||||
*/
|
||||
private fun setGpuVoRequirement(reason: String, active: Boolean) {
|
||||
if (!usesMediaCodecVo || disposing) return
|
||||
val transition = synchronized(gpuVoReasons) {
|
||||
val changed = if (active) gpuVoReasons.add(reason) else gpuVoReasons.remove(reason)
|
||||
if (!changed) return
|
||||
val desired = GpuVoPolicy.targetFor(gpuVoReasons)
|
||||
if (desired == activeGpuVoTarget) return
|
||||
val line = "${activeGpuVoTarget ?: "mediacodec"} -> ${desired ?: "mediacodec"} " +
|
||||
"(reasons=[${gpuVoReasons.joinToString(",")}])"
|
||||
activeGpuVoTarget = desired
|
||||
line
|
||||
}
|
||||
Log.i(TAG, "Video output: $transition")
|
||||
applyGpuVoTarget()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the session to whatever the arbiter last decided.
|
||||
*
|
||||
* The decision is atomic under [gpuVoReasons], but the write cannot be:
|
||||
* it has to leave the lock to reach mpv. Reasons are raised from different
|
||||
* threads — per-file DV routing runs on [mpvWriteDispatcher], the gamma,
|
||||
* shader and chain-failure observers on the main thread — so the order
|
||||
* writes are *enqueued* is not the order decisions were *made*. Rather
|
||||
* than trust the target its caller saw, every transition re-reads the
|
||||
* current one here, which makes the last write the right one under any
|
||||
* interleaving. Serialized on [mpvWriteDispatcher], so the paired main
|
||||
* thread work stays in the same order too.
|
||||
*/
|
||||
private fun applyGpuVoTarget() {
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
|
||||
runOnMain {
|
||||
if (disposing) return@runOnMain
|
||||
if (target == null) {
|
||||
osdSurfaceView?.visibility = View.VISIBLE
|
||||
} else {
|
||||
// The gpu VOs draw their own OSD; a stale subtitle frame must not
|
||||
// linger on the overlay plane.
|
||||
osdSurfaceView?.visibility = View.GONE
|
||||
resetVideoSurfaceToFullContainer()
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Before the vo write, which recreates the VO: it reads the OSD
|
||||
// surface option at creation.
|
||||
val p = player
|
||||
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
|
||||
writeProperty("vo", target ?: "mediacodec")
|
||||
if (p == null) return@launch
|
||||
if (target != null) {
|
||||
// A failed conversion chain makes mpv deselect the video track
|
||||
// ("Could not initialize video chain" -> vid=no) before the VO
|
||||
// switch lands. Re-select the explicit track id: mid-file "auto"
|
||||
// resolves to no selection rather than re-running selection.
|
||||
if (p.getString("vid").let { it == null || it == "no" }) {
|
||||
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
|
||||
if (videoTrackId != null) {
|
||||
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
|
||||
writeProperty("vid", videoTrackId.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
applySurfaceSizeInternal(p, force = true)
|
||||
if (target == null) {
|
||||
// Refit the surfaces to the aspect rectangle now that the plane
|
||||
// owns scaling again (the gpu VOs letterboxed within full
|
||||
// containers).
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled vo transition write")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to move the video output to ${target ?: "mediacodec"}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
|
||||
* the DOVI configuration record's profile on the track list (never trust
|
||||
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
|
||||
* every file-loaded, so a following non-P5 file restores hardware decode
|
||||
* and returns to the video plane.
|
||||
*/
|
||||
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
|
||||
val profile = selectedVideoDvProfile(p)
|
||||
val needs = GpuVoPolicy.needsDvReshaping(
|
||||
dvProfile = profile,
|
||||
conversionMode = currentDvConversionMode,
|
||||
canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context)
|
||||
)
|
||||
if (needs == dvReshapeActive) return
|
||||
dvReshapeActive = needs
|
||||
if (needs) {
|
||||
Log.i(TAG, "DV P5 (bitstream) without native support: software decode + gpu-next reshaping")
|
||||
val current = p.getString("hwdec")
|
||||
hwdecBeforeDvReshape.set(current ?: "no")
|
||||
writeProperty("hwdec", "no")
|
||||
} else {
|
||||
val restore = hwdecBeforeDvReshape.getAndSet(null)
|
||||
if (restore != null && restore != "no") writeProperty("hwdec", restore)
|
||||
}
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_DV_RESHAPE, needs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected video track's Dolby Vision profile, or null for non-DV content
|
||||
* (mpv omits the field when the bitstream carries no DOVI configuration
|
||||
* record).
|
||||
*/
|
||||
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
|
||||
?.takeIf { it.has("dolby-vision-profile") }
|
||||
?.getLong("dolby-vision-profile")
|
||||
|
||||
/**
|
||||
* Observed rather than derived from the hardware-decoding setting because
|
||||
* the fallback is decided per file, inside mpv. Why it matters:
|
||||
* [GpuVoPolicy.needsSoftwareRender].
|
||||
*/
|
||||
private fun collectDecoderState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("hwdec-current").collect { value ->
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_SW_DECODE, GpuVoPolicy.needsSoftwareRender(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User shaders need a GL vo; the video plane renders none. Observed
|
||||
* natively so Dart's `glsl-shaders` change-list writes (ShaderService,
|
||||
* ambient lighting) switch the session live, without a channel contract.
|
||||
*/
|
||||
private fun collectShaderState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("glsl-shaders").collect { value ->
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_SHADERS, value.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observed from video-params so the reason follows per-file transfer
|
||||
* changes, and the display is re-queried per change so an HDMI mode switch
|
||||
* mid-session is honoured on the next file. Why it matters:
|
||||
* [GpuVoPolicy.needsHdrToneMapping].
|
||||
*/
|
||||
private fun collectHdrToneMapState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("video-params/gamma").collect { value ->
|
||||
val needsToneMap = GpuVoPolicy.needsHdrToneMapping(
|
||||
gamma = value,
|
||||
displaySupportsHdr = DoviBridge.displaySupportsHdr(context)
|
||||
)
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_HDR_SDR, needsToneMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Video tracks from mpv's track list, selected first; empty on any parse failure. */
|
||||
private suspend fun videoTracks(p: MpvPlayer): List<org.json.JSONObject> {
|
||||
val json = p.getString("track-list") ?: return emptyList()
|
||||
return try {
|
||||
val tracks = org.json.JSONArray(json)
|
||||
(0 until tracks.length())
|
||||
.map { tracks.getJSONObject(it) }
|
||||
.filter { it.optString("type") == "video" }
|
||||
.sortedByDescending { it.optBoolean("selected") }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse track-list", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sizes the video surface to the rectangle the image should occupy, per
|
||||
* [VideoRectPolicy], and lets the container clip the overflow.
|
||||
*
|
||||
* The OSD surface is left full-container: the vo builds its `mp_osd_res`
|
||||
* from the OSD window's own size, so libass keeps the whole window as its
|
||||
* canvas — subtitles sit in the letterbox bars as they did under vo=gpu,
|
||||
* and stay on screen when a zoomed image runs past the container.
|
||||
*/
|
||||
private fun applyVideoRectLayout() {
|
||||
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
|
||||
runOnMain {
|
||||
if (disposing) return@runOnMain
|
||||
val container = surfaceContainer ?: return@runOnMain
|
||||
val size = VideoRectPolicy.sizeFor(
|
||||
containerWidth = container.width,
|
||||
containerHeight = container.height,
|
||||
videoWidth = videoDisplayWidth,
|
||||
videoHeight = videoDisplayHeight,
|
||||
panscan = videoPanscan,
|
||||
videoZoomLog2 = videoZoomLog2
|
||||
) ?: return@runOnMain
|
||||
// The guard matters: this runs from an OnGlobalLayoutListener, so an
|
||||
// unconditional write would re-trigger layout forever.
|
||||
surfaceView?.let { view ->
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
if (lp.width != size.width || lp.height != size.height || lp.gravity != android.view.Gravity.CENTER) {
|
||||
lp.width = size.width
|
||||
lp.height = size.height
|
||||
lp.gravity = android.view.Gravity.CENTER
|
||||
view.layoutParams = lp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetVideoSurfaceToFullContainer() {
|
||||
val view = surfaceView ?: return
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
if (lp.width == android.widget.FrameLayout.LayoutParams.MATCH_PARENT) return
|
||||
lp.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
lp.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
lp.gravity = android.view.Gravity.NO_GRAVITY
|
||||
view.layoutParams = lp
|
||||
}
|
||||
|
||||
private fun rememberSurfaceSize(width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) return
|
||||
lastKnownSurfaceWidth = width
|
||||
@@ -529,10 +1001,15 @@ class MpvPlayerCore private constructor(
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
|
||||
// An unattached OSD Surface forces a wid re-attach: the VO reads the
|
||||
// OSD surface option at creation, and setting wid recreates the VO.
|
||||
// Suppressed off the plane, where the GL vo draws its own OSD.
|
||||
val osdNeedsAttach = activeGpuVoTarget == null && pendingOsdSurface !== attachedOsdSurface
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osdNeedsAttach
|
||||
val wasAttachedToPlaceholder = attachedToPlaceholder
|
||||
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
|
||||
if (needsAttach) {
|
||||
attachOsdSurfaceIfNeeded(p)
|
||||
p.attachSurface(surface)
|
||||
attachedSurface = surface
|
||||
hasAttachedSurface = true
|
||||
@@ -850,12 +1327,136 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "Load pause intent updated: paused=$paused")
|
||||
}
|
||||
|
||||
/**
|
||||
* `dv-conversion-mode` is an app-level property shared with the ExoPlayer
|
||||
* and Apple cores, not an mpv one. It maps onto the fork FFmpeg
|
||||
* hevc_mediacodec decoder options, mirroring the ExoPlayer DoviBridge
|
||||
* decision tree. Single-layer profiles (5/8) use the Dolby Vision decoder
|
||||
* whenever the path is enabled and the decoder advertises the profile.
|
||||
*/
|
||||
private fun applyDvConversionMode(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
val displayDv = DoviBridge.displaySupportsDolbyVision(context)
|
||||
val (dolbyVision, p7Mode) = when (value.trim().lowercase()) {
|
||||
"auto" -> if (displayDv) "1" to "auto" else "0" to "strip"
|
||||
"disabled", "native" -> "1" to "native"
|
||||
"dv81" -> "1" to "convert"
|
||||
"hevc", "hevc_strip" -> "1" to "strip"
|
||||
else -> {
|
||||
onComplete?.invoke(Result.failure(IllegalArgumentException("Invalid DV conversion mode: $value")))
|
||||
return
|
||||
}
|
||||
}
|
||||
currentDvConversionMode = value.trim().lowercase()
|
||||
Log.i(TAG, "DV conversion mode '$value' (displayDV=$displayDv) -> dolby_vision=$dolbyVision dv_p7_mode=$p7Mode")
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val completion = try {
|
||||
val ours = "dolby_vision=$dolbyVision,dv_p7_mode=$p7Mode"
|
||||
val merged = mergeDecoderOptions(player?.getString("vd-lavc-o"), ours)
|
||||
writeProperty("vd-lavc-o", merged)
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "DV conversion mode write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `content-color-transfer` is an app-level property: Dart announces the
|
||||
* selected stream's transfer (server metadata) before playback so an HDR
|
||||
* session can get a BT.2020 PQ 10-bit GL surface instead of tone-mapped
|
||||
* SDR. Consumed by whichever android GL context the session ever creates -
|
||||
* up front for a software session, or at the fallback transition when a
|
||||
* plane session leaves vo=mediacodec (the plane itself carries HDR via the
|
||||
* decoder's dataspace and ignores all of this).
|
||||
*
|
||||
* The first announcement decides for the whole core: the surface colorspace
|
||||
* is fixed at EGL-surface creation, and both latched states stay correct
|
||||
* for later files (a PQ target renders SDR content correctly, an sRGB
|
||||
* surface tone-maps HDR as before) - re-deciding mid-session could pair a
|
||||
* live sRGB surface with a PQ render target, which is wrong everywhere.
|
||||
*/
|
||||
private fun applyContentColorTransfer(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
val transfer = value.trim().lowercase()
|
||||
if (hdrSurfaceDecided) {
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
hdrSurfaceDecided = true
|
||||
val wants = wantsHdrSurface(transfer)
|
||||
val displayHdr = wants && DoviBridge.displaySupportsHdr(context)
|
||||
// Independent of the GL surface outcome: on the MediaCodec plane the
|
||||
// decoder's dataspace carries HDR to the display without a PQ GL surface.
|
||||
hdrDisplayActive = displayHdr
|
||||
val outputFormat = if (wants) EglHdrCaps.pqOutputFormat() else null
|
||||
if (!wants || !displayHdr || outputFormat == null) {
|
||||
if (wants) {
|
||||
Log.i(TAG, "HDR GL surface unavailable (transfer=$transfer displayHdr=$displayHdr eglFormat=$outputFormat)")
|
||||
}
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "HDR GL surface engaged: BT.2020 PQ / $outputFormat for transfer=$transfer")
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val completion = try {
|
||||
writeProperty("android-surface-colorspace", "bt2020-pq")
|
||||
writeProperty("egl-output-format", outputFormat)
|
||||
writeProperty("target-trc", "pq")
|
||||
writeProperty("target-prim", "bt.2020")
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "HDR surface property write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return
|
||||
}
|
||||
|
||||
if (name == "dv-conversion-mode") {
|
||||
applyDvConversionMode(value, onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
if (name == "content-color-transfer") {
|
||||
applyContentColorTransfer(value, onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
// View geometry on the plane (see VideoRectPolicy), but both still fall
|
||||
// through to mpv, which is what makes them work unchanged on the GL vos.
|
||||
if (name == "panscan" || name == "video-zoom") {
|
||||
val parsed = value.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
if (name == "panscan") videoPanscan = parsed else videoZoomLog2 = parsed
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// While the per-file DV policy holds hwdec at `no`, park writes instead
|
||||
// of applying them: a hardware value under gpu-next would lose the RPU
|
||||
// side data (and blue-screen the Tegra class, #2010). The parked value
|
||||
// is restored when a non-P5 file drops the requirement.
|
||||
if (name == "hwdec" && dvReshapeActive) {
|
||||
hwdecBeforeDvReshape.set(value)
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
|
||||
val paused = if (name == "pause") normalizePauseValue(value) else null
|
||||
val pauseIntent = paused?.let {
|
||||
synchronized(publicPauseIntentLock) {
|
||||
@@ -1038,9 +1639,13 @@ class MpvPlayerCore private constructor(
|
||||
"estimated-vf-fps" to getProperty("estimated-vf-fps"),
|
||||
"video-bitrate" to getProperty("video-bitrate"),
|
||||
"hwdec-current" to getProperty("hwdec-current"),
|
||||
"current-vo" to getProperty("current-vo"),
|
||||
"audio-codec-name" to getProperty("audio-codec-name"),
|
||||
"audio-params/samplerate" to getProperty("audio-params/samplerate"),
|
||||
"audio-params/hr-channels" to getProperty("audio-params/hr-channels"),
|
||||
"audio-params/format" to getProperty("audio-params/format"),
|
||||
"current-tracks/audio/demux-samplerate" to getProperty("current-tracks/audio/demux-samplerate"),
|
||||
"current-tracks/audio/demux-channel-count" to getProperty("current-tracks/audio/demux-channel-count"),
|
||||
"audio-bitrate" to getProperty("audio-bitrate"),
|
||||
"total-avsync-change" to getProperty("total-avsync-change"),
|
||||
"cache-used" to getProperty("cache-used"),
|
||||
@@ -1171,6 +1776,7 @@ class MpvPlayerCore private constructor(
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
@@ -1178,7 +1784,7 @@ class MpvPlayerCore private constructor(
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
|
||||
player?.let {
|
||||
updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") {
|
||||
onComplete(switched)
|
||||
@@ -1188,7 +1794,7 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrDisplayActive)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
@@ -1239,11 +1845,15 @@ class MpvPlayerCore private constructor(
|
||||
|
||||
// Capture locals for deferred cleanup (audio-only has no views)
|
||||
val sv = surfaceView
|
||||
val osdSv = osdSurfaceView
|
||||
val container = surfaceContainer
|
||||
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
|
||||
surfaceContainer = null
|
||||
surfaceView = null
|
||||
osdSurfaceView = null
|
||||
pendingOsdSurface = null
|
||||
attachedOsdSurface = null
|
||||
|
||||
// Remove layout listener synchronously
|
||||
overlayLayoutListener?.let { listener ->
|
||||
@@ -1295,6 +1905,7 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "Disposed (native)")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
sv?.holder?.removeCallback(this)
|
||||
osdSv?.holder?.removeCallback(osdSurfaceCallback)
|
||||
if (container?.parent != null) {
|
||||
contentView?.removeView(container)
|
||||
}
|
||||
@@ -1305,6 +1916,7 @@ class MpvPlayerCore private constructor(
|
||||
// No player — safe to remove views immediately
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
sv?.holder?.removeCallback(this)
|
||||
osdSv?.holder?.removeCallback(osdSurfaceCallback)
|
||||
if (container?.parent != null) {
|
||||
contentView?.removeView(container)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.edde746.plezy.exoplayer.supportedMpvSpdifCodecs
|
||||
import com.edde746.plezy.shared.PlayerChannelBinding
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
@@ -13,6 +15,7 @@ import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal fun completeMpvPropertyResult(
|
||||
result: MethodChannel.Result,
|
||||
@@ -61,6 +64,17 @@ open class MpvPlayerPlugin(
|
||||
private val nameToId = mutableMapOf<String, Int>()
|
||||
private var sessionGeneration = 0
|
||||
|
||||
// The Dart instanceId that created the current core. A `dispose` carrying a
|
||||
// different token lost the ownership race to a successor and must not tear
|
||||
// down that successor's core; it is acknowledged without touching anything.
|
||||
private var coreInstanceId: Long? = null
|
||||
|
||||
// How long a Dart `dispose` waits for the native teardown before being
|
||||
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
|
||||
// session's surface/audio release); small against the alternative, which
|
||||
// is wedging every subsequent playback session behind a hung teardown.
|
||||
private val disposeWatchdogMs = 6_000L
|
||||
|
||||
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
|
||||
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||
|
||||
@@ -94,6 +108,7 @@ open class MpvPlayerPlugin(
|
||||
++sessionGeneration
|
||||
val core = playerCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
cancelPendingInits()
|
||||
return core
|
||||
}
|
||||
@@ -147,8 +162,8 @@ open class MpvPlayerPlugin(
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
"getStats" -> handleGetStats(result)
|
||||
@@ -159,16 +174,38 @@ open class MpvPlayerPlugin(
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
"getAudioSpdifCodecs" -> handleGetAudioSpdifCodecs(result)
|
||||
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
|
||||
"openContentFd" -> handleOpenContentFd(call, result)
|
||||
"closeContentFd" -> handleCloseContentFd(call, result)
|
||||
"getHeapSize" -> {
|
||||
// Device heap class for Dart-side memory tiering (stream ring cache).
|
||||
// Lives on the always-registered mpv channel so it survives backends.
|
||||
val context: Context? = activity ?: applicationContext
|
||||
val am = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
result.success(am?.largeMemoryClass ?: 0)
|
||||
}
|
||||
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
|
||||
"setLogLevel" -> handleSetLogLevel(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInitialize(result: MethodChannel.Result) {
|
||||
/**
|
||||
* Derives the `audio-spdif` value for the current audio route. mpv force-passthroughs
|
||||
* every codec named there with no decode fallback, so with no context to inspect the
|
||||
* route the conservative answer is the empty list — decode everything (#1703, #1991).
|
||||
*/
|
||||
private fun handleGetAudioSpdifCodecs(result: MethodChannel.Result) {
|
||||
val context: Context? = activity ?: applicationContext
|
||||
result.success(context?.let(::supportedMpvSpdifCodecs) ?: "")
|
||||
}
|
||||
|
||||
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
|
||||
// Whether the session will hardware-decode; decides the initial vo chain
|
||||
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
|
||||
// from older callers; hardware decode is the setting's default.
|
||||
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
|
||||
// Video cores need the Activity (surface/view hierarchy); the audio-only
|
||||
// core is built on the application context so it can outlive it.
|
||||
val coreContext: Context? = if (audioOnly) applicationContext else activity
|
||||
@@ -227,10 +264,11 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(coreContext, audioOnly).apply {
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to initialize: ${e.message}", e)
|
||||
completePendingInits(attempt, success = false, errorMessage = e.message)
|
||||
@@ -242,7 +280,10 @@ open class MpvPlayerPlugin(
|
||||
playerCore !== core ||
|
||||
!isCurrentInitAttempt(attempt)
|
||||
if (stale || !success) {
|
||||
if (playerCore === core) playerCore = null
|
||||
if (playerCore === core) {
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
}
|
||||
core.dispose()
|
||||
if (stale) {
|
||||
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
@@ -299,13 +340,37 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<Number>("instanceId")?.toLong()
|
||||
runOnMain {
|
||||
val core = takeCoreForTeardown()
|
||||
core?.dispose {
|
||||
Log.d(tag, "Disposed")
|
||||
val owner = coreInstanceId
|
||||
if (playerCore != null && token != null && owner != null && token != owner) {
|
||||
// This dispose lost the ownership race: a successor already created
|
||||
// the current core. Acknowledge without touching it.
|
||||
Log.d(tag, "Ignoring stale dispose (token=$token, core owner=$owner)")
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
val core = takeCoreForTeardown()
|
||||
if (core == null) {
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
// A hung native teardown must not wedge the Dart-side release chain:
|
||||
// answer after the watchdog even if the teardown thread is stuck, so
|
||||
// the next session can start on a fresh core. The stuck core leaks its
|
||||
// resources until the process ends — recoverable, unlike the wedge.
|
||||
val completed = AtomicBoolean(false)
|
||||
fun completeOnce(reason: String) {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
Log.d(tag, reason)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
channels.mainHandler.postDelayed({
|
||||
completeOnce("Dispose watchdog fired after ${disposeWatchdogMs}ms; teardown continues in background")
|
||||
}, disposeWatchdogMs)
|
||||
core.dispose { completeOnce("Disposed") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,14 +513,19 @@ open class MpvPlayerPlugin(
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
|
||||
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
||||
val matchResolution = call.argument<Boolean>("matchResolution") ?: false
|
||||
|
||||
Log.d(tag, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
||||
Log.d(
|
||||
tag,
|
||||
"setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, " +
|
||||
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution"
|
||||
)
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Pure geometry for the video plane's surface rectangle.
|
||||
*
|
||||
* The fork `vo=mediacodec` scales decoded buffers to the whole Surface and
|
||||
* implements none of mpv's src/dst rect math, so aspect, cover and zoom are
|
||||
* view geometry: the video surface is sized to the rectangle the image should
|
||||
* occupy and the container clips whatever falls outside it. That mirrors the
|
||||
* ExoPlayer path, which scales its own surface and keeps the subtitle overlay
|
||||
* on the full-size container.
|
||||
*/
|
||||
internal object VideoRectPolicy {
|
||||
/**
|
||||
* `video-zoom` is reachable from the user's mpv.conf, where an absurd value
|
||||
* is a compositor allocation rather than just arithmetic. Bounds it well
|
||||
* outside the range the player's own zoom control offers.
|
||||
*/
|
||||
private const val MAX_ZOOM_LOG2 = 2f
|
||||
|
||||
data class Size(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* Size for the video surface, or null when a dimension is not known yet.
|
||||
*
|
||||
* [panscan] is mpv's 0..1 property: 0 fits the image inside the container
|
||||
* (letterbox), 1 fills it (crop), values between interpolate the scale.
|
||||
* [videoZoomLog2] is mpv's `video-zoom`, a log2 factor, applied on top.
|
||||
*/
|
||||
fun sizeFor(
|
||||
containerWidth: Int,
|
||||
containerHeight: Int,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
panscan: Float = 0f,
|
||||
videoZoomLog2: Float = 0f
|
||||
): Size? {
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) return null
|
||||
val fit = min(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
|
||||
val cover = max(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
|
||||
val pan = panscan.coerceIn(0f, 1f)
|
||||
val zoom = 2.0f.pow(videoZoomLog2.coerceIn(-MAX_ZOOM_LOG2, MAX_ZOOM_LOG2))
|
||||
val scale = (fit + (cover - fit) * pan) * zoom
|
||||
return Size(
|
||||
width = (videoWidth * scale).toInt().coerceAtLeast(1),
|
||||
height = (videoHeight * scale).toInt().coerceAtLeast(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Pure display-mode selection policy for content-adaptive display switching.
|
||||
* Extracted from [FrameRateManager] so the policy is unit-testable on the
|
||||
* JVM, where android.view.Display.Mode cannot be instantiated.
|
||||
*/
|
||||
object DisplayModeSelector {
|
||||
const val RATE_TOLERANCE = 0.1f
|
||||
|
||||
/** JVM-testable mirror of android.view.Display.Mode. */
|
||||
data class ModeInfo(val modeId: Int, val width: Int, val height: Int, val refreshRate: Float) {
|
||||
val area: Long get() = width.toLong() * height
|
||||
}
|
||||
|
||||
data class RefreshRateMatch(val reason: String, val priority: Int, val error: Float)
|
||||
|
||||
data class Selection(val mode: ModeInfo, val reason: String)
|
||||
|
||||
private data class Candidate(val mode: ModeInfo, val match: RefreshRateMatch)
|
||||
|
||||
/** How well [refreshRate] presents [fps] content: exact, an integer multiple, or not at all. */
|
||||
fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
|
||||
if (refreshRate <= 0f || fps <= 0f) return null
|
||||
|
||||
val exactError = abs(refreshRate - fps)
|
||||
if (exactError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
|
||||
}
|
||||
|
||||
val multiple = (refreshRate / fps).roundToInt()
|
||||
if (multiple > 1) {
|
||||
val multipleError = abs(refreshRate - (fps * multiple))
|
||||
if (multipleError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the display mode for the video, or null when no switch target exists.
|
||||
* The caller compares the result against the current mode to decide whether
|
||||
* an actual switch is needed.
|
||||
*
|
||||
* With [matchResolution] and known video dimensions, resolution wins over
|
||||
* cadence: the target is the smallest mode that still contains the video
|
||||
* (never downscaling it), rate-matched within that resolution when [fps] is
|
||||
* known. Otherwise the cadence-only policy applies and requires [fps] > 0.
|
||||
*/
|
||||
fun findBestMode(
|
||||
fps: Float,
|
||||
currentMode: ModeInfo,
|
||||
supportedModes: List<ModeInfo>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean
|
||||
): Selection? {
|
||||
if (matchResolution && videoWidth > 0 && videoHeight > 0) {
|
||||
resolutionMatch(fps, currentMode, supportedModes, videoWidth, videoHeight)?.let { return it }
|
||||
// No mode can contain the video (source larger than the panel):
|
||||
// fall back to the cadence-only policy below.
|
||||
}
|
||||
return cadenceMatch(fps, currentMode, supportedModes, videoWidth, videoHeight)
|
||||
}
|
||||
|
||||
private fun resolutionMatch(
|
||||
fps: Float,
|
||||
currentMode: ModeInfo,
|
||||
supportedModes: List<ModeInfo>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): Selection? {
|
||||
val candidates = supportedModes.filter { it.width >= videoWidth && it.height >= videoHeight }
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
// Native target: the smallest resolution that still contains the video,
|
||||
// so the display (not the device) performs the upscale.
|
||||
val targetArea = candidates.minOf { it.area }
|
||||
val bucket = candidates.filter { it.area == targetArea }
|
||||
|
||||
// Rate-match within the target resolution when requested. Resolution
|
||||
// wins over cadence: a missing rate match here deliberately does not
|
||||
// widen back out to other resolutions.
|
||||
if (fps > 0f) {
|
||||
bucket
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<Candidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
?.let { return Selection(it.mode, "resolution + ${it.match.reason} rate, error=${it.match.error}") }
|
||||
}
|
||||
|
||||
// Resolution-only request, or no cadence match at the target resolution:
|
||||
// stay as close to the current refresh rate as possible so the switch
|
||||
// renegotiates only what it has to.
|
||||
val fallback = bucket.minWithOrNull(
|
||||
compareBy<ModeInfo> { abs(it.refreshRate - currentMode.refreshRate) }.thenByDescending { it.refreshRate }
|
||||
)
|
||||
return fallback?.let { Selection(it, "resolution only") }
|
||||
}
|
||||
|
||||
private fun cadenceMatch(
|
||||
fps: Float,
|
||||
currentMode: ModeInfo,
|
||||
supportedModes: List<ModeInfo>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): Selection? {
|
||||
// Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only
|
||||
// switch, the least disruptive (no resolution/HDMI renegotiation).
|
||||
supportedModes.asSequence()
|
||||
.filter { it.width == currentMode.width && it.height == currentMode.height }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<Candidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
?.let { return Selection(it.mode, "${it.match.reason}, error=${it.match.error}") }
|
||||
|
||||
// Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a
|
||||
// 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one
|
||||
// that downscales the video below its native size (trading detail for cadence).
|
||||
// Requires known video dimensions; without them keep Tier-1-only behaviour.
|
||||
if (videoWidth <= 0 || videoHeight <= 0) return null
|
||||
return supportedModes.asSequence()
|
||||
.filter { it.width >= videoWidth && it.height >= videoHeight }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
// Prefer the resolution closest to the panel's current one (least change,
|
||||
// keeps panel-native res when a high-res match exists), then refresh match.
|
||||
compareBy<Candidate> { abs(it.mode.area - currentMode.area) }
|
||||
.thenBy { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
)
|
||||
?.let { Selection(it.mode, "${it.match.reason}, error=${it.match.error}") }
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FrameRateManager(
|
||||
private val activity: Activity,
|
||||
@@ -21,38 +19,45 @@ class FrameRateManager(
|
||||
private const val TAG = "FrameRateManager"
|
||||
private const val DISPLAY_SETTLE_MS = 2000L
|
||||
private const val WATCHDOG_MARGIN_MS = 3000L
|
||||
private const val RATE_TOLERANCE = 0.1f
|
||||
|
||||
// How long to let the HDR-exit commit land before restoring the refresh
|
||||
// rate. Restoring while the display is still signaling HDR folds the
|
||||
// HDR-exit and the mode change into one HDMI renegotiation, which some
|
||||
// sink chains take 8-30 s to complete (#2172); sequenced, the HDR
|
||||
// infoframe clear is free and the SDR mode switch takes ~1 s. The
|
||||
// player's surface teardown commits the HDR exit within ~50 ms of
|
||||
// dispose, so 400 ms covers it with margin even on a busy main thread.
|
||||
private const val HDR_EXIT_SETTLE_MS = 400L
|
||||
}
|
||||
|
||||
private data class RefreshRateMatch(
|
||||
val reason: String,
|
||||
val priority: Int,
|
||||
val error: Float
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private data class DisplayModeCandidate(
|
||||
val mode: Display.Mode,
|
||||
val match: RefreshRateMatch
|
||||
)
|
||||
|
||||
private var currentVideoFps: Float = 0f
|
||||
private var currentVideoWidth: Int = 0
|
||||
private var currentVideoHeight: Int = 0
|
||||
private var currentMatchResolution: Boolean = false
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
private var pendingSettleRunnable: Runnable? = null
|
||||
private var watchdogRunnable: Runnable? = null
|
||||
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
|
||||
|
||||
// Owns the deferred HDR-exit restore. Deliberately NOT the shared player
|
||||
// [handler]: core dispose clears that one wholesale, and the restore must
|
||||
// survive player disposal or the display stays at the content rate.
|
||||
private val restoreHandler = Handler(android.os.Looper.getMainLooper())
|
||||
private var pendingRestoreRunnable: Runnable? = null
|
||||
|
||||
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
|
||||
// Request a display frame-rate switch. Invokes [onComplete] once, either:
|
||||
// - immediately with `switched=false` when no switch is needed (invalid fps,
|
||||
// no matching mode, or already matching); or
|
||||
// Request a display mode switch for the video's frame rate and/or, with
|
||||
// [matchResolution], its native resolution. Invokes [onComplete] once, either:
|
||||
// - immediately with `switched=false` when no switch is needed (no usable
|
||||
// fps/resolution target, no matching mode, or already matching); or
|
||||
// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the caller's
|
||||
// [extraDelayMs], with `switched=true`; or
|
||||
// - via a watchdog if the real event never arrives, so the caller doesn't hang.
|
||||
//
|
||||
// fps <= 0 with [matchResolution] requests a resolution-only switch that
|
||||
// keeps the refresh rate as close to the current one as possible.
|
||||
//
|
||||
// The caller is responsible for pausing playback before calling and resuming
|
||||
// it after [onComplete] fires.
|
||||
fun setVideoFrameRate(
|
||||
@@ -61,20 +66,27 @@ class FrameRateManager(
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int = 0,
|
||||
videoHeight: Int = 0,
|
||||
matchResolution: Boolean = false,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
// A new session's switch must not be clobbered by a still-pending
|
||||
// deferred restore from the previous session's teardown.
|
||||
cancelPendingRestore()
|
||||
currentVideoFps = fps
|
||||
currentVideoWidth = videoWidth
|
||||
currentVideoHeight = videoHeight
|
||||
if (fps <= 0f) {
|
||||
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
|
||||
currentMatchResolution = matchResolution
|
||||
val hasResolutionTarget = matchResolution && videoWidth > 0 && videoHeight > 0
|
||||
if (fps <= 0f && !hasResolutionTarget) {
|
||||
Log.d(TAG, "setVideoFrameRate: no usable target (fps=$fps, video=${videoWidth}x$videoHeight), skipping")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
log(
|
||||
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
|
||||
"video=${videoWidth}x$videoHeight, API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
|
||||
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution, " +
|
||||
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -84,21 +96,47 @@ class FrameRateManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
// [hdrActive]: the session was outputting HDR. The restore is then deferred
|
||||
// by [HDR_EXIT_SETTLE_MS] so the caller's surface teardown can commit the
|
||||
// HDR exit first — see [HDR_EXIT_SETTLE_MS] for why stacking them is slow.
|
||||
fun clearVideoFrameRate(hdrActive: Boolean = false) {
|
||||
Log.d(TAG, "clearVideoFrameRate(hdrActive=$hdrActive)")
|
||||
currentVideoFps = 0f
|
||||
// Resolve any pending setVideoFrameRate future as "not switched" so
|
||||
// the Dart caller's await doesn't hang on player dispose.
|
||||
firePendingCompletion("clear", switched = false)
|
||||
// Restore default display mode on API M (preferredDisplayModeId persists)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
cancelPendingRestore()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
// Nothing to restore when no preferred mode was ever applied.
|
||||
if ((activity.window?.attributes?.preferredDisplayModeId ?: 0) == 0) return
|
||||
if (hdrActive) {
|
||||
val restore = Runnable {
|
||||
pendingRestoreRunnable = null
|
||||
// Log.d, not [log]: this fires after core dispose, when the
|
||||
// Flutter-channel logger is already gone.
|
||||
Log.d(TAG, "restoring default display mode after HDR exit")
|
||||
restorePreferredDisplayMode()
|
||||
}
|
||||
pendingRestoreRunnable = restore
|
||||
restoreHandler.postDelayed(restore, HDR_EXIT_SETTLE_MS)
|
||||
} else {
|
||||
restorePreferredDisplayMode()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restorePreferredDisplayMode() {
|
||||
// preferredDisplayModeId persists on the window; restore the default.
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelPendingRestore() {
|
||||
pendingRestoreRunnable?.let { restoreHandler.removeCallbacks(it) }
|
||||
pendingRestoreRunnable = null
|
||||
}
|
||||
|
||||
// Release pending callbacks/listener without restoring the display mode.
|
||||
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
|
||||
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
|
||||
@@ -129,7 +167,12 @@ class FrameRateManager(
|
||||
cb(switched)
|
||||
}
|
||||
|
||||
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
private fun registerDisplayListener(
|
||||
fps: Float,
|
||||
targetModeId: Int,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
// Resolve any previous pending op before starting a new one.
|
||||
firePendingCompletion("superseded", switched = false)
|
||||
pendingCompletion = onComplete
|
||||
@@ -144,7 +187,9 @@ class FrameRateManager(
|
||||
getDisplayManager().unregisterDisplayListener(this)
|
||||
displayListener = null
|
||||
|
||||
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
|
||||
val settle = Runnable {
|
||||
firePendingCompletion("display settled", switched = currentMatchesRequest(fps, targetModeId))
|
||||
}
|
||||
pendingSettleRunnable = settle
|
||||
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
|
||||
}
|
||||
@@ -154,37 +199,20 @@ class FrameRateManager(
|
||||
// Watchdog: if the TV never signals a display change (silently ignoring
|
||||
// the mode request), still complete after a bounded wait so the caller
|
||||
// doesn't hang.
|
||||
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
|
||||
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentMatchesRequest(fps, targetModeId)) }
|
||||
watchdogRunnable = watchdog
|
||||
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
|
||||
}
|
||||
|
||||
private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
|
||||
if (refreshRate <= 0f || fps <= 0f) return null
|
||||
|
||||
val exactError = abs(refreshRate - fps)
|
||||
if (exactError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
|
||||
}
|
||||
|
||||
val multiple = (refreshRate / fps).roundToInt()
|
||||
if (multiple > 1) {
|
||||
val multipleError = abs(refreshRate - (fps * multiple))
|
||||
if (multipleError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun currentRateMatch(fps: Float): RefreshRateMatch? {
|
||||
val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
currentDisplayMode()?.refreshRate
|
||||
} else {
|
||||
null
|
||||
} ?: return null
|
||||
return matchRefreshRate(current, fps)
|
||||
// Whether the display landed on the requested mode: the exact target, or —
|
||||
// for a rate request — any mode whose refresh presents [fps] (a TV may pick
|
||||
// a different-but-equivalent mode). A resolution-only request (fps <= 0)
|
||||
// only counts the exact target.
|
||||
private fun currentMatchesRequest(fps: Float, targetModeId: Int): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
|
||||
val current = currentDisplayMode() ?: return false
|
||||
if (current.modeId == targetModeId) return true
|
||||
return DisplayModeSelector.matchRefreshRate(current.refreshRate, fps) != null
|
||||
}
|
||||
|
||||
private fun currentModeDescription(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -214,46 +242,11 @@ class FrameRateManager(
|
||||
private fun describeSupportedModes(modes: Array<Display.Mode>): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) }
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun findBestModeMatch(
|
||||
fps: Float,
|
||||
currentMode: Display.Mode,
|
||||
supportedModes: Array<Display.Mode>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): DisplayModeCandidate? {
|
||||
// Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only
|
||||
// switch, the least disruptive (no resolution/HDMI renegotiation).
|
||||
supportedModes.asSequence()
|
||||
.filter { it.physicalHeight == currentMode.physicalHeight && it.physicalWidth == currentMode.physicalWidth }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<DisplayModeCandidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
?.let { return it }
|
||||
|
||||
// Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a
|
||||
// 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one
|
||||
// that downscales the video below its native size (trading detail for cadence).
|
||||
// Requires known video dimensions; without them keep Tier-1-only behaviour.
|
||||
if (videoWidth <= 0 || videoHeight <= 0) return null
|
||||
val currentArea = currentMode.physicalWidth.toLong() * currentMode.physicalHeight
|
||||
return supportedModes.asSequence()
|
||||
.filter { it.physicalWidth >= videoWidth && it.physicalHeight >= videoHeight }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
// Prefer the resolution closest to the panel's current one (least change,
|
||||
// keeps panel-native res when a high-res match exists), then refresh match.
|
||||
compareBy<DisplayModeCandidate> { abs(it.mode.physicalWidth.toLong() * it.mode.physicalHeight - currentArea) }
|
||||
.thenBy { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
)
|
||||
}
|
||||
private fun Display.Mode.toModeInfo(): DisplayModeSelector.ModeInfo = DisplayModeSelector.ModeInfo(modeId, physicalWidth, physicalHeight, refreshRate)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
log("setDisplayMode fps=$fps")
|
||||
log("setDisplayMode fps=$fps, matchResolution=$currentMatchResolution")
|
||||
val display = currentDisplay()
|
||||
if (display == null) {
|
||||
log("display unavailable")
|
||||
@@ -270,34 +263,43 @@ class FrameRateManager(
|
||||
val currentMode = display.mode
|
||||
log("supported modes=${describeSupportedModes(supportedModes)}")
|
||||
|
||||
val modeMatch = findBestModeMatch(fps, currentMode, supportedModes, currentVideoWidth, currentVideoHeight)
|
||||
if (modeMatch == null) {
|
||||
val selection = DisplayModeSelector.findBestMode(
|
||||
fps,
|
||||
currentMode.toModeInfo(),
|
||||
supportedModes.map { it.toModeInfo() },
|
||||
currentVideoWidth,
|
||||
currentVideoHeight,
|
||||
currentMatchResolution
|
||||
)
|
||||
if (selection == null) {
|
||||
log(
|
||||
"no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight} " +
|
||||
"(video=${currentVideoWidth}x$currentVideoHeight)"
|
||||
"(video=${currentVideoWidth}x$currentVideoHeight, matchResolution=$currentMatchResolution)"
|
||||
)
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
val modeToUse = modeMatch.mode
|
||||
val modeToUse = supportedModes.firstOrNull { it.modeId == selection.mode.modeId }
|
||||
if (modeToUse == null) {
|
||||
log("selected mode #${selection.mode.modeId} disappeared from supported modes")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
if (modeToUse.modeId == currentMode.modeId) {
|
||||
log("current mode already matches ${fps}fps (${modeMatch.match.reason}), no switch needed")
|
||||
log("current mode already matches ${fps}fps (${selection.reason}), no switch needed")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
log(
|
||||
"switching to ${describeMode(modeToUse)} for ${fps}fps " +
|
||||
"(${modeMatch.match.reason}, error=${modeMatch.match.error})"
|
||||
)
|
||||
log("switching to ${describeMode(modeToUse)} for ${fps}fps (${selection.reason})")
|
||||
val window = activity.window
|
||||
if (window == null) {
|
||||
log("window unavailable")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
registerDisplayListener(fps, extraDelayMs, onComplete)
|
||||
registerDisplayListener(fps, modeToUse.modeId, extraDelayMs, onComplete)
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = modeToUse.modeId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,34 @@ import java.util.Locale
|
||||
|
||||
/** Canonical decoder lookup and hardware classification for native playback. */
|
||||
internal object MediaCodecQuery {
|
||||
/**
|
||||
* Codecs whose advertised support is decided by hardware: both have a
|
||||
* software decoder behind them, but a software HEVC or AV1 decode on
|
||||
* phone/TV-class hardware drops frames.
|
||||
*/
|
||||
private val HARDWARE_GATED_VIDEO_MIME_TYPES = mapOf(
|
||||
"hevc" to "video/hevc",
|
||||
"av1" to "video/av01"
|
||||
)
|
||||
|
||||
/** Codec name -> whether this device has a hardware decoder for it. */
|
||||
fun hardwareVideoDecodeSupport(
|
||||
hardwareMimeTypes: Set<String> = hardwareDecoderMimeTypes()
|
||||
): Map<String, Boolean> = HARDWARE_GATED_VIDEO_MIME_TYPES.mapValues { (_, mimeType) -> mimeType in hardwareMimeTypes }
|
||||
|
||||
/**
|
||||
* Every MIME type served by a hardware decoder, lowercased. One walk answers
|
||||
* for all codecs, unlike [findHardwareDecoder], which rescans per lookup.
|
||||
*/
|
||||
private fun hardwareDecoderMimeTypes(): Set<String> {
|
||||
val mimeTypes = HashSet<String>()
|
||||
for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) {
|
||||
if (info.isEncoder || !isHardwareAccelerated(info)) continue
|
||||
for (type in info.supportedTypes) mimeTypes.add(type.lowercase(Locale.ROOT))
|
||||
}
|
||||
return mimeTypes
|
||||
}
|
||||
|
||||
fun findHardwareDecoder(
|
||||
mimeType: String,
|
||||
codecKind: Int = MediaCodecList.REGULAR_CODECS,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
@@ -14,8 +15,37 @@ internal object PlayerSurfaceHost {
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Fallback fill for the frames before the punch surface below is placed.
|
||||
setBackgroundColor(Color.BLACK)
|
||||
this.clipChildren = clipChildren
|
||||
addView(createLetterboxPunchSurface(activity))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen, buffer-less SurfaceView kept beneath the video surface.
|
||||
*
|
||||
* Its only job is taking the letterbox area off the app-window (graphics)
|
||||
* plane: like any below-window SurfaceView it punches the parent canvas and
|
||||
* registers its rect as a window transparent region, so the pixels around
|
||||
* the video rect scan out as the SurfaceFlinger backdrop instead of
|
||||
* window-plane black. Several TV compositors (Fire TV Stick 4K Max, some
|
||||
* Sony/Philips models) raise SDR graphics-plane black while the display is
|
||||
* in HDR/Dolby Vision mode, which shows as gray letterbox bars on OLED
|
||||
* panels (issue #2163; same mechanism as ExoPlayer #8803 and Kodi #25300).
|
||||
*
|
||||
* No buffer is ever posted to it: SurfaceFlinger skips buffer-less layers,
|
||||
* and drawing black into it would put the bars back onto an SDR layer —
|
||||
* exactly the plane being avoided. Views drawn on the parent canvas after
|
||||
* the punch (Media3 subtitle cues) still re-claim their own bounds.
|
||||
*/
|
||||
private fun createLetterboxPunchSurface(activity: Activity): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(false)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -2)
|
||||
}
|
||||
|
||||
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
@@ -29,6 +59,23 @@ internal object PlayerSurfaceHost {
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent plane directly above the video surface for the mpv
|
||||
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
|
||||
* the video SurfaceView but still beneath the Flutter window content.
|
||||
*/
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
holder.addCallback(callback)
|
||||
holder.setFormat(PixelFormat.TRANSLUCENT)
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(true)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -1)
|
||||
}
|
||||
|
||||
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
|
||||
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
contentView.addView(container, 0)
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SurfacePlayerCore {
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,83 +48,205 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() {
|
||||
assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true })
|
||||
fun dtsDecodeIsForcedToFfmpegWhenDirectOutputIsBlocked() {
|
||||
// Passthrough disabled (the #1995 report), downmix, normalization, or a failure block:
|
||||
// the stream decodes, and platform DTS decoders render silence on license-gated devices.
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { true }, { true }))
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { true }, { true }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListDropsCodecsTheRouteCannotBitstream() {
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream,
|
||||
// TrueHD and DTS do not (#1703).
|
||||
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
|
||||
|
||||
assertEquals("ac3,eac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
|
||||
fun dtsDecodeIsForcedToFfmpegWhenTheRouteCannotBitstream() {
|
||||
// Onn 4K Plus over HDMI to a Dolby-only TV: passthrough enabled (the Android TV default)
|
||||
// still decodes because the route carries no DTS shape (#1995).
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { false }))
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListOmitsDtsHdOnDtsCoreOnlyRoutes() {
|
||||
// media3's passthrough probe would answer yes for DTS-HD here by downgrading to the
|
||||
// DTS core. mpv reads `dts,dts-hd` as `dts-hd` alone and force-passes DTS-HD MA, so
|
||||
// accepting that downgrade would break DTS too.
|
||||
val dtsCoreOnlyRoute = setOf(C.ENCODING_DTS)
|
||||
|
||||
assertEquals("dts", mpvSpdifCodecs { encoding -> encoding in dtsCoreOnlyRoute })
|
||||
fun bitstreamCapableDtsRoutesKeepThePlatformDecoderVisible() {
|
||||
// A bitstreaming session never engages a decoder; leaving the platform decoder visible
|
||||
// keeps the hardware-decoder tunneling gate exactly as it was.
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { true }))
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { true }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListKeepsDtsHdWhenTheRouteAdvertisesIt() {
|
||||
val dtsHdRoute = setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD)
|
||||
|
||||
assertEquals("dts,dts-hd", mpvSpdifCodecs { encoding -> encoding in dtsHdRoute })
|
||||
fun dtsVariantsFfmpegCannotClaimAreLeftAlone() {
|
||||
// DTS Express and DTS:X are not in FfmpegLibrary's mime map; hiding their platform
|
||||
// decoders would leave those streams with no decoder at all.
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd;profile=lbr", { true }, { false }))
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.uhd", { true }, { false }))
|
||||
assertFalse(shouldForceFfmpegDtsDecode(MimeTypes.AUDIO_TRUEHD, { true }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierIsNeverOfferedBelowApi29() {
|
||||
// No direct-playback oracle exists there, and getMinBufferSize alone is known to lie
|
||||
// (a Shield sizes the tuple, then the AudioTrack fails to initialise).
|
||||
fun nonDtsMimesNeverConsultTheDtsProbes() {
|
||||
// Decoder selection runs this predicate for every mime, video included; the probes make
|
||||
// binder calls and must stay behind the mime gate.
|
||||
assertFalse(
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = 28,
|
||||
canSizeCarrierBuffer = { true },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true }
|
||||
shouldForceFfmpegDtsDecode(
|
||||
MimeTypes.VIDEO_H265,
|
||||
{ throw AssertionError("directOutputBlocked consulted for a non-DTS mime") },
|
||||
{ throw AssertionError("routeCanBitstreamDts consulted for a non-DTS mime") }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierRequiresASizableBufferOnEveryTier() {
|
||||
for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) {
|
||||
fun spdifListNamesEveryCodecARouteWithAllShapesCanCarry() {
|
||||
// libmpv v1.1.0's audiotrack AO opens each burst at its own rate and channel mask, so a route
|
||||
// that takes all three shapes and advertises everything bitstreams the lossless codecs too.
|
||||
// `dts-hd` supersedes plain `dts`: ad_spdif picks the core burst per file for non-HD tracks.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListOnAStereo48kOnlyRouteNamesTheCoreCodecsOnly() {
|
||||
// E-AC3 (192kHz), TrueHD MAT and DTS-HD MA (192kHz/8ch) have no track to ride here.
|
||||
assertEquals("ac3,dts", spdifCodecs(allEncodings, setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdFallsBackToThePlainCoreWithoutTheCarrierShape() {
|
||||
// Advertising ENCODING_DTS_HD says the receiver decodes it, not that the route takes the
|
||||
// 192kHz/8ch track its burst needs (#1988); naming `dts-hd` anyway strands playback.
|
||||
assertEquals(
|
||||
"dts",
|
||||
spdifCodecs(setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD), setOf(MpvIecShape.STEREO_48K))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eac3IsNotNamedWithoutThe192kStereoShape() {
|
||||
assertEquals("", spdifCodecs(setOf(C.ENCODING_E_AC3), setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trueHdIsNotNamedWithoutTheCarrierShape() {
|
||||
assertEquals(
|
||||
"",
|
||||
spdifCodecs(
|
||||
setOf(C.ENCODING_DOLBY_TRUEHD),
|
||||
setOf(MpvIecShape.STEREO_48K, MpvIecShape.STEREO_192K)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainDtsSurvivesWhenTheRouteDoesNotAdvertiseDtsHd() {
|
||||
// The core burst is stereo/48k, so it rides a full-shape route unchanged when only the
|
||||
// lossless encoding is missing.
|
||||
assertEquals("dts", spdifCodecs(setOf(C.ENCODING_DTS), allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListDropsCodecsTheRouteCannotBitstream() {
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, DTS does not (#1703).
|
||||
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
|
||||
|
||||
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs({ false }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
|
||||
// Every encoding advertised, but no raw track and no IEC 61937 shape opens: mpv has no
|
||||
// decode fallback for a named codec, so nothing may be named (#1991).
|
||||
assertEquals("", spdifCodecs(allEncodings, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawCapableRouteBitstreamsTheCoreCodecsWithoutAnyIecShape() {
|
||||
// #2177's Shield: every mpv IEC track opens and drains into silence, while raw
|
||||
// ENCODING_AC3/E_AC3/DTS tracks (the ExoPlayer transport) play. The AO opens raw first,
|
||||
// so raw support alone must qualify the core codecs.
|
||||
assertEquals(
|
||||
"ac3,eac3,dts",
|
||||
spdifCodecs(allEncodings, emptySet(), raw = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3, C.ENCODING_DTS))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawSupportNeverQualifiesTheLosslessCodecs() {
|
||||
// TrueHD and DTS-HD MA have no raw transport in the AO; they ride the 192kHz/7.1 IEC
|
||||
// carrier or decode. A route that takes every raw track but no carrier must not name them.
|
||||
assertEquals("ac3,eac3,dts", spdifCodecs(allEncodings, emptySet(), raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawProbeIsOnlyConsultedForRawCandidates() {
|
||||
// The raw probe costs real route calls; the carrier-only codecs must never trigger it.
|
||||
val codecs = mpvSpdifCodecs(
|
||||
{ true },
|
||||
{ true },
|
||||
{ encoding ->
|
||||
if (encoding == C.ENCODING_DOLBY_TRUEHD || encoding == C.ENCODING_DTS_HD) {
|
||||
throw AssertionError("raw probe consulted for a carrier-only codec")
|
||||
}
|
||||
true
|
||||
}
|
||||
)
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", codecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdStillSupersedesPlainDtsWhenDtsBitstreamsRaw() {
|
||||
// dts-hd selects the lossless spdif decoder for the whole dts codec; the raw core track
|
||||
// must not resurrect the plain name beside it.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes, raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteIsNeverOfferedBelowApi24() {
|
||||
// ENCODING_IEC61937 does not exist there.
|
||||
assertFalse(
|
||||
iecRouteSupported(
|
||||
sdkInt = 23,
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true },
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteRequiresASizableBufferOnEveryTier() {
|
||||
for (sdkInt in intArrayOf(25, 28, 29, 30, 32, 33, 34)) {
|
||||
assertFalse(
|
||||
"api $sdkInt",
|
||||
trueHdMatCarrierSupported(
|
||||
iecRouteSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeCarrierBuffer = { false },
|
||||
canSizeBuffer = { false },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true }
|
||||
directPlaybackSupported = { true },
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierOnApi29To32FollowsTheDirectPlaybackProbe() {
|
||||
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
|
||||
fun routeOnApi24To28FollowsTheHdmiAdvertisement() {
|
||||
// No runtime oracle exists below API 29; only an HDMI AudioDeviceInfo that explicitly
|
||||
// advertises the IEC tuple can vouch for it. Shield Experience 8.x is API 28, and a flat
|
||||
// `false` on this tier force-decoded TrueHD on routes that genuinely carry it (#1991).
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
for (sdkInt in intArrayOf(29, 30, 32)) {
|
||||
for (sdkInt in intArrayOf(25, 28)) {
|
||||
assertEquals(
|
||||
"api $sdkInt supported=$supported",
|
||||
supported,
|
||||
trueHdMatCarrierSupported(
|
||||
iecRouteSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeCarrierBuffer = { true },
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
|
||||
directPlaybackSupported = { supported }
|
||||
directPlaybackSupported = { throw AssertionError("isDirectPlaybackSupported does not exist below API 29") },
|
||||
hdmiRouteAdvertised = { supported }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -132,20 +254,55 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierOnApi33UsesTheBitstreamProbe() {
|
||||
fun routeOnApi29To32FollowsTheDirectPlaybackProbe() {
|
||||
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
for (sdkInt in intArrayOf(29, 30, 32)) {
|
||||
assertEquals(
|
||||
"api $sdkInt supported=$supported",
|
||||
supported,
|
||||
iecRouteSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
|
||||
directPlaybackSupported = { supported },
|
||||
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routeOnApi33UsesTheBitstreamProbe() {
|
||||
// getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29
|
||||
// probe must not shadow it where the platform can answer precisely.
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
assertEquals(
|
||||
"supported=$supported",
|
||||
supported,
|
||||
trueHdMatCarrierSupported(
|
||||
iecRouteSupported(
|
||||
sdkInt = 33,
|
||||
canSizeCarrierBuffer = { true },
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { supported },
|
||||
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") }
|
||||
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") },
|
||||
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every encoding the spdif table can ask for, i.e. a receiver that decodes all of them. */
|
||||
private val allEncodings = setOf(
|
||||
C.ENCODING_AC3,
|
||||
C.ENCODING_E_AC3,
|
||||
C.ENCODING_DOLBY_TRUEHD,
|
||||
C.ENCODING_DTS,
|
||||
C.ENCODING_DTS_HD
|
||||
)
|
||||
|
||||
private val allShapes = MpvIecShape.values().toSet()
|
||||
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String =
|
||||
mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the DTS-HD -> IEC 61937 "DTS type IV" carrier against FFmpeg (#1988).
|
||||
*
|
||||
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
|
||||
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
|
||||
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
|
||||
* FFmpeg's own input and output at the 768kHz HD rate that fills the 192kHz/7.1 carrier.
|
||||
*
|
||||
* FFmpeg cannot encode Master Audio, so the access units are synthesized — real `dca` core frames,
|
||||
* each glued to a hand-built extension substream — by `dtshd_fixture_generator.py` beside the
|
||||
* fixtures; the golden is then FFmpeg's own packing:
|
||||
*
|
||||
* ```
|
||||
* ffmpeg -f dts -i dtshd_ma_access_units.bin -c:a copy -f spdif -dtshd_rate 768000 \
|
||||
* dtshd_ma_iec61937_golden.bin
|
||||
* ```
|
||||
*
|
||||
* One mid-stream unit is oversized on purpose, so the fixtures also pin FFmpeg's overflow answer:
|
||||
* strip to the core substream and hold that for ~60s.
|
||||
*/
|
||||
class DtsHdIecPackerTest {
|
||||
|
||||
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
|
||||
.use { it.readBytes() }
|
||||
|
||||
private val accessUnits by lazy { resource("dtshd_ma_access_units.bin") }
|
||||
private val golden by lazy { resource("dtshd_ma_iec61937_golden.bin") }
|
||||
|
||||
/** 512 samples at the fixture's 48kHz core rate on the 768kHz carrier model, in bytes. */
|
||||
private val burstSize = 32768
|
||||
|
||||
/** The whole point: our carrier and FFmpeg's are the same bytes. */
|
||||
@Test
|
||||
fun packedCarrierMatchesFfmpegByteForByte() {
|
||||
val packed = packAll(accessUnits)
|
||||
|
||||
assertEquals(
|
||||
"burst count differs from FFmpeg",
|
||||
golden.size / burstSize,
|
||||
packed.size / burstSize
|
||||
)
|
||||
// Compare per burst so a failure names the burst rather than dumping 480KB.
|
||||
for (index in 0 until packed.size / burstSize) {
|
||||
val from = index * burstSize
|
||||
val to = from + burstSize
|
||||
assertArrayEquals(
|
||||
"burst $index differs from FFmpeg",
|
||||
golden.copyOfRange(from, to),
|
||||
packed.copyOfRange(from, to)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each burst is a full IEC 61937 frame: preamble, then payload, then zero padding. */
|
||||
@Test
|
||||
fun everyBurstCarriesTheDtsHdPreamble() {
|
||||
val packed = packAll(accessUnits)
|
||||
|
||||
for (index in 0 until packed.size / burstSize) {
|
||||
val at = index * burstSize
|
||||
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
|
||||
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
|
||||
// Data type 0x11 with subtype 4: an 8192 IEC 60958 frame repetition period.
|
||||
assertEquals("burst $index Pc (IEC61937_DTSHD)", 0x0411, readLittleEndianShort(packed, at + 4))
|
||||
val payloadSize = 10 + 2 + burstAccessUnitSize(packed, at)
|
||||
assertEquals(
|
||||
"burst $index Pd must carry FFmpeg's (length & 0xf) == 0x8 alignment",
|
||||
((payloadSize + 0x17) and 0x0F.inv()) - 8,
|
||||
readLittleEndianShort(packed, at + 6)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Master Audio peak the carrier cannot hold is stripped to the always-fitting core substream,
|
||||
* and stays stripped for ~60s so the receiver does not flap between core and MA decoding. The
|
||||
* fixture's fifth unit is oversized on purpose; everything after it is core-only.
|
||||
*/
|
||||
@Test
|
||||
fun anOversizedUnitStripsToTheCoreAndHolds() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val packed = packAll(accessUnits, packer)
|
||||
|
||||
val fullUnit = 3904
|
||||
val coreOnly = 1884
|
||||
for (index in 0 until packed.size / burstSize) {
|
||||
val expected = if (index < 4) fullUnit else coreOnly
|
||||
assertEquals("burst $index payload size", expected, burstAccessUnitSize(packed, index * burstSize))
|
||||
}
|
||||
assertTrue("the strip must be observable for logging", packer.strippedToCore)
|
||||
assertFalse("stripping is a downgrade, not an unsupported stream", packer.unsupportedStream)
|
||||
}
|
||||
|
||||
/** Core frames and their glued extension substreams split as one access unit each. */
|
||||
@Test
|
||||
fun accessUnitsSpanTheCoreAndItsExtensionSubstreams() {
|
||||
val packer = DtsHdIecPacker()
|
||||
var units = 0
|
||||
var offset = 0
|
||||
while (offset < accessUnits.size) {
|
||||
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
|
||||
if (length == 0) break
|
||||
units++
|
||||
offset += length
|
||||
}
|
||||
assertEquals("every byte of the fixture belongs to a unit", accessUnits.size, offset)
|
||||
assertEquals(golden.size / burstSize, units)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams with core sometimes open with a stray HD frame that has no core (FFmpeg discards
|
||||
* these). It must be consumed as a unit so the stream keeps moving, but never carried: there is
|
||||
* no core to derive the burst period from.
|
||||
*/
|
||||
@Test
|
||||
fun aStrayLeadingExtensionSubstreamIsConsumedButNotCarried() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val firstUnit = packer.accessUnitLength(accessUnits, 0, accessUnits.size)
|
||||
val exssSize = 2020
|
||||
val stray = accessUnits.copyOfRange(firstUnit - exssSize, firstUnit)
|
||||
|
||||
val length = packer.accessUnitLength(stray, 0, stray.size)
|
||||
|
||||
assertEquals("the stray frame's own size field walks it", exssSize, length)
|
||||
assertNull("a coreless frame cannot ride the carrier", packer.packAccessUnit(stray, 0, length))
|
||||
assertFalse("a stray frame is dropped, not latched", packer.unsupportedStream)
|
||||
}
|
||||
|
||||
/** The wide (bHeaderSizeType=1) extension-substream header carries 20-bit size fields. */
|
||||
@Test
|
||||
fun wideHeaderExtensionSubstreamSizesAreParsed() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val core = accessUnits.copyOfRange(0, 1884)
|
||||
val exssSize = 4100
|
||||
val unit = core + wideHeaderExtensionSubstream(exssSize)
|
||||
|
||||
assertEquals(1884 + exssSize, packer.accessUnitLength(unit, 0, unit.size))
|
||||
}
|
||||
|
||||
/**
|
||||
* Little-endian and 14-bit core framings (S/PDIF and DTS-in-WAV captures) have no period mapping
|
||||
* on this carrier; they must latch the stream unsupported so the sink hands it to the decoder,
|
||||
* not silently consume it.
|
||||
*/
|
||||
@Test
|
||||
fun nonBigEndianCoreFramingLatchesTheStreamUnsupported() {
|
||||
for (sync in listOf(
|
||||
byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()),
|
||||
byteArrayOf(0x1F, 0xFF.toByte(), 0xE8.toByte(), 0x00),
|
||||
byteArrayOf(0xFF.toByte(), 0x1F, 0x00, 0xE8.toByte())
|
||||
)) {
|
||||
val packer = DtsHdIecPacker()
|
||||
val buffer = sync + ByteArray(64)
|
||||
|
||||
assertEquals(0, packer.accessUnitLength(buffer, 0, buffer.size))
|
||||
assertTrue("sync ${sync.joinToString { "%02x".format(it) }}", packer.unsupportedStream)
|
||||
}
|
||||
}
|
||||
|
||||
/** A 44.1kHz-family core maps to no IEC 61937-11 repetition period at 192kHz; it must decode. */
|
||||
@Test
|
||||
fun aFortyFourFamilyCoreLatchesTheStreamUnsupported() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val unit = accessUnits.copyOfRange(0, 1884)
|
||||
// SFREQ sits in bits [5:2] of byte 8; index 8 is 44100Hz.
|
||||
unit[8] = ((unit[8].toInt() and 0b11000011) or (8 shl 2)).toByte()
|
||||
|
||||
assertNull(packer.packAccessUnit(unit, 0, unit.size))
|
||||
assertTrue(packer.unsupportedStream)
|
||||
}
|
||||
|
||||
/**
|
||||
* The flags gate every later call, so leaving them latched across a reset would make a packer
|
||||
* that once saw a bad stream emit nothing (or log strips) for the rest of its life.
|
||||
*/
|
||||
@Test
|
||||
fun resetClearsTheLatchesAndReproducesTheStream() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val leSync = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
|
||||
packer.accessUnitLength(leSync, 0, leSync.size)
|
||||
assertTrue(packer.unsupportedStream)
|
||||
|
||||
packer.reset()
|
||||
|
||||
assertFalse("reset must clear the unsupported latch", packer.unsupportedStream)
|
||||
assertFalse("reset must clear the strip latch", packer.strippedToCore)
|
||||
assertArrayEquals(
|
||||
"a clean packer must reproduce the stream after a poisoned one",
|
||||
golden,
|
||||
packAll(accessUnits, packer)
|
||||
)
|
||||
}
|
||||
|
||||
/** The packer must not allocate a burst per unit; buffers alternate and are reused. */
|
||||
@Test
|
||||
fun burstBuffersAreReusedRatherThanAllocated() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
|
||||
var bursts = 0
|
||||
var offset = 0
|
||||
while (offset < accessUnits.size) {
|
||||
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
|
||||
if (length == 0) break
|
||||
packer.packAccessUnit(accessUnits, offset, length)?.let {
|
||||
seen[it] = true
|
||||
bursts++
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
assertTrue("expected multiple bursts", bursts > 2)
|
||||
assertEquals("buffers must alternate between exactly two", 2, seen.size)
|
||||
}
|
||||
|
||||
private fun packAll(units: ByteArray, packer: DtsHdIecPacker = DtsHdIecPacker()): ByteArray {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
var offset = 0
|
||||
while (offset < units.size) {
|
||||
val length = packer.accessUnitLength(units, offset, units.size)
|
||||
if (length == 0) break
|
||||
packer.packAccessUnit(units, offset, length)?.let { burst ->
|
||||
val copy = ByteArray(burst.remaining())
|
||||
burst.duplicate().get(copy)
|
||||
out.write(copy)
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** The 16-bit big-endian size field after the start code, read from the byte-swapped burst. */
|
||||
private fun burstAccessUnitSize(packed: ByteArray, burstOffset: Int): Int {
|
||||
// Payload bytes 10 and 11 land swapped within their 16-bit word: 10 -> +19, 11 -> +18.
|
||||
val high = packed[burstOffset + 8 + 11].toInt() and 0xFF
|
||||
val low = packed[burstOffset + 8 + 10].toInt() and 0xFF
|
||||
return (high shl 8) or low
|
||||
}
|
||||
|
||||
/** An extension substream whose header uses the wide 12/20-bit size fields. */
|
||||
private fun wideHeaderExtensionSubstream(size: Int): ByteArray {
|
||||
val frame = ByteArray(size)
|
||||
frame[0] = 0x64
|
||||
frame[1] = 0x58
|
||||
frame[2] = 0x20
|
||||
frame[3] = 0x25
|
||||
// After UserDefinedBits(8): nExtSSIndex(2)=0, bHeaderSizeType(1)=1, then 12 header-size bits
|
||||
// and 20 frame-size bits, all values stored minus one.
|
||||
var bits = 0L
|
||||
bits = (bits shl 2) or 0L
|
||||
bits = (bits shl 1) or 1L
|
||||
bits = (bits shl 12) or (32L - 1)
|
||||
bits = (bits shl 20) or (size.toLong() - 1)
|
||||
// 35 bits, MSB-aligned into bytes 5..9.
|
||||
val aligned = bits shl (40 - 35)
|
||||
for (i in 0 until 5) {
|
||||
frame[5 + i] = ((aligned shr (32 - 8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
+176
-16
@@ -24,13 +24,14 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Routing and back-pressure contract for the TrueHD MAT carrier (#1804).
|
||||
* Routing and back-pressure contract for the IEC 61937 carrier: TrueHD/MAT (#1804) and DTS-HD
|
||||
* (#1988).
|
||||
*
|
||||
* The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting
|
||||
* the wrong sink handle TrueHD.
|
||||
* the wrong sink handle a carrier codec.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class TrueHdCarrierSinkTest {
|
||||
class IecCarrierSinkTest {
|
||||
|
||||
private val accessUnits: ByteArray =
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
|
||||
@@ -40,12 +41,28 @@ class TrueHdCarrierSinkTest {
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_iec61937_golden.bin"))
|
||||
.use { it.readBytes() }
|
||||
|
||||
private val dtsHdAccessUnits: ByteArray by lazy {
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_access_units.bin"))
|
||||
.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private val dtsHdGolden: ByteArray by lazy {
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_iec61937_golden.bin"))
|
||||
.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun trueHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
|
||||
.setChannelCount(6)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun dtsHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_DTS_HD)
|
||||
.setChannelCount(6)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
|
||||
|
||||
private fun sink(
|
||||
@@ -53,7 +70,7 @@ class TrueHdCarrierSinkTest {
|
||||
normal: FakeSink = FakeSink(),
|
||||
routeAvailable: Boolean = true,
|
||||
blocked: Boolean = false
|
||||
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
||||
) = IecCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
||||
|
||||
/**
|
||||
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
|
||||
@@ -75,6 +92,54 @@ class TrueHdCarrierSinkTest {
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdWithACarrierRouteIsSupportedDirectly() {
|
||||
val carrierSink = sink()
|
||||
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
* The route that advertises the carrier and raw `ENCODING_DTS_HD` at once is the one whose raw
|
||||
* path renders silence (#1988), so while the carrier route exists DTS-HD is the carrier or it is
|
||||
* decoded — never the normal sink's raw path, even when policy declines the carrier.
|
||||
*/
|
||||
@Test
|
||||
fun dtsHdOnACarrierRouteNeverFallsThroughToTheRawPath() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val blockedSink = sink(normal = normal, blocked = true)
|
||||
|
||||
assertFalse(blockedSink.supportsFormat(dtsHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, blockedSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes without the IEC carrier never had it to lose, and some of them bitstream raw DTS-HD
|
||||
* genuinely; the pre-carrier behavior is preserved there.
|
||||
*/
|
||||
@Test
|
||||
fun dtsHdWithoutACarrierRouteFallsThroughToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val carrierSink = sink(normal = normal, routeAvailable = false)
|
||||
|
||||
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
|
||||
@Test
|
||||
fun dtsExpressIsLeftToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val carrierSink = sink(normal = normal)
|
||||
val express = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_DTS_EXPRESS)
|
||||
.setSampleRate(48_000)
|
||||
.build()
|
||||
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(express))
|
||||
assertTrue(carrierSink.supportsFormat(express))
|
||||
}
|
||||
|
||||
/** Downmix and normalization already force decoding; the carrier must not override that. */
|
||||
@Test
|
||||
fun blockedDirectOutputDeclinesTheCarrier() {
|
||||
@@ -83,8 +148,8 @@ class TrueHdCarrierSinkTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 44.1kHz-family TrueHD rides a 176.4kHz carrier this path does not build. It has to be decided
|
||||
* from the format: the packer only learns the rate from a major sync, long after selection, and
|
||||
* 44.1kHz-family streams ride a 176.4kHz carrier this path does not build. It has to be decided
|
||||
* from the format: the packer only learns the rate from the bitstream, long after selection, and
|
||||
* selecting the carrier for it would produce silence.
|
||||
*/
|
||||
@Test
|
||||
@@ -92,6 +157,8 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 44_100)))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 176_400)))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 44_100)))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 88_200)))
|
||||
}
|
||||
|
||||
/** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */
|
||||
@@ -100,6 +167,7 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,6 +251,33 @@ class TrueHdCarrierSinkTest {
|
||||
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
|
||||
}
|
||||
|
||||
/**
|
||||
* The DTS-HD equivalent: a bitstream whose framing the carrier cannot ride (an S/PDIF-style
|
||||
* little-endian capture) must latch onto the decoder, and the offending unit must survive.
|
||||
*/
|
||||
@Test
|
||||
fun aDtsHdFramingMismatchLeavesTheCarrierInsteadOfGoingSilent() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
val littleEndianCapture = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
|
||||
val buffer = ByteBuffer.wrap(littleEndianCapture)
|
||||
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
|
||||
assertFalse("a mismatch must apply back pressure, not report success", accepted)
|
||||
assertEquals("the offending unit must stay in the buffer", 0, buffer.position())
|
||||
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
|
||||
assertEquals(
|
||||
"DTS-HD must now decode rather than ride the carrier or the raw path",
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
carrierSink.getFormatSupport(dtsHdFormat())
|
||||
)
|
||||
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
|
||||
}
|
||||
|
||||
/**
|
||||
* media3 resets every renderer disabled by a new selection before enabling the replacement, and
|
||||
* both audio renderers share this sink. That reset lands mid-handover, so a latch cleared there
|
||||
@@ -278,7 +373,7 @@ class TrueHdCarrierSinkTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything that is not TrueHD keeps going to the existing processed sink. */
|
||||
/** Everything that is not a carrier codec keeps going to the existing processed sink. */
|
||||
@Test
|
||||
fun otherFormatsAreLeftToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
@@ -303,7 +398,7 @@ class TrueHdCarrierSinkTest {
|
||||
// Refuse every third burst once, so rejections land inside samples rather than between them.
|
||||
carrier.refuseEveryNth = 3
|
||||
|
||||
val produced = feedWholeStream(carrierSink)
|
||||
val produced = feedWholeStream(carrierSink, accessUnits)
|
||||
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
|
||||
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
|
||||
}
|
||||
@@ -315,7 +410,53 @@ class TrueHdCarrierSinkTest {
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
assertArrayEquals(golden, feedWholeStream(carrierSink))
|
||||
assertArrayEquals(golden, feedWholeStream(carrierSink, accessUnits))
|
||||
}
|
||||
|
||||
/** A DTS-HD stream must select the DTS packer and come out matching FFmpeg's bytes. */
|
||||
@Test
|
||||
fun theDtsHdCarrierOutputMatchesTheGoldenStream() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
assertArrayEquals(dtsHdGolden, feedWholeStream(carrierSink, dtsHdAccessUnits))
|
||||
}
|
||||
|
||||
/** Back pressure must not cost DTS-HD units either; bursts and units are one-to-one there. */
|
||||
@Test
|
||||
fun aRefusedDtsHdBurstLosesNoAccessUnits() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
carrier.refuseEveryNth = 3
|
||||
|
||||
val produced = feedWholeStream(carrierSink, dtsHdAccessUnits)
|
||||
assertArrayEquals(dtsHdGolden, produced)
|
||||
assertTrue(carrier.refusals > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Burst timestamps come from the carrier cadence, not the closing access unit. A DTS-HD burst is
|
||||
* 10666.67us of carrier — not a whole number — so the cadence must be derived from cumulative
|
||||
* carrier frames; per-burst rounding would drift ~62ms over a movie.
|
||||
*/
|
||||
@Test
|
||||
fun dtsHdBurstTimestampsFollowTheExactCarrierCadence() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
feedWholeStream(carrierSink, dtsHdAccessUnits)
|
||||
|
||||
val framesPerBurst = 32768L / IecCarrier.BYTES_PER_FRAME
|
||||
carrier.bufferTimesUs.forEachIndexed { index, timeUs ->
|
||||
assertEquals(
|
||||
"burst $index timestamp",
|
||||
index * framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE,
|
||||
timeUs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A refused burst must be re-offered before any further input is taken. */
|
||||
@@ -380,8 +521,23 @@ class TrueHdCarrierSinkTest {
|
||||
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.format.sampleRate)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.format.channelCount)
|
||||
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
|
||||
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
|
||||
}
|
||||
|
||||
/** DTS-HD rides the same PCM-shaped carrier tuple TrueHD does. */
|
||||
@Test
|
||||
fun theDtsHdCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
val configured = checkNotNull(carrier.configuredConfig)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
|
||||
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
|
||||
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -399,18 +555,20 @@ class TrueHdCarrierSinkTest {
|
||||
assertEquals(null, carrier.configuredConfig)
|
||||
}
|
||||
|
||||
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
|
||||
val buffer = ByteBuffer.wrap(accessUnits)
|
||||
private fun feedWholeStream(carrierSink: IecCarrierSink, units: ByteArray): ByteArray {
|
||||
val buffer = ByteBuffer.wrap(units)
|
||||
var guard = 0
|
||||
while (buffer.hasRemaining() && guard++ < 10_000) {
|
||||
// media3 retries handleBuffer until it returns true, even once the buffer is fully consumed;
|
||||
// a refusal of the stream's final burst leaves it pending with nothing remaining to read.
|
||||
while ((buffer.hasRemaining() || carrierSink.hasPendingData()) && guard++ < 10_000) {
|
||||
carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
}
|
||||
val carrier = carrierSinkDelegate(carrierSink)
|
||||
return carrier.written.toByteArray()
|
||||
}
|
||||
|
||||
private fun carrierSinkDelegate(sink: TrueHdCarrierSink): FakeSink {
|
||||
val field = TrueHdCarrierSink::class.java.getDeclaredField("carrierSink")
|
||||
private fun carrierSinkDelegate(sink: IecCarrierSink): FakeSink {
|
||||
val field = IecCarrierSink::class.java.getDeclaredField("carrierSink")
|
||||
field.isAccessible = true
|
||||
return field.get(sink) as FakeSink
|
||||
}
|
||||
@@ -430,6 +588,7 @@ class TrueHdCarrierSinkTest {
|
||||
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
|
||||
private class FakeSink : AudioSink {
|
||||
val written = ByteArrayOutputStream()
|
||||
val bufferTimesUs = mutableListOf<Long>()
|
||||
var configuredConfig: AudioSink.AudioSinkConfig? = null
|
||||
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
var lastVolume: Float = -1f
|
||||
@@ -454,6 +613,7 @@ class TrueHdCarrierSinkTest {
|
||||
val copy = ByteArray(buffer.remaining())
|
||||
buffer.duplicate().get(copy)
|
||||
written.write(copy)
|
||||
bufferTimesUs.add(presentationTimeUs)
|
||||
buffer.position(buffer.limit())
|
||||
return true
|
||||
}
|
||||
@@ -27,8 +27,11 @@ import org.robolectric.annotation.Config
|
||||
class MatroskaLateTracksTest {
|
||||
|
||||
private class CapturedTrack(val type: Int) : TrackOutput {
|
||||
val timesUs = mutableListOf<Long>()
|
||||
var format: Format? = null
|
||||
var sampleCount = 0
|
||||
|
||||
val sampleCount: Int
|
||||
get() = timesUs.size
|
||||
|
||||
override fun format(format: Format) {
|
||||
this.format = format
|
||||
@@ -41,16 +44,19 @@ class MatroskaLateTracksTest {
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
sampleCount++
|
||||
timesUs.add(timeUs)
|
||||
}
|
||||
}
|
||||
|
||||
private class CapturingExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, CapturedTrack>()
|
||||
val seekMaps = mutableListOf<SeekMap>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
|
||||
override fun endTracks() = Unit
|
||||
override fun seekMap(seekMap: SeekMap) = Unit
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
seekMaps.add(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
@@ -69,22 +75,15 @@ class MatroskaLateTracksTest {
|
||||
"fixture matroska_tracks_at_end.mkv missing from test resources"
|
||||
}.use { it.readBytes() }
|
||||
|
||||
private fun extractFixture(): CapturingExtractorOutput {
|
||||
val data = fixtureData()
|
||||
val assHandler = AssHandler()
|
||||
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
|
||||
val output = CapturingExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val reader = ByteArrayDataReader(data)
|
||||
var input: ExtractorInput = DefaultExtractorInput(reader, 0, data.size.toLong())
|
||||
private fun readToEnd(extractor: Extractor, reader: ByteArrayDataReader, length: Long) {
|
||||
var input: ExtractorInput = DefaultExtractorInput(reader, reader.position, length)
|
||||
val seekPosition = PositionHolder()
|
||||
repeat(100_000) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return output
|
||||
Extractor.RESULT_END_OF_INPUT -> return
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +92,13 @@ class MatroskaLateTracksTest {
|
||||
|
||||
@Test
|
||||
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
|
||||
val output = extractFixture()
|
||||
val assHandler = AssHandler()
|
||||
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
|
||||
val output = CapturingExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val data = fixtureData()
|
||||
readToEnd(extractor, ByteArrayDataReader(data), data.size.toLong())
|
||||
|
||||
assertEquals(2, output.tracks.size)
|
||||
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
|
||||
@@ -102,4 +107,48 @@ class MatroskaLateTracksTest {
|
||||
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for
|
||||
* tracks-after-clusters files is before the Tracks element parsed — the map then permanently
|
||||
* reports unseekable and resolves every seek to byte 0, snapping playback to the start
|
||||
* (#1969; upstream androidx/media #3377).
|
||||
* The production stack must repair it through the per-track cue lookups.
|
||||
*/
|
||||
@Test
|
||||
fun seeksViaCuesWhenSeekMapIsBuiltBeforeTracks() {
|
||||
val assHandler = AssHandler()
|
||||
val extractor = CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
|
||||
val output = CapturingExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val data = fixtureData()
|
||||
val reader = ByteArrayDataReader(data)
|
||||
readToEnd(extractor, reader, data.size.toLong())
|
||||
|
||||
assertEquals(1, output.seekMaps.size)
|
||||
val seekMap = output.seekMaps.single()
|
||||
assertTrue("tracks-after-clusters file with Cues must be seekable", seekMap.isSeekable)
|
||||
|
||||
val points = seekMap.getSeekPoints(500_000L)
|
||||
// Cue-based resolution snaps to the fixture's only cue point (t=0); a byte-proportional
|
||||
// estimate would return the requested time instead.
|
||||
assertEquals(0L, points.first.timeUs)
|
||||
val position = points.first.position
|
||||
val clusterId = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
|
||||
assertTrue(
|
||||
"seek position $position must point at a Cluster element",
|
||||
data.copyOfRange(position.toInt(), position.toInt() + 4).contentEquals(clusterId)
|
||||
)
|
||||
|
||||
output.tracks.values.forEach { it.timesUs.clear() }
|
||||
extractor.seek(position, points.first.timeUs)
|
||||
reader.position = position
|
||||
readToEnd(extractor, reader, data.size.toLong())
|
||||
|
||||
output.tracks.values.forEach { track ->
|
||||
assertTrue("expected extracted samples after seek for track type ${track.type}", track.sampleCount > 0)
|
||||
assertEquals(points.first.timeUs, track.timesUs.first())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ class TrueHdMatPackerTest {
|
||||
/** A burst is exactly 20ms of carrier, which is what makes the PCM-domain accounting downstream correct. */
|
||||
@Test
|
||||
fun aBurstIsTwentyMillisecondsOfCarrier() {
|
||||
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME
|
||||
val durationUs = framesPerBurst * 1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
|
||||
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / IecCarrier.BYTES_PER_FRAME
|
||||
val durationUs = framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE
|
||||
assertEquals(20_000L, durationUs)
|
||||
}
|
||||
|
||||
@@ -166,11 +166,11 @@ class TrueHdMatPackerTest {
|
||||
|
||||
val length = TrueHdMatPacker.accessUnitLength(units, 0, units.size)
|
||||
packer.packAccessUnit(units, 0, length)
|
||||
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedRateFamily)
|
||||
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedStream)
|
||||
|
||||
packer.reset()
|
||||
|
||||
assertFalse("reset must clear the flag", packer.unsupportedRateFamily)
|
||||
assertFalse("reset must clear the flag", packer.unsupportedStream)
|
||||
assertArrayEquals(
|
||||
"a clean packer must reproduce the stream after a poisoned one",
|
||||
golden,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.DeflaterOutputStream
|
||||
import java.util.zip.Inflater
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint,
|
||||
* timecode, flags) must survive byte-identically, the payload must inflate, and
|
||||
* every block the rewriter cannot prove safe must pass through unchanged (null).
|
||||
*/
|
||||
class ZlibTextBlockTest {
|
||||
|
||||
private val inflater = Inflater()
|
||||
|
||||
private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload
|
||||
|
||||
private fun deflate(bytes: ByteArray): ByteArray {
|
||||
val target = ByteArrayOutputStream()
|
||||
DeflaterOutputStream(target, Deflater()).use { it.write(bytes) }
|
||||
return target.toByteArray()
|
||||
}
|
||||
|
||||
// Track 3 (varint 0x83), relative timecode 0x0102, no lacing.
|
||||
private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00)
|
||||
|
||||
@Test
|
||||
fun inflatesUnlacedBlockPayloadAndPreservesHeader() {
|
||||
val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray()
|
||||
val data = block(unlacedHeader, deflate(dialogue))
|
||||
|
||||
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
|
||||
|
||||
assertArrayEquals(unlacedHeader + dialogue, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoByteTrackNumberVarintIsPreserved() {
|
||||
val dialogue = "1,,Default,,0,0,0,,hello".toByteArray()
|
||||
val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298
|
||||
val data = block(header, deflate(dialogue))
|
||||
|
||||
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
|
||||
|
||||
assertArrayEquals(header + dialogue, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lacedBlockPassesThrough() {
|
||||
val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set
|
||||
val data = block(laced, deflate("payload".toByteArray()))
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptStreamPassesThrough() {
|
||||
val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun truncatedStreamPassesThrough() {
|
||||
val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray())
|
||||
val truncated = compressed.copyOf(compressed.size - 4)
|
||||
val data = block(unlacedHeader, truncated)
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerOnlyBlockPassesThrough() {
|
||||
assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater))
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.CueDecoder
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with
|
||||
* `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that
|
||||
* broke #2023) and an identical uncompressed mux — and verifies the zlib block
|
||||
* payloads are inflated *before* MatroskaExtractor's subtitle sample assembly.
|
||||
*
|
||||
* Regenerate with:
|
||||
* mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass
|
||||
* mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass
|
||||
* where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events.
|
||||
*
|
||||
* Robolectric provides real android.util/android.os implementations —
|
||||
* MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which
|
||||
* are no-op stubs on plain JVM.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ZlibTextSubtitleExtractionTest {
|
||||
|
||||
private class CapturedSample(val timeUs: Long, val data: ByteArray)
|
||||
|
||||
private class FakeTrackOutput : TrackOutput {
|
||||
val formats = mutableListOf<androidx.media3.common.Format>()
|
||||
val samples = mutableListOf<CapturedSample>()
|
||||
private var buf = ByteArray(64 * 1024)
|
||||
private var bufLen = 0
|
||||
|
||||
override fun format(format: androidx.media3.common.Format) {
|
||||
formats.add(format)
|
||||
}
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
|
||||
ensureCapacity(bufLen + length)
|
||||
val read = input.read(buf, bufLen, length)
|
||||
if (read > 0) bufLen += read
|
||||
return read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
ensureCapacity(bufLen + length)
|
||||
data.readBytes(buf, bufLen, length)
|
||||
bufLen += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
val start = bufLen - offset - size
|
||||
samples.add(CapturedSample(timeUs, buf.copyOfRange(start, start + size)))
|
||||
if (offset == 0) bufLen = 0
|
||||
}
|
||||
|
||||
private fun ensureCapacity(needed: Int) {
|
||||
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, FakeTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
|
||||
override fun endTracks() {}
|
||||
override fun seekMap(seekMap: SeekMap) {}
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
var position = 0L
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= data.size) return C.RESULT_END_OF_INPUT
|
||||
val toRead = minOf(length, data.size - position.toInt())
|
||||
System.arraycopy(data, position.toInt(), buffer, offset, toRead)
|
||||
position += toRead
|
||||
return toRead
|
||||
}
|
||||
}
|
||||
|
||||
private fun extract(resource: String): FakeExtractorOutput {
|
||||
val data = checkNotNull(javaClass.getResourceAsStream(resource)) {
|
||||
"fixture $resource missing from test resources"
|
||||
}.use { it.readBytes() }
|
||||
|
||||
// DefaultSubtitleParserFactory (not AssSubtitleParserFactory) so the SSA track
|
||||
// transcodes to decodable media3 cues instead of loading native libass.
|
||||
val extractor = ZlibMatroskaExtractor(DefaultSubtitleParserFactory(), AssHandler())
|
||||
val output = FakeExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val reader = ByteArrayDataReader(data)
|
||||
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
|
||||
val seekPosition = PositionHolder()
|
||||
while (true) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return output
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DecodedCue(val startTimeUs: Long, val durationUs: Long, val text: String)
|
||||
|
||||
private fun decodeCues(output: FakeExtractorOutput): List<DecodedCue> {
|
||||
val track = output.tracks.values.single()
|
||||
val format = track.formats.last()
|
||||
assertEquals(MimeTypes.APPLICATION_MEDIA3_CUES, format.sampleMimeType)
|
||||
assertEquals(MimeTypes.TEXT_SSA, format.codecs)
|
||||
val decoder = CueDecoder()
|
||||
return track.samples.map { sample ->
|
||||
val cues = decoder.decode(sample.timeUs, sample.data, 0, sample.data.size)
|
||||
DecodedCue(
|
||||
cues.startTimeUs,
|
||||
cues.durationUs,
|
||||
cues.cues.joinToString("\n") { it.text.toString() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibCompressedAssDialogueIsInflatedToParseableCues() {
|
||||
val cues = decodeCues(extract("/zlib_ssa.mkv"))
|
||||
|
||||
assertEquals(45, cues.size)
|
||||
assertEquals("SUBTITLE LINE 1 (0:00:00.00)", cues.first().text)
|
||||
assertEquals(0L, cues.first().startTimeUs)
|
||||
assertEquals(2_000_000L, cues.first().durationUs)
|
||||
assertEquals("SUBTITLE LINE 45 (0:01:28.00)", cues.last().text)
|
||||
assertEquals(88_000_000L, cues.last().startTimeUs)
|
||||
cues.forEachIndexed { index, cue ->
|
||||
assertEquals(index * 2_000_000L, cue.startTimeUs)
|
||||
assertTrue("cue $index text: ${cue.text}", cue.text.startsWith("SUBTITLE LINE ${index + 1} "))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibAndPlainMuxesProduceIdenticalCues() {
|
||||
val zlib = decodeCues(extract("/zlib_ssa.mkv"))
|
||||
val plain = decodeCues(extract("/plain_ssa.mkv"))
|
||||
|
||||
assertEquals(plain.size, zlib.size)
|
||||
plain.zip(zlib).forEachIndexed { index, (expected, actual) ->
|
||||
assertEquals("startTimeUs of cue $index", expected.startTimeUs, actual.startTimeUs)
|
||||
assertEquals("durationUs of cue $index", expected.durationUs, actual.durationUs)
|
||||
assertEquals("text of cue $index", expected.text, actual.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Boundaries are the contract; rationale on [DemuxerBudget]. */
|
||||
class DemuxerBudgetTest {
|
||||
private val mib = 1024L * 1024L
|
||||
|
||||
@Test
|
||||
fun `unknown heap class keeps mpv defaults`() {
|
||||
assertNull(DemuxerBudget.forHeapClassMB(0))
|
||||
assertNull(DemuxerBudget.forHeapClassMB(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small heap class gets the tight tier`() {
|
||||
val budget = DemuxerBudget.forHeapClassMB(256)!!
|
||||
assertEquals(32 * mib, budget.aheadBytes)
|
||||
assertEquals(16 * mib, budget.backBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid heap class gets the middle tier`() {
|
||||
assertEquals(64 * mib, DemuxerBudget.forHeapClassMB(257)!!.aheadBytes)
|
||||
val budget = DemuxerBudget.forHeapClassMB(512)!!
|
||||
assertEquals(64 * mib, budget.aheadBytes)
|
||||
assertEquals(32 * mib, budget.backBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large heap class gets the full tier`() {
|
||||
val budget = DemuxerBudget.forHeapClassMB(513)!!
|
||||
assertEquals(100 * mib, budget.aheadBytes)
|
||||
assertEquals(48 * mib, budget.backBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* P5 has no compatible base layer: on a device without native DV it must
|
||||
* leave the video plane for gpu-next software reshaping, and nothing else
|
||||
* may be routed (wrong-colors class verified on a Pixel 7). The vo target
|
||||
* matrix keeps gpu-next away from hardware sessions (#2010) while giving
|
||||
* the reshaping path the only vo that composites RPU metadata (#1902).
|
||||
*/
|
||||
class GpuVoPolicyTest {
|
||||
@Test
|
||||
fun `P5 without native support is routed`() {
|
||||
assertTrue(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `P5 with native support stays on the plane`() {
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base-layer-compatible profiles are never routed`() {
|
||||
// P7/P8 strip to an HDR10/HLG base layer; non-DV content has no profile.
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(7L, "auto", canPlayP5Natively = false))
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(8L, "auto", canPlayP5Natively = false))
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(null, "auto", canPlayP5Natively = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit conversion modes are user overrides and stay native`() {
|
||||
for (mode in listOf("disabled", "native", "dv81", "hevc", "hevc_strip")) {
|
||||
assertFalse(mode, GpuVoPolicy.needsDvReshaping(5L, mode, canPlayP5Natively = false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hdr tone-mapping is needed only for a PQ or HLG signal on a non-HDR display`() {
|
||||
assertTrue(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = false))
|
||||
assertTrue(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = false))
|
||||
// An HDR display scans the signal out itself.
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = true))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = true))
|
||||
// SDR transfers need no mapping, and mpv reports none before the first
|
||||
// frame of a file.
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("bt.1886", displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("srgb", displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping(null, displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("", displaySupportsHdr = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only direct mediacodec output can stay on the plane`() {
|
||||
// -copy also reads frames back into system memory, so it leaves too.
|
||||
assertTrue(GpuVoPolicy.needsSoftwareRender("no"))
|
||||
assertTrue(GpuVoPolicy.needsSoftwareRender("mediacodec-copy"))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender("mediacodec"))
|
||||
// Unreported until the decoder initializes: stay on the plane.
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender(null))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a software-decoding session targets gpu, not gpu-next`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SW_DECODE)))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_SW_DECODE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no reasons keeps the video plane`() {
|
||||
assertNull(GpuVoPolicy.targetFor(emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dv reshaping targets gpu-next even alongside other reasons`() {
|
||||
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
|
||||
assertEquals(
|
||||
"gpu-next",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_DV_RESHAPE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shaders and chain failure target the hardware-safe gpu vo`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CHAIN_FAILURE)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_CHAIN_FAILURE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hdr tone-mapping targets gpu but yields to dv reshaping`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_SHADERS))
|
||||
)
|
||||
assertEquals(
|
||||
"gpu-next",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_DV_RESHAPE))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -48,6 +49,40 @@ class MpvPlayerPluginTest {
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
|
||||
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
|
||||
// with no context to inspect the audio route the only safe answer is "" (#1703, #1991).
|
||||
val result = RecordingResult()
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(MethodCall("getAudioSpdifCodecs", null), result)
|
||||
|
||||
assertEquals("", result.successValue)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardwareDecodeSessionsUseTheMediaCodecVoWithGpuFallback() {
|
||||
// Rationale on MpvPlayerCore.initialVideoOutput.
|
||||
assertEquals("mediacodec,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
|
||||
assertEquals("gpu,gpu-next", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrSurfaceIsWantedOnlyForPqAndHlgTransfers() {
|
||||
// Rationale on MpvPlayerCore.wantsHdrSurface: both render into a PQ
|
||||
// target; anything else stays on the sRGB surface, which renders every
|
||||
// content correctly.
|
||||
assertTrue(MpvPlayerCore.wantsHdrSurface("smpte2084"))
|
||||
assertTrue(MpvPlayerCore.wantsHdrSurface("arib-std-b67"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("bt709"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("bt1886"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("unknown"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface(""))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
@@ -598,6 +633,53 @@ class MpvPlayerPluginTest {
|
||||
assertEquals(0, pending.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleDisposeIsAcknowledgedWithoutTearingDownTheCore() {
|
||||
// A dispose whose instanceId is not the core creator's lost the ownership
|
||||
// race to a successor; tearing the core down anyway would kill that
|
||||
// successor's session. It must be acknowledged as a no-op instead.
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 2L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 1)), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNotNull(getPluginField(plugin, "playerCore"))
|
||||
assertEquals(2L, getPluginField(plugin, "coreInstanceId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchingDisposeTearsDownTheCoreAndClearsTheToken() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 7L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 7)), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNull(getPluginField(plugin, "playerCore"))
|
||||
assertNull(getPluginField(plugin, "coreInstanceId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenlessDisposeKeepsLegacySemanticsAndTearsDownTheCore() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 7L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", null), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNull(getPluginField(plugin, "playerCore"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
@@ -867,6 +949,97 @@ class MpvPlayerPluginTest {
|
||||
getBoolean(core)
|
||||
}
|
||||
|
||||
private fun invokeSetGpuVoRequirement(core: MpvPlayerCore, reason: String, active: Boolean) {
|
||||
MpvPlayerCore::class.java
|
||||
.getDeclaredMethod("setGpuVoRequirement", String::class.java, Boolean::class.javaPrimitiveType)
|
||||
.apply {
|
||||
isAccessible = true
|
||||
invoke(core, reason, active)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voTargetFollowsTheActiveReasonSet() {
|
||||
// Reason precedence through the real property-write path: DV reshaping
|
||||
// outranks HDR-on-SDR, dropping the winner falls back to the reason still
|
||||
// active, and dropping the last one returns the session to the plane.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
setBoolean(core, "isInitialized", true)
|
||||
|
||||
fun lastVo(): String? = writes.toList().lastOrNull { it.first == "vo" }?.second
|
||||
|
||||
// HDR-on-SDR raises first, then the DV router wins the arbitration.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, true)
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, true)
|
||||
awaitCondition { lastVo() == "gpu-next" }
|
||||
assertEquals("gpu-next", lastVo())
|
||||
|
||||
// Dropping the winning reason must fall back to the one still active,
|
||||
// not to the plane.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, false)
|
||||
awaitCondition { lastVo() == "gpu" }
|
||||
assertEquals("gpu", lastVo())
|
||||
|
||||
// Last reason dropping returns the session to the video plane.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, false)
|
||||
awaitCondition { lastVo() == "mediacodec" }
|
||||
assertEquals("mediacodec", lastVo())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dvConversionModeMapsOntoForkDecoderOptions() {
|
||||
// The app-level `dv-conversion-mode` property must translate to the fork
|
||||
// FFmpeg hevc_mediacodec options, mirroring the ExoPlayer DoviBridge
|
||||
// modes. Robolectric reports no Dolby Vision display, so `auto` takes the
|
||||
// no-DV branch deterministically.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
|
||||
fun apply(mode: String): Result<Unit> {
|
||||
writes.clear()
|
||||
var outcome: Result<Unit>? = null
|
||||
core.setProperty("dv-conversion-mode", mode) { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
return outcome!!
|
||||
}
|
||||
|
||||
assertTrue(apply("auto").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=0,dv_p7_mode=strip"), writes.toList())
|
||||
|
||||
assertTrue(apply("disabled").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=native"), writes.toList())
|
||||
|
||||
assertTrue(apply("dv81").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=convert"), writes.toList())
|
||||
|
||||
assertTrue(apply("hevc_strip").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=strip"), writes.toList())
|
||||
|
||||
val invalid = apply("bogus")
|
||||
assertTrue(invalid.isFailure)
|
||||
assertTrue(writes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderOptionsMergeKeepsUserEntriesFirst() {
|
||||
// The property interface cannot append to a list option, so the write
|
||||
// replaces it wholesale: a user's own mpv.conf `vd-lavc-o` entries must
|
||||
// survive, with the app's keys last (FFmpeg keeps the last duplicate).
|
||||
assertEquals(
|
||||
"threads=4,dolby_vision=1",
|
||||
MpvPlayerCore.mergeDecoderOptions("threads=4", "dolby_vision=1")
|
||||
)
|
||||
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(null, "dolby_vision=1"))
|
||||
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(" ", "dolby_vision=1"))
|
||||
}
|
||||
|
||||
private fun awaitQueueEntry(
|
||||
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||
expected: Pair<String, String>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VideoRectPolicyTest {
|
||||
@Test
|
||||
fun `contain letterboxes wide content inside the container`() {
|
||||
// 2.39:1 content on a 16:9 container: full width, bars top and bottom.
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
|
||||
assertEquals(1920, size.width)
|
||||
assertEquals(803, size.height)
|
||||
assertTrue(size.height < 1080)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contain pillarboxes tall content inside the container`() {
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 1000, 2390)!!
|
||||
assertEquals(1080, size.height)
|
||||
assertTrue(size.width < 1920)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panscan 1 fills the container so the image is cropped, not letterboxed`() {
|
||||
// The regression this exists for: cover used to be a no-op on the plane.
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue("cover must reach the container height", size.height >= 1080)
|
||||
assertTrue("width must overflow and be clipped", size.width > 1920)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panscan interpolates between contain and cover`() {
|
||||
val contain = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
|
||||
val half = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 0.5f)!!
|
||||
val cover = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue(half.height > contain.height)
|
||||
assertTrue(half.height < cover.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `video-zoom is a log2 factor on top of the fit`() {
|
||||
val base = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
|
||||
val doubled = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 1f)!!
|
||||
val halved = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = -1f)!!
|
||||
assertEquals(base.width * 2, doubled.width)
|
||||
assertEquals(base.width / 2, halved.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cover fills a container whose aspect is wildly mismatched`() {
|
||||
// Scope content in a portrait container: cover/fit exceeds 4, which a
|
||||
// fit-relative clamp would truncate back into letterboxing.
|
||||
val size = VideoRectPolicy.sizeFor(1080, 2400, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue("height must reach the container", size.height >= 2400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absurd zoom is bounded rather than allocating an unbounded surface`() {
|
||||
val fit = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
|
||||
val absurd = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 12f)!!
|
||||
assertEquals(fit.width * 4, absurd.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown dimensions yield no size instead of a degenerate one`() {
|
||||
assertNull(VideoRectPolicy.sizeFor(0, 1080, 1920, 1080))
|
||||
assertNull(VideoRectPolicy.sizeFor(1920, 1080, 0, 0))
|
||||
assertNull(VideoRectPolicy.sizeFor(1920, 0, 1920, 1080))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import com.edde746.plezy.shared.DisplayModeSelector.ModeInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DisplayModeSelectorTest {
|
||||
|
||||
// A typical 4K TV: panel-native modes plus lower-resolution HDMI modes.
|
||||
private val uhd60 = ModeInfo(1, 3840, 2160, 60f)
|
||||
private val uhd50 = ModeInfo(2, 3840, 2160, 50f)
|
||||
private val uhd24 = ModeInfo(3, 3840, 2160, 23.976f)
|
||||
private val fhd60 = ModeInfo(4, 1920, 1080, 60f)
|
||||
private val fhd50 = ModeInfo(5, 1920, 1080, 50f)
|
||||
private val fhd24 = ModeInfo(6, 1920, 1080, 23.976f)
|
||||
private val hd60 = ModeInfo(7, 1280, 720, 60f)
|
||||
private val sd60 = ModeInfo(8, 720, 480, 60f)
|
||||
private val allModes = listOf(uhd60, uhd50, uhd24, fhd60, fhd50, fhd24, hd60, sd60)
|
||||
|
||||
private fun select(
|
||||
fps: Float,
|
||||
current: ModeInfo = uhd60,
|
||||
modes: List<ModeInfo> = allModes,
|
||||
videoWidth: Int = 0,
|
||||
videoHeight: Int = 0,
|
||||
matchResolution: Boolean = false
|
||||
) = DisplayModeSelector.findBestMode(fps, current, modes, videoWidth, videoHeight, matchResolution)
|
||||
|
||||
// --- Resolution matching ---
|
||||
|
||||
@Test
|
||||
fun resolutionMatchingPicksNativeResolutionAndRate() {
|
||||
val selection = select(23.976f, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
|
||||
assertEquals(fhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionOnlyRequestKeepsCurrentRefreshRate() {
|
||||
val selection = select(0f, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
|
||||
assertEquals(fhd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionMatchingNeverDownscalesTheVideo() {
|
||||
// 1080p-class anamorphic content is wider than 720p even though shorter:
|
||||
// the smallest containing mode is 1080p, not 720p.
|
||||
val selection = select(0f, videoWidth = 1920, videoHeight = 800, matchResolution = true)
|
||||
assertEquals(fhd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionWinsOverCadenceWhenNativeResolutionHasNoMatchingRate() {
|
||||
// No 720p24 mode exists; the 720p video still lands on 720p (TV upscales)
|
||||
// instead of widening back out to a 24 Hz mode at another resolution.
|
||||
val selection = select(23.976f, videoWidth = 1280, videoHeight = 720, matchResolution = true)
|
||||
assertEquals(hd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelNativeContentStaysAtPanelResolution() {
|
||||
val selection = select(23.976f, videoWidth = 3840, videoHeight = 2160, matchResolution = true)
|
||||
assertEquals(uhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelNativeResolutionOnlyRequestNeedsNoSwitch() {
|
||||
val selection = select(0f, videoWidth = 3840, videoHeight = 2160, matchResolution = true)
|
||||
assertEquals(uhd60, selection?.mode) // caller sees modeId == current and skips
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceLargerThanPanelFallsBackToCadencePolicy() {
|
||||
val selection = select(23.976f, videoWidth = 7680, videoHeight = 4320, matchResolution = true)
|
||||
assertEquals(uhd24, selection?.mode) // Tier-1 refresh-only switch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceLargerThanPanelWithoutFpsHasNoTarget() {
|
||||
assertNull(select(0f, videoWidth = 7680, videoHeight = 4320, matchResolution = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionMatchingWithoutDimensionsBehavesLikeCadenceOnly() {
|
||||
val selection = select(23.976f, matchResolution = true)
|
||||
assertEquals(uhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionOnlyPrefersRateClosestToCurrent() {
|
||||
// From a 50 Hz current mode, a resolution-only switch keeps 50 Hz.
|
||||
val selection = select(0f, current = uhd50, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
|
||||
assertEquals(fhd50, selection?.mode)
|
||||
}
|
||||
|
||||
// --- Cadence-only policy (matchResolution off): pre-existing behaviour ---
|
||||
|
||||
@Test
|
||||
fun cadenceMatchingStaysAtCurrentResolution() {
|
||||
val selection = select(23.976f, videoWidth = 1920, videoHeight = 1080)
|
||||
assertEquals(uhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cadenceTierTwoAllowsResolutionChangeButNeverBelowVideo() {
|
||||
// Panel has no 4K@24; only 1080p@24 remains for 1080p content.
|
||||
val modes = listOf(uhd60, uhd50, fhd60, fhd24, hd60)
|
||||
val selection = select(23.976f, modes = modes, videoWidth = 1920, videoHeight = 1080)
|
||||
assertEquals(fhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cadenceTierTwoRequiresKnownDimensions() {
|
||||
val modes = listOf(uhd60, uhd50, fhd60, fhd24, hd60)
|
||||
assertNull(select(23.976f, modes = modes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidFpsWithoutResolutionRequestHasNoTarget() {
|
||||
assertNull(select(0f, videoWidth = 1920, videoHeight = 1080))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleRateCountsAsCadenceMatch() {
|
||||
// 30 fps on a 60 Hz mode is a clean 2x pulldown; current 60 Hz mode wins.
|
||||
val selection = select(29.97f, current = uhd60, modes = listOf(uhd60, uhd50))
|
||||
assertNotNull(selection)
|
||||
assertEquals(uhd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactRateBeatsMultipleRate() {
|
||||
val uhd30 = ModeInfo(9, 3840, 2160, 29.97f)
|
||||
val selection = select(29.97f, modes = allModes + uhd30)
|
||||
assertEquals(uhd30, selection?.mode)
|
||||
}
|
||||
|
||||
// --- matchRefreshRate ---
|
||||
|
||||
@Test
|
||||
fun refreshRateMatchClassifiesExactMultipleAndMiss() {
|
||||
assertEquals(0, DisplayModeSelector.matchRefreshRate(23.976f, 23.976f)?.priority)
|
||||
assertEquals(1, DisplayModeSelector.matchRefreshRate(59.94f, 29.97f)?.priority)
|
||||
assertNull(DisplayModeSelector.matchRefreshRate(60f, 23.976f))
|
||||
assertNull(DisplayModeSelector.matchRefreshRate(60f, 0f))
|
||||
assertNull(DisplayModeSelector.matchRefreshRate(0f, 24f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Teardown ordering for frame-rate matching (#2172): restoring the display
|
||||
* mode while the display is still signaling HDR folds the HDR exit and the
|
||||
* mode change into one slow HDMI renegotiation. An HDR session therefore
|
||||
* defers the restore; an SDR session restores immediately.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FrameRateManagerRestoreTest {
|
||||
|
||||
private fun buildManager(activity: Activity): FrameRateManager =
|
||||
FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
|
||||
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
|
||||
|
||||
private fun applyPreferredMode(activity: Activity, modeId: Int) {
|
||||
val attrs = activity.window.attributes
|
||||
attrs.preferredDisplayModeId = modeId
|
||||
activity.window.attributes = attrs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sdrClearRestoresTheDefaultModeImmediately() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = false)
|
||||
|
||||
assertEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrClearDefersTheRestoreUntilTheHdrExitSettles() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
|
||||
// Still at the content mode: the surface teardown must commit the HDR
|
||||
// exit before the mode change renegotiates the link.
|
||||
assertEquals(4, preferredModeId(activity))
|
||||
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
assertEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newSwitchRequestCancelsAPendingDeferredRestore() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
// A new session's request arrives before the deferred restore fires.
|
||||
manager.setVideoFrameRate(fps = 23.976f, videoDurationMs = 0L, extraDelayMs = 0L) { }
|
||||
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
// The stale restore must not clobber whatever the new request applied.
|
||||
// (Robolectric's display has no matching mode, so the request itself
|
||||
// leaves the window untouched; only the cancelled restore could zero it.)
|
||||
assertNotEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrClearWithNoAppliedModePostsNoRestore() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 7)
|
||||
|
||||
// Simulate "nothing applied by us": manager sees modeId 0.
|
||||
applyPreferredMode(activity, 0)
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
|
||||
// A later, externally applied mode must not be clobbered by a stale
|
||||
// deferred restore from a session that never switched.
|
||||
applyPreferredMode(activity, 7)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
assertEquals(7, preferredModeId(activity))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -60,4 +61,18 @@ class MediaCodecQueryTest {
|
||||
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
|
||||
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsGatedCodecsToTheMimeTypesDecodersAdvertise() {
|
||||
assertEquals(
|
||||
mapOf("hevc" to true, "av1" to true),
|
||||
MediaCodecQuery.hardwareVideoDecodeSupport(
|
||||
setOf("video/avc", "video/hevc", "video/av01", "audio/mp4a-latm")
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
mapOf("hevc" to true, "av1" to false),
|
||||
MediaCodecQuery.hardwareVideoDecodeSupport(setOf("video/avc", "video/hevc"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -41,4 +42,27 @@ class PlayerSurfaceHostTest {
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun letterboxAreaIsCoveredByABufferlessPunchSurfaceBeneathTheVideo() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
activity.setContentView(FrameLayout(activity))
|
||||
val callback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = Unit
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
|
||||
}
|
||||
|
||||
val container = PlayerSurfaceHost.createContainer(activity)
|
||||
val punch = container.getChildAt(0)
|
||||
assertTrue(punch is SurfaceView)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.width)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.height)
|
||||
|
||||
// Cores append their surfaces after createContainer, so video (and the mpv
|
||||
// OSD plane) always land above the punch layer in drawing order.
|
||||
val video = PlayerSurfaceHost.createVideoSurface(activity, callback)
|
||||
container.addView(video)
|
||||
assertTrue(container.indexOfChild(video) > container.indexOfChild(punch))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerates the DTS-HD MA packer fixtures (#1988).
|
||||
|
||||
FFmpeg has no DTS-HD MA encoder, so the access-unit fixture is synthesized: real
|
||||
DTS core frames from FFmpeg's `dca` encoder, each followed by a hand-built
|
||||
extension substream (EXSS) whose header fields are coherent enough for FFmpeg's
|
||||
DCA parser to glue core+EXSS into one access unit. The IEC 61937 wrapping never
|
||||
inspects EXSS payload bytes, only the sync word and size fields, so this pins
|
||||
the same wire format genuine Master Audio does.
|
||||
|
||||
One mid-stream access unit is oversized so `ffmpeg -f spdif` overflows the
|
||||
burst and strips to core-only for `dtshd_fallback_time` (60s, i.e. the rest of
|
||||
the fixture) — the golden therefore also pins the strip-and-hold behavior.
|
||||
|
||||
Usage (from this directory):
|
||||
ffmpeg -y -f lavfi -i "aevalsrc=0.3*sin(2*PI*440*t)|0.3*sin(2*PI*554*t)|0.3*sin(2*PI*659*t)|0.2*sin(2*PI*220*t)|0.25*sin(2*PI*330*t)|0.25*sin(2*PI*392*t):s=48000:d=0.15" \
|
||||
-c:a dca -strict experimental -f dts /tmp/dts_core.dts
|
||||
python3 dtshd_fixture_generator.py /tmp/dts_core.dts
|
||||
ffmpeg -y -f dts -i dtshd_ma_access_units.bin -c:a copy \
|
||||
-f spdif -dtshd_rate 768000 dtshd_ma_iec61937_golden.bin
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CORE_SYNC = 0x7FFE8001
|
||||
EXSS_SYNC = 0x64582025
|
||||
|
||||
# Payload bytes past which a 32768-byte burst (512 samples at 48kHz on the
|
||||
# 768kHz HBR carrier) overflows: 32768 - 8 preamble - 12 start code/length.
|
||||
BURST_CAPACITY = 32768 - 8 - 12
|
||||
NORMAL_EXSS_SIZE = 2020
|
||||
# Large enough that core (1884) + EXSS exceeds the burst capacity.
|
||||
OVERSIZED_EXSS_SIZE = BURST_CAPACITY
|
||||
OVERSIZED_AU_INDEX = 4
|
||||
|
||||
|
||||
def build_exss(size: int) -> bytes:
|
||||
"""A minimal EXSS frame: sync, then the narrow (bHeaderSizeType=0) header.
|
||||
|
||||
Bit layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
|
||||
bHeaderSizeType(1), nuBits4Header(8) = header bytes - 1,
|
||||
nuBits4ExSSFsize(16) = frame bytes - 1. Everything else is padding the
|
||||
parser and packer never read.
|
||||
"""
|
||||
header_size = 16
|
||||
bits = 0
|
||||
bits = (bits << 8) | 0 # UserDefinedBits
|
||||
bits = (bits << 2) | 0 # nExtSSIndex
|
||||
bits = (bits << 1) | 0 # bHeaderSizeType
|
||||
bits = (bits << 8) | (header_size - 1)
|
||||
bits = (bits << 16) | (size - 1)
|
||||
packed = bits.to_bytes(5, "big") # 35 bits, left-aligned below
|
||||
body = bytearray(size)
|
||||
struct.pack_into(">I", body, 0, EXSS_SYNC)
|
||||
shifted = int.from_bytes(packed, "big") << (40 - 35)
|
||||
body[4:9] = shifted.to_bytes(5, "big")
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
core = open(sys.argv[1], "rb").read()
|
||||
out = bytearray()
|
||||
offset = 0
|
||||
index = 0
|
||||
while offset + 9 <= len(core):
|
||||
sync = struct.unpack_from(">I", core, offset)[0]
|
||||
assert sync == CORE_SYNC, hex(sync)
|
||||
b24 = (core[offset + 5] << 16) | (core[offset + 6] << 8) | core[offset + 7]
|
||||
fsize = ((b24 >> 4) & 0x3FFF) + 1
|
||||
out += core[offset : offset + fsize]
|
||||
exss_size = OVERSIZED_EXSS_SIZE if index == OVERSIZED_AU_INDEX else NORMAL_EXSS_SIZE
|
||||
out += build_exss(exss_size)
|
||||
offset += fsize
|
||||
index += 1
|
||||
open("dtshd_ma_access_units.bin", "wb").write(out)
|
||||
print(f"{index} access units, {len(out)} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/universal_gamepad/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
|
||||
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
|
||||
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
|
||||
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
|
||||
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -80,6 +81,7 @@
|
||||
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
|
||||
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
|
||||
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
|
||||
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecodeCapabilitiesPlugin.swift; sourceTree = "<group>"; };
|
||||
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -197,6 +199,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -430,6 +433,7 @@
|
||||
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
|
||||
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -797,8 +801,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/edde746/MPVKit";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 1.0.21;
|
||||
kind = revision;
|
||||
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"state" : {
|
||||
"revision" : "1b8e5b2ba83f0b62c0eab6e90f36fa3dc5b4038f",
|
||||
"version" : "1.0.21"
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"state" : {
|
||||
"revision" : "1b8e5b2ba83f0b62c0eab6e90f36fa3dc5b4038f",
|
||||
"version" : "1.0.21"
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,6 +38,10 @@ import MediaPlayer
|
||||
MpvAudioPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "VideoDecodeCapabilitiesPlugin") {
|
||||
VideoDecodeCapabilitiesPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeviceAdjustmentChannel") {
|
||||
registerDeviceAdjustmentChannel(messenger: registrar.messenger())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import CoreMedia
|
||||
import Flutter
|
||||
import VideoToolbox
|
||||
|
||||
/// Answers `com.plezy/device`'s `getVideoDecodeCapabilities` on iOS and tvOS.
|
||||
///
|
||||
/// Compiled into the iOS and tvOS Runners only — macOS keeps its own sources
|
||||
/// and is deliberately left unprobed, because desktop CPUs software-decode
|
||||
/// both codecs in real time and narrowing the profile there would force
|
||||
/// transcodes for nothing.
|
||||
class VideoDecodeCapabilitiesPlugin: NSObject, FlutterPlugin {
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.plezy/device",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
registrar.addMethodCallDelegate(VideoDecodeCapabilitiesPlugin(), channel: channel)
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "getVideoDecodeCapabilities":
|
||||
result([
|
||||
"hevc": VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC),
|
||||
"av1": VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1),
|
||||
])
|
||||
default:
|
||||
// The rest of `com.plezy/device` is Android-only.
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,50 +5,6 @@ import '../services/plex_auth_service.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import '../utils/url_utils.dart';
|
||||
|
||||
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
|
||||
/// [MediaBackend] for places that only care about persistence/auth shape
|
||||
/// (e.g. database column values).
|
||||
enum ConnectionKind {
|
||||
plex,
|
||||
jellyfin,
|
||||
emby;
|
||||
|
||||
String get id => switch (this) {
|
||||
ConnectionKind.plex => 'plex',
|
||||
ConnectionKind.jellyfin => 'jellyfin',
|
||||
ConnectionKind.emby => 'emby',
|
||||
};
|
||||
|
||||
static ConnectionKind fromId(String id) => switch (id) {
|
||||
'plex' => ConnectionKind.plex,
|
||||
'jellyfin' => ConnectionKind.jellyfin,
|
||||
'emby' => ConnectionKind.emby,
|
||||
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
ConnectionKind.plex => MediaBackend.plex,
|
||||
ConnectionKind.jellyfin => MediaBackend.jellyfin,
|
||||
ConnectionKind.emby => MediaBackend.emby,
|
||||
};
|
||||
|
||||
/// The MediaBrowser dialect this kind speaks, or `null` for Plex.
|
||||
MediaBrowserDialect? get dialect => switch (this) {
|
||||
ConnectionKind.plex => null,
|
||||
ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin,
|
||||
ConnectionKind.emby => MediaBrowserDialect.emby,
|
||||
};
|
||||
|
||||
static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) {
|
||||
MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin,
|
||||
MediaBrowserDialect.emby => ConnectionKind.emby,
|
||||
};
|
||||
}
|
||||
|
||||
/// Health snapshot for a connection. Updated by the orchestrator each time a
|
||||
/// session is established or refreshed.
|
||||
enum ConnectionStatus { unknown, online, offline, authError, disabled }
|
||||
|
||||
/// A media server connection — a unit of authentication the user added.
|
||||
///
|
||||
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
|
||||
@@ -56,15 +12,14 @@ enum ConnectionStatus { unknown, online, offline, authError, disabled }
|
||||
/// user. Most users only ever add one connection.
|
||||
sealed class Connection {
|
||||
String get id;
|
||||
ConnectionKind get kind;
|
||||
MediaBackend get kind;
|
||||
String get displayName;
|
||||
ConnectionStatus get status;
|
||||
DateTime get createdAt;
|
||||
DateTime? get lastAuthenticatedAt;
|
||||
|
||||
/// Backend kind as a [MediaBackend] — for UI that branches on backend
|
||||
/// (badges, etc.). Just a passthrough to [kind.backend].
|
||||
MediaBackend get backend => kind.backend;
|
||||
/// Alias for [kind] kept for UI call sites that read "backend"
|
||||
/// (badges, etc.).
|
||||
MediaBackend get backend => kind;
|
||||
|
||||
/// Primary label shown in connection-list UIs. Plex shows the active
|
||||
/// profile/account name; Jellyfin shows the server name.
|
||||
@@ -90,9 +45,6 @@ class PlexAccountConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@@ -123,13 +75,12 @@ class PlexAccountConnection extends Connection {
|
||||
required this.accountLabel,
|
||||
this.activeProfile,
|
||||
this.servers = const [],
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.plex;
|
||||
MediaBackend get kind => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
|
||||
@@ -150,7 +101,6 @@ class PlexAccountConnection extends Connection {
|
||||
PlexHomeUser? activeProfile,
|
||||
bool clearActiveProfile = false,
|
||||
List<PlexServer>? servers,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
@@ -161,7 +111,6 @@ class PlexAccountConnection extends Connection {
|
||||
accountLabel: accountLabel ?? this.accountLabel,
|
||||
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
|
||||
servers: servers ?? this.servers,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
@@ -181,7 +130,6 @@ class PlexAccountConnection extends Connection {
|
||||
factory PlexAccountConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
@@ -198,7 +146,6 @@ class PlexAccountConnection extends Connection {
|
||||
accountLabel: json['accountLabel'] as String? ?? 'Plex',
|
||||
activeProfile: activeProfile,
|
||||
servers: servers,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
@@ -212,9 +159,6 @@ class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@@ -276,14 +220,13 @@ class JellyfinConnection extends Connection {
|
||||
this.dialect = MediaBrowserDialect.jellyfin,
|
||||
this.isAdministrator = false,
|
||||
this.primaryImageTag,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
}) : baseUrl = canonicalizeBaseUrl(baseUrl),
|
||||
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
|
||||
MediaBackend get kind => dialect.backend;
|
||||
|
||||
@override
|
||||
String get displayName => '$userName · $serverName';
|
||||
@@ -338,7 +281,6 @@ class JellyfinConnection extends Connection {
|
||||
/// a refresh must be able to null the cached value — a bare
|
||||
/// `primaryImageTag: null` is indistinguishable from "unchanged".
|
||||
bool clearPrimaryImageTag = false,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
@@ -356,7 +298,6 @@ class JellyfinConnection extends Connection {
|
||||
dialect: dialect ?? this.dialect,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
@@ -384,7 +325,6 @@ class JellyfinConnection extends Connection {
|
||||
factory JellyfinConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
@@ -408,7 +348,6 @@ class JellyfinConnection extends Connection {
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
isAdministrator: json['isAdministrator'] as bool? ?? false,
|
||||
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
import 'connection_registry.dart';
|
||||
import 'plex_account_setup.dart';
|
||||
|
||||
/// One-shot helpers that bridge between the legacy single-Plex-account
|
||||
/// SharedPreferences state (`StorageService.plexToken` +
|
||||
@@ -97,25 +98,12 @@ class ConnectionBootstrap {
|
||||
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
|
||||
|
||||
try {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final info = await auth.getUserInfo(devToken);
|
||||
final servers = await auth.fetchServers(devToken);
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.$clientId',
|
||||
accountToken: devToken,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex',
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}');
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
// The dev seed always keys its row by the device client id and aborts
|
||||
// on an identity-lookup failure — a bad PLEX_TOKEN must not seed a
|
||||
// mislabelled row.
|
||||
final build = await buildPlexAccountConnection(devToken, keyByAccountUuid: false, tolerateUserInfoFailure: false);
|
||||
await connectionRegistry.upsert(build.connection);
|
||||
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${build.connection.id}');
|
||||
} catch (e, st) {
|
||||
appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st);
|
||||
}
|
||||
@@ -138,31 +126,17 @@ class ConnectionBootstrap {
|
||||
}
|
||||
|
||||
try {
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final servers = await serverRegistry.getServers();
|
||||
|
||||
String accountLabel = 'Plex';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await _plexUserInfoFetcher(token);
|
||||
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
appLogger.d('Plex migration: account label lookup failed (using fallback): $e');
|
||||
}
|
||||
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
|
||||
accountToken: token,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: accountLabel,
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
final build = await buildPlexAccountConnection(
|
||||
token,
|
||||
clientIdentifier: await storage.getOrCreateClientIdentifier(),
|
||||
fetchUserInfo: _plexUserInfoFetcher,
|
||||
// The migration wraps the cached legacy server list; a network server
|
||||
// fetch could fail exactly when the migration must succeed offline.
|
||||
fetchServers: (_) => serverRegistry.getServers(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}');
|
||||
return conn;
|
||||
await connectionRegistry.upsert(build.connection);
|
||||
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${build.connection.id}');
|
||||
return build.connection;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Plex account migration failed', error: e, stackTrace: st);
|
||||
return null;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../services/credential_vault.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
@@ -11,13 +13,23 @@ import 'connection.dart';
|
||||
/// CRUD over the persisted [Connections] table. The registry is the source
|
||||
/// of truth for which connections the user has added; the runtime
|
||||
/// `MultiServerManager` populates per-server clients from these records.
|
||||
///
|
||||
/// Single-connection users get a default automatically — power users with
|
||||
/// multiple connections can override it via [setDefault].
|
||||
class ConnectionRegistry {
|
||||
ConnectionRegistry(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
static const DeepCollectionEquality _configEquality = DeepCollectionEquality();
|
||||
|
||||
final Expando<Map<String, Object?>> _decryptedConfigs = Expando<Map<String, Object?>>(
|
||||
'ConnectionRegistry.decryptedConfigs',
|
||||
);
|
||||
|
||||
/// Compares the decrypted persisted config projections retained while rows
|
||||
/// are decoded. The fallback covers models supplied outside this registry.
|
||||
bool hasSameConfig(Connection a, Connection b) {
|
||||
final aConfig = _decryptedConfigs[a] ??= a.toConfigJson();
|
||||
final bConfig = _decryptedConfigs[b] ??= b.toConfigJson();
|
||||
return _configEquality.equals(aConfig, bConfig);
|
||||
}
|
||||
|
||||
/// Emits the current set of connections after every mutation. Drift's
|
||||
/// `watch()` provides this for free.
|
||||
@@ -40,33 +52,19 @@ class ConnectionRegistry {
|
||||
return _rowToConnection(row);
|
||||
}
|
||||
|
||||
/// Insert or replace [connection]. If this is the first stored connection
|
||||
/// it is automatically marked default; re-upserting an existing row keeps
|
||||
/// the row's current `isDefault` and `createdAt` (so token/metadata
|
||||
/// refreshes don't clear the default flag or restamp creation order).
|
||||
/// Insert or replace [connection]. Re-upserting an existing row keeps the
|
||||
/// row's current `createdAt` (so token/metadata refreshes don't restamp
|
||||
/// creation order).
|
||||
///
|
||||
/// Creation order is behaviour, not bookkeeping: it decides which
|
||||
/// connection lends a profile its picture, and `remove` promotes the oldest
|
||||
/// remaining row to default. Re-authenticating rebuilds the model with
|
||||
/// `DateTime.now()` and reuses the same stable id, so without this the
|
||||
/// originally-first connection would jump to last on every re-sign-in.
|
||||
/// connection lends a profile its picture. Re-authenticating rebuilds the
|
||||
/// model with `DateTime.now()` and reuses the same stable id, so without
|
||||
/// this the originally-first connection would jump to last on every
|
||||
/// re-sign-in.
|
||||
Future<void> upsert(Connection connection) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
|
||||
final bool isDefault;
|
||||
final int createdAt;
|
||||
if (existing != null) {
|
||||
isDefault = existing.isDefault;
|
||||
createdAt = existing.createdAt;
|
||||
} else {
|
||||
final any =
|
||||
await (_db.selectOnly(_db.connections)
|
||||
..addColumns([_db.connections.id])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
isDefault = any == null;
|
||||
createdAt = connection.createdAt.millisecondsSinceEpoch;
|
||||
}
|
||||
final createdAt = existing?.createdAt ?? connection.createdAt.millisecondsSinceEpoch;
|
||||
final protectedConfig = await CredentialVault.protectConnectionConfig(
|
||||
connection.kind.id,
|
||||
connection.toConfigJson(),
|
||||
@@ -76,7 +74,6 @@ class ConnectionRegistry {
|
||||
kind: Value(connection.kind.id),
|
||||
displayName: Value(connection.displayName),
|
||||
configJson: Value(jsonEncode(protectedConfig)),
|
||||
isDefault: Value(isDefault),
|
||||
createdAt: Value(createdAt),
|
||||
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
|
||||
);
|
||||
@@ -85,44 +82,14 @@ class ConnectionRegistry {
|
||||
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
|
||||
}
|
||||
|
||||
/// Remove a stored connection. If the removed row was the default, the
|
||||
/// oldest remaining connection (if any) becomes default.
|
||||
/// Remove a stored connection.
|
||||
Future<void> remove(String id) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
|
||||
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
}
|
||||
});
|
||||
appLogger.d('ConnectionRegistry: removed $id');
|
||||
}
|
||||
|
||||
/// Set [id] as the default connection. Clears the flag on all others.
|
||||
Future<void> setDefault(String id) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await _db.transaction(() async {
|
||||
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Update only the auth-related metadata on an existing row (token,
|
||||
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
|
||||
/// silent refresh without touching the rest of the config.
|
||||
Future<void> recordAuthSuccess(String id, DateTime at) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
|
||||
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await _db.delete(_db.connections).go();
|
||||
@@ -147,29 +114,28 @@ class ConnectionRegistry {
|
||||
Future<Connection?> _rowToConnection(ConnectionRow row) async {
|
||||
try {
|
||||
final json = jsonDecode(row.configJson) as Map<String, dynamic>;
|
||||
final kind = ConnectionKind.fromId(row.kind);
|
||||
final kind = MediaBackend.fromId(row.kind);
|
||||
final revealed = await CredentialVault.revealConnectionConfig(kind.id, json);
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt);
|
||||
final lastAuth = row.lastAuthenticatedAt == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!);
|
||||
final connection = switch (kind) {
|
||||
ConnectionKind.plex => PlexAccountConnection.fromConfigJson(
|
||||
MediaBackend.plex => PlexAccountConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson(
|
||||
MediaBackend.jellyfin || MediaBackend.emby => JellyfinConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
dialect: kind.dialect!,
|
||||
),
|
||||
};
|
||||
_decryptedConfigs[connection] = revealed.config;
|
||||
if (revealed.migrated) {
|
||||
await upsert(connection);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,77 @@ typedef PlexAccountRegistration = ({
|
||||
String email,
|
||||
});
|
||||
|
||||
/// Result of [buildPlexAccountConnection]: the assembled connection plus the
|
||||
/// raw identity fields callers fold into their own records and dedup scans.
|
||||
typedef PlexAccountBuild = ({PlexAccountConnection connection, String username, String email, String accountUuid});
|
||||
|
||||
/// Shared token → [PlexAccountConnection] pipeline: resolve the plex.tv
|
||||
/// account identity, list the account's servers, and assemble a connection
|
||||
/// row stamped "now". [registerPlexAccountFromToken] and both
|
||||
/// [ConnectionBootstrap] entry points (the PLEX_TOKEN dev seed and the
|
||||
/// legacy-token migration) funnel through here so the id/label policy can't
|
||||
/// drift. Each caller keeps its delta via the knobs:
|
||||
///
|
||||
/// - [clientIdentifier] is persisted on the row and keys it when the account
|
||||
/// uuid is unavailable. `null` uses the short-lived [PlexAuthService]'s own
|
||||
/// per-device id (the same [StorageService]-backed value).
|
||||
/// - [fetchUserInfo]/[fetchServers] override the identity/server sources —
|
||||
/// the legacy migration injects a test seam and the cached legacy server
|
||||
/// list. When both are given (and [clientIdentifier] is set) no
|
||||
/// [PlexAuthService] is created at all.
|
||||
/// - [keyByAccountUuid]: the dev seed keys its row by client id even when
|
||||
/// the account uuid is known.
|
||||
/// - [tolerateUserInfoFailure]: sign-in and migration fall back to a
|
||||
/// client-id-keyed row when the identity lookup fails; the dev seed aborts
|
||||
/// instead of seeding a mislabelled row.
|
||||
Future<PlexAccountBuild> buildPlexAccountConnection(
|
||||
String token, {
|
||||
String? clientIdentifier,
|
||||
Future<Map<String, dynamic>> Function(String token)? fetchUserInfo,
|
||||
Future<List<PlexServer>> Function(String token)? fetchServers,
|
||||
bool keyByAccountUuid = true,
|
||||
bool tolerateUserInfoFailure = true,
|
||||
}) async {
|
||||
PlexAuthService? auth;
|
||||
try {
|
||||
if (clientIdentifier == null || fetchUserInfo == null || fetchServers == null) {
|
||||
auth = await PlexAuthService.create();
|
||||
}
|
||||
// Account identity from plex.tv — the uuid is what makes multi-account
|
||||
// work (the clientIdentifier is per-device and identical for every Plex
|
||||
// account on this install). Falls back to the client identifier only
|
||||
// when the user-info call fails outright; a later successful sign-in
|
||||
// migrates that legacy row via [_migrateLegacyClientIdRow].
|
||||
String username = '';
|
||||
String email = '';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await (fetchUserInfo ?? auth!.getUserInfo)(token);
|
||||
username = (info['username'] as String?) ?? '';
|
||||
email = (info['email'] as String?) ?? '';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
if (!tolerateUserInfoFailure) rethrow;
|
||||
appLogger.d('Plex user-info lookup failed (using fallback identity): $e');
|
||||
}
|
||||
|
||||
final servers = await (fetchServers ?? auth!.fetchServers)(token);
|
||||
final clientId = clientIdentifier ?? auth!.clientIdentifier;
|
||||
final connection = PlexAccountConnection(
|
||||
id: 'plex.${keyByAccountUuid && accountUuid.isNotEmpty ? accountUuid : clientId}',
|
||||
accountToken: token,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
return (connection: connection, username: username, email: email, accountUuid: accountUuid);
|
||||
} finally {
|
||||
auth?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared post-auth pipeline for a fresh plex.tv [token]: resolve the
|
||||
/// account identity, persist the [PlexAccountConnection] (folding in a
|
||||
/// legacy client-id-keyed row from an earlier failed identity lookup), and
|
||||
@@ -31,66 +102,36 @@ Future<PlexAccountRegistration> registerPlexAccountFromToken({
|
||||
required StorageService storage,
|
||||
required PlexHomeService plexHome,
|
||||
}) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
// Account identity from plex.tv — the uuid is what makes multi-account
|
||||
// work (the clientIdentifier is per-device and identical for every Plex
|
||||
// account on this install). Falls back to the client identifier only
|
||||
// when the user-info call fails outright; a later successful sign-in
|
||||
// migrates that legacy row below.
|
||||
String username = '';
|
||||
String email = '';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await auth.getUserInfo(token);
|
||||
username = (info['username'] as String?) ?? '';
|
||||
email = (info['email'] as String?) ?? '';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
appLogger.d('getUserInfo after Plex sign-in failed (using fallback identity): $e');
|
||||
}
|
||||
final build = await buildPlexAccountConnection(token);
|
||||
final connection = build.connection;
|
||||
final legacyId = 'plex.${connection.clientIdentifier}';
|
||||
final existedBefore =
|
||||
await connections.get(connection.id) != null ||
|
||||
(build.accountUuid.isNotEmpty &&
|
||||
legacyId != connection.id &&
|
||||
await connections.get(legacyId) is PlexAccountConnection);
|
||||
await connections.upsert(connection);
|
||||
|
||||
final servers = await auth.fetchServers(token);
|
||||
final connection = PlexAccountConnection(
|
||||
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}',
|
||||
accountToken: token,
|
||||
clientIdentifier: auth.clientIdentifier,
|
||||
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
if (build.accountUuid.isNotEmpty) {
|
||||
await _migrateLegacyClientIdRow(
|
||||
replacement: connection,
|
||||
clientIdentifier: connection.clientIdentifier,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
final legacyId = 'plex.${auth.clientIdentifier}';
|
||||
final existedBefore =
|
||||
await connections.get(connection.id) != null ||
|
||||
(accountUuid.isNotEmpty &&
|
||||
legacyId != connection.id &&
|
||||
await connections.get(legacyId) is PlexAccountConnection);
|
||||
await connections.upsert(connection);
|
||||
|
||||
if (accountUuid.isNotEmpty) {
|
||||
await _migrateLegacyClientIdRow(
|
||||
replacement: connection,
|
||||
clientIdentifier: auth.clientIdentifier,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch home users now so pickers surface the account's virtual
|
||||
// profiles immediately.
|
||||
final homeUsersFetched = await plexHome.refresh(connection);
|
||||
return (
|
||||
connection: connection,
|
||||
homeUsersFetched: homeUsersFetched,
|
||||
existedBefore: existedBefore,
|
||||
username: username,
|
||||
email: email,
|
||||
);
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
|
||||
// Fetch home users now so pickers surface the account's virtual
|
||||
// profiles immediately.
|
||||
final homeUsersFetched = await plexHome.refresh(connection);
|
||||
return (
|
||||
connection: connection,
|
||||
homeUsersFetched: homeUsersFetched,
|
||||
existedBefore: existedBefore,
|
||||
username: build.username,
|
||||
email: build.email,
|
||||
);
|
||||
}
|
||||
|
||||
/// A row keyed by the per-device client identifier (created when an earlier
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Dense BMP ranks per DUCET (Unicode 13.0) + pinned CLDR CJK radical-stroke.
|
||||
/// Each code unit stores rank + 1; zero means absent.
|
||||
/// Katakana sorts before hiragana (CLDR root tailoring).
|
||||
/// Generated by scripts/generate_ducet_ranks.dart — do not edit.
|
||||
/// Generated by scripts/codegen/generate_ducet_ranks.dart — do not edit.
|
||||
library;
|
||||
|
||||
const String _ducetRanks =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by dart run scripts/generate_hid_key_labels.dart from scripts/data/hid_key_labels.json; do not edit by hand.
|
||||
// Generated by dart run scripts/codegen/generate_hid_key_labels.dart from scripts/codegen/data/hid_key_labels.json; do not edit by hand.
|
||||
|
||||
/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.
|
||||
const hidKeyLabels = <int, String>{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by dart run scripts/generate_iso_639_data.dart from scripts/data/iso_639_codes.json; do not edit by hand.
|
||||
// Generated by dart run scripts/codegen/generate_iso_639_data.dart from scripts/codegen/data/iso_639_codes.json; do not edit by hand.
|
||||
|
||||
class LanguageEntry {
|
||||
final String code1;
|
||||
|
||||
@@ -37,15 +37,6 @@ enum OfflineActionType {
|
||||
OfflineActionType.watched => 'watched',
|
||||
OfflineActionType.unwatched => 'unwatched',
|
||||
};
|
||||
|
||||
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
|
||||
/// silently fall back to the wrong action.
|
||||
static OfflineActionType fromId(String id) => switch (id) {
|
||||
'progress' => OfflineActionType.progress,
|
||||
'watched' => OfflineActionType.watched,
|
||||
'unwatched' => OfflineActionType.unwatched,
|
||||
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
|
||||
};
|
||||
}
|
||||
|
||||
final class AppDatabaseBootstrap {
|
||||
@@ -67,6 +58,7 @@ final class AppDatabaseBootstrap {
|
||||
Connections,
|
||||
Profiles,
|
||||
ProfileConnections,
|
||||
MusicSessions,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -81,7 +73,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase._withRecovery(super.e, this._recoveryStore);
|
||||
|
||||
final TvosDatabaseRecoveryStore? _recoveryStore;
|
||||
final SerialFutureQueue _durabilityQueue = SerialFutureQueue();
|
||||
static final Object _durabilityZoneKey = Object();
|
||||
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
|
||||
|
||||
@@ -156,12 +147,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
final store = _recoveryStore;
|
||||
if (store == null || !store.isTvos) return Future<void>.value();
|
||||
|
||||
return _durabilityQueue.run(
|
||||
() => _tvosRecoveryQueue.run(
|
||||
() => store.acknowledgeRecoveryRequired(
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
),
|
||||
return _tvosRecoveryQueue.run(
|
||||
() => store.acknowledgeRecoveryRequired(
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -175,17 +164,15 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (store == null || !store.isTvos) return mutation();
|
||||
if (Zone.current[_durabilityZoneKey] == this) return mutation();
|
||||
|
||||
return _durabilityQueue.run(
|
||||
() => _tvosRecoveryQueue.run(
|
||||
() => runZoned(
|
||||
() => store.runDurableMutation(
|
||||
group: group,
|
||||
mutation: mutation,
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
),
|
||||
zoneValues: {_durabilityZoneKey: this},
|
||||
return _tvosRecoveryQueue.run(
|
||||
() => runZoned(
|
||||
() => store.runDurableMutation(
|
||||
group: group,
|
||||
mutation: mutation,
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
),
|
||||
zoneValues: {_durabilityZoneKey: this},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -324,6 +311,14 @@ class AppDatabase extends _$AppDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
/// Columns that once existed in a snapshotted table and may still appear in
|
||||
/// committed recovery images written by older builds. They are stripped
|
||||
/// before the strict round-trip check in [_decodeRecoveryRow] so retiring a
|
||||
/// column does not brick restore on devices holding a pre-retirement image.
|
||||
static const Map<String, Set<String>> _retiredRecoveryColumns = {
|
||||
'connections': {'isDefault'},
|
||||
};
|
||||
|
||||
static List<T> _decodeRecoveryRows<T extends DataClass>(
|
||||
Map<String, Object?> group,
|
||||
String key,
|
||||
@@ -331,9 +326,21 @@ class AppDatabase extends _$AppDatabase {
|
||||
) {
|
||||
final value = group[key];
|
||||
if (value is! List) throw _invalidRecoveryImage;
|
||||
final retired = _retiredRecoveryColumns[key];
|
||||
return [
|
||||
for (final row in value)
|
||||
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
|
||||
if (row is Map<String, dynamic>)
|
||||
_decodeRecoveryRow(
|
||||
retired == null
|
||||
? row
|
||||
: {
|
||||
for (final entry in row.entries)
|
||||
if (!retired.contains(entry.key)) entry.key: entry.value,
|
||||
},
|
||||
fromJson,
|
||||
)
|
||||
else
|
||||
throw _invalidRecoveryImage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -359,7 +366,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
|
||||
|
||||
@override
|
||||
int get schemaVersion => 20;
|
||||
int get schemaVersion => 22;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -725,6 +732,14 @@ class AppDatabase extends _$AppDatabase {
|
||||
() => m.create(idxSyncRuleDownloadsProfileKey),
|
||||
);
|
||||
}
|
||||
if (from < 21) {
|
||||
appLogger.i('Dropping unused Connections.isDefault column (v21 migration)');
|
||||
await m.alterTable(TableMigration(connections));
|
||||
}
|
||||
if (from < 22) {
|
||||
appLogger.i('Adding MusicSessions table (v22 migration)');
|
||||
await _ignoreAlreadyExists('MusicSessions table', () => m.createTable(musicSessions));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1057,25 +1072,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete a specific watch action outside a snapshotted replay.
|
||||
Future<void> deleteWatchAction(int id) {
|
||||
return _runPendingMutation(() async {
|
||||
await (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go();
|
||||
});
|
||||
}
|
||||
|
||||
/// Update retry state outside a snapshotted replay.
|
||||
Future<void> updateSyncAttempt(int id, String? errorMessage) {
|
||||
return _runPendingMutation(() async {
|
||||
final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
if (existing == null) return;
|
||||
|
||||
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
|
||||
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get count of pending sync items
|
||||
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
|
||||
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
|
||||
@@ -1103,6 +1099,38 @@ class AppDatabase extends _$AppDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Music session persistence (#2148)
|
||||
// ===========================================================================
|
||||
|
||||
/// Full snapshot write: replaces the profile's persisted music session.
|
||||
Future<void> upsertMusicSession(MusicSessionRow row) {
|
||||
return into(musicSessions).insertOnConflictUpdate(row);
|
||||
}
|
||||
|
||||
/// Cheap write-through for playhead/cursor changes — leaves the (possibly
|
||||
/// large) queue JSON untouched. No-op when no snapshot row exists.
|
||||
Future<void> updateMusicSessionProgress({
|
||||
required String profileId,
|
||||
required int cursor,
|
||||
required int positionMs,
|
||||
required int updatedAt,
|
||||
}) async {
|
||||
await (update(musicSessions)..where((t) => t.profileId.equals(profileId))).write(
|
||||
MusicSessionsCompanion(cursor: Value(cursor), positionMs: Value(positionMs), updatedAt: Value(updatedAt)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<MusicSessionRow?> getMusicSession(String profileId) {
|
||||
return (select(musicSessions)..where((t) => t.profileId.equals(profileId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Drop a profile's persisted music session (user session end or profile
|
||||
/// teardown).
|
||||
Future<void> deleteMusicSessionForProfile(String profileId) async {
|
||||
await (delete(musicSessions)..where((t) => t.profileId.equals(profileId))).go();
|
||||
}
|
||||
|
||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||
final query = select(syncRules);
|
||||
if (profileId != null) {
|
||||
@@ -1275,9 +1303,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
|
||||
|
||||
Future<void> completeSyncRuleExecution(String globalKey) {
|
||||
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(
|
||||
|
||||
+1017
-138
File diff suppressed because it is too large
Load Diff
@@ -145,10 +145,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getDownloadOwnerCount(String globalKey) async {
|
||||
return (await _validDownloadOwnerRows(globalKey)).length;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
||||
@@ -190,8 +186,8 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// inherit them.
|
||||
///
|
||||
/// Runs on every profile switch — validity context is computed once and
|
||||
/// applied in memory instead of the per-download full-table rescan
|
||||
/// `getDownloadOwnerCount` would do.
|
||||
/// applied in memory instead of a per-download full-table rescan through
|
||||
/// `_validDownloadOwnerRows`.
|
||||
Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
|
||||
if (profileId.isEmpty) return;
|
||||
if (isStillActive != null && !isStillActive()) return;
|
||||
@@ -437,14 +433,20 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
|
||||
/// Get next item from queue (highest priority, oldest first)
|
||||
/// Only returns items that are not paused
|
||||
Future<DownloadQueueItem?> getNextQueueItem() async {
|
||||
/// Only returns items that are not paused.
|
||||
///
|
||||
/// [excludedGlobalKeys] filters out heads the caller already tried and could
|
||||
/// not resolve, so one stale row cannot starve the rest of the queue.
|
||||
Future<DownloadQueueItem?> getNextQueueItem({Set<String> excludedGlobalKeys = const {}}) async {
|
||||
final query = select(
|
||||
downloadQueue,
|
||||
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
||||
|
||||
query.where(downloadedMedia.status.equals(DownloadStatus.queued.index));
|
||||
if (excludedGlobalKeys.isNotEmpty) {
|
||||
query.where(downloadQueue.mediaGlobalKey.isNotIn(excludedGlobalKeys.toList(growable: false)));
|
||||
}
|
||||
query
|
||||
..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
|
||||
..orderBy([
|
||||
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: downloadQueue.addedAt),
|
||||
|
||||
@@ -9,12 +9,14 @@ class ApiCache extends Table {
|
||||
|
||||
TextColumn get data => text()();
|
||||
|
||||
/// When the row was last written ([ApiCacheSingleton.put] stamps it on
|
||||
/// every store). Read by the fresh-cache-first playback metadata gate,
|
||||
/// [ApiCacheSingleton.getIfFresh].
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
/// Whether this item is pinned for offline access
|
||||
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp for cache invalidation (optional future use)
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -150,10 +152,6 @@ class Connections extends Table {
|
||||
/// Backend-specific config payload (token, baseUrl, profile id, …).
|
||||
TextColumn get configJson => text()();
|
||||
|
||||
/// Whether this is the default connection used at app launch when only
|
||||
/// one connection is present.
|
||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp this connection was added (milliseconds since epoch).
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
@@ -284,3 +282,45 @@ class OfflineWatchProgress extends Table {
|
||||
/// Last sync error message
|
||||
TextColumn get lastError => text().nullable()();
|
||||
}
|
||||
|
||||
/// Last music session per profile, restored paused on the next launch (#2148).
|
||||
///
|
||||
/// One row per profile: the serialized queue plus enough arrangement state to
|
||||
/// rebuild it faithfully (canonical order, shuffle permutation, cursor, modes)
|
||||
/// and the playhead. Written through during playback (throttled position
|
||||
/// updates, full rewrites on queue-shape changes) because mobile gives no
|
||||
/// termination hook; cleared when the user visibly ends the session.
|
||||
@DataClassName('MusicSessionRow')
|
||||
class MusicSessions extends Table {
|
||||
/// Active Plezy profile that owns this session snapshot.
|
||||
TextColumn get profileId => text()();
|
||||
|
||||
/// Canonical queue tracks (insertion order) as a JSON array of MediaItem
|
||||
/// JSON — self-contained so restore never depends on volatile cache rows.
|
||||
TextColumn get queueJson => text()();
|
||||
|
||||
/// Playback-order permutation into [queueJson] as a JSON int array
|
||||
/// (identity while unshuffled).
|
||||
TextColumn get orderJson => text()();
|
||||
|
||||
/// Position of the current track within the playback order.
|
||||
IntColumn get cursor => integer()();
|
||||
|
||||
BoolColumn get shuffled => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Stable repeat-mode id ('off' | 'all' | 'one') — not the enum `.name`.
|
||||
TextColumn get repeatMode => text().withDefault(const Constant('off'))();
|
||||
|
||||
/// Play-context provenance ("Playing from …"); kind is a stable id.
|
||||
TextColumn get contextTitle => text().nullable()();
|
||||
TextColumn get contextKind => text().nullable()();
|
||||
|
||||
/// Playhead within the current track in milliseconds.
|
||||
IntColumn get positionMs => integer().withDefault(const Constant(0))();
|
||||
|
||||
/// Timestamp of the last write (milliseconds since epoch).
|
||||
IntColumn get updatedAt => integer()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {profileId};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class MediaServerAuthException extends MediaServerException {
|
||||
/// Auth polling reached a terminal server-side expiry/rejection state before
|
||||
/// the user completed the external sign-in flow.
|
||||
class MediaServerPinExpiredException extends MediaServerAuthException {
|
||||
const MediaServerPinExpiredException({String? display}) : super('PIN expired before sign-in', display: display);
|
||||
const MediaServerPinExpiredException({super.display}) : super('PIN expired before sign-in');
|
||||
}
|
||||
|
||||
/// HTTP transport / non-2xx errors. Carries the status code (when known),
|
||||
@@ -57,11 +57,11 @@ class MediaServerHttpException extends MediaServerException {
|
||||
MediaServerHttpException({
|
||||
required this.type,
|
||||
String? message,
|
||||
String? display,
|
||||
super.display,
|
||||
this.statusCode,
|
||||
this.responseData,
|
||||
this.requestUri,
|
||||
}) : super(message ?? '', display: display);
|
||||
}) : super(message ?? '');
|
||||
|
||||
/// Map a caught exception to a [MediaServerHttpException].
|
||||
factory MediaServerHttpException.from(Object error, {Uri? uri}) {
|
||||
@@ -119,6 +119,17 @@ class MediaServerHttpException extends MediaServerException {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend already has a recording scheduled for the requested airing.
|
||||
///
|
||||
/// Plex signals duplicates with a bare 409, which UI maps by status code.
|
||||
/// Jellyfin/Emby answer `POST /LiveTv/Timers` duplicates with a 400 that is
|
||||
/// indistinguishable from a malformed request by status alone — only the DVR
|
||||
/// adapter knows that call site's semantics, so it rethrows this type and UI
|
||||
/// maps it to the "already scheduled" outcome without a backend check.
|
||||
class RecordingConflictException extends MediaServerException {
|
||||
const RecordingConflictException(super.message, {super.display});
|
||||
}
|
||||
|
||||
/// The server explicitly terminated the client's playback session (admin
|
||||
/// "stop stream", paused-too-long auto-termination, concurrent-stream limit).
|
||||
///
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -70,29 +71,73 @@ extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
|
||||
}
|
||||
|
||||
/// Base class for suppressing key-up events after a key category triggers an
|
||||
/// action (e.g. opening a sheet). While suppressed, all events for the matched
|
||||
/// key category are consumed; suppression auto-clears on [KeyUpEvent].
|
||||
/// Base class for suppressing key events after a key category triggers an
|
||||
/// action (e.g. opening a sheet). While suppressed, every event of the matched
|
||||
/// key category is consumed — including a [KeyDownEvent] when
|
||||
/// [consumeKeyDowns] is set, because an armer running in the
|
||||
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
|
||||
/// very KeyDown that is then re-dispatched through the focus tree.
|
||||
///
|
||||
/// Suppression ends with the physical press: a global [HardwareKeyboard]
|
||||
/// observer watches for the matching [KeyUpEvent] and clears the armed state
|
||||
/// in a microtask — after that KeyUp's own synchronous focus dispatch, so
|
||||
/// focus-phase consumers still swallow it — which guarantees a stale armed
|
||||
/// state (a KeyUp delivered to a focus chain that never consulted us) can
|
||||
/// never swallow the next press.
|
||||
///
|
||||
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
|
||||
/// matching [KeyDownEvent] as proof the suppressed press already ended without
|
||||
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
|
||||
/// state is cleared and the fresh press passes through unconsumed.
|
||||
class _KeyUpSuppressor {
|
||||
final bool Function(LogicalKeyboardKey) _keyMatcher;
|
||||
|
||||
_KeyUpSuppressor(this._keyMatcher);
|
||||
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
|
||||
/// documentation for why armers targeting an in-flight KeyUp must unset it.
|
||||
final bool consumeKeyDowns;
|
||||
|
||||
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
|
||||
|
||||
bool _suppressed = false;
|
||||
|
||||
void suppress() => _suppressed = true;
|
||||
void suppress() {
|
||||
// Re-registered on every arm because flutter_test's HardwareKeyboard
|
||||
// clearState() drops handlers between tests; remove-then-add keeps exactly
|
||||
// one live registration. The observer only reads state and returns false,
|
||||
// so it can never consume an event, and it is intentionally never removed
|
||||
// outside re-registration.
|
||||
HardwareKeyboard.instance
|
||||
..removeHandler(_observeKeyUp)
|
||||
..addHandler(_observeKeyUp);
|
||||
_suppressed = true;
|
||||
}
|
||||
|
||||
bool _observeKeyUp(KeyEvent event) {
|
||||
if (_suppressed && event is KeyUpEvent && _keyMatcher(event.logicalKey)) {
|
||||
// The hardware phase runs before the same event's focus dispatch;
|
||||
// clearing in a microtask keeps the armed state visible to the KeyUp's
|
||||
// focus-phase consumers while ending it before any later event.
|
||||
scheduleMicrotask(clearSuppression);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void clearSuppression() => _suppressed = false;
|
||||
|
||||
/// Returns `true` (consumed) when the event belongs to the matched key
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent].
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent],
|
||||
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
|
||||
bool consumeIfSuppressed(KeyEvent event) {
|
||||
if (!_suppressed) return false;
|
||||
if (_keyMatcher(event.logicalKey)) {
|
||||
if (event is KeyUpEvent) _suppressed = false;
|
||||
return true;
|
||||
if (!_keyMatcher(event.logicalKey)) return false;
|
||||
if (event is KeyUpEvent) _suppressed = false;
|
||||
if (event is KeyDownEvent && !consumeKeyDowns) {
|
||||
// The in-flight KeyUp never arrived; this is a fresh press, not the
|
||||
// suppressed one. Clear and let it through.
|
||||
_suppressed = false;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +153,13 @@ class SelectKeyUpSuppressor {
|
||||
/// Global helper to suppress the next BACK key-up event.
|
||||
///
|
||||
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
|
||||
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — so the
|
||||
/// in-flight key-up doesn't propagate to the underlying screen's back handler.
|
||||
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
|
||||
/// down-only back handler moves focus before the matching KeyUp is dispatched.
|
||||
/// A matching KeyDown while armed clears the arming instead of consuming: it
|
||||
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
|
||||
/// fresh press must act normally.
|
||||
class BackKeyUpSuppressor {
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey);
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
|
||||
|
||||
static void suppressBackUntilKeyUp() => _instance.suppress();
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Widget buildFocusChrome(
|
||||
bool useBackgroundFocus = false,
|
||||
bool useFocusGlow = false,
|
||||
bool delegateFocusBorder = false,
|
||||
bool? showGlow,
|
||||
Size? glowSize,
|
||||
required Widget child,
|
||||
}) {
|
||||
@@ -44,10 +45,12 @@ Widget buildFocusChrome(
|
||||
}
|
||||
|
||||
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
|
||||
// symmetric; the in-card decoration only carries the border.
|
||||
// symmetric; the in-card decoration only carries the border. [showGlow] lets
|
||||
// callers hold back just the glow (e.g. while a viewport scroll animates)
|
||||
// without touching the border or scale chrome.
|
||||
if (useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showFocus,
|
||||
isFocused: showGlow ?? showFocus,
|
||||
borderRadius: borderRadius,
|
||||
color: focusColor ?? FocusTheme.getFocusBorderColor(context),
|
||||
glowSize: glowSize,
|
||||
|
||||
@@ -57,6 +57,12 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
/// fades out before the portal is hidden in [_handleFadeEnd].
|
||||
bool _visible = false;
|
||||
|
||||
/// Whether the previous [build] mounted the [OverlayPortal] — i.e. whether
|
||||
/// [_controller] is attached. An attached controller's show()/hide() assert
|
||||
/// when called during build, so [didUpdateWidget] must defer; a detached
|
||||
/// controller's show() merely records pending state and is build-safe.
|
||||
bool _portalInTree = false;
|
||||
|
||||
/// Glow is skipped on the reduced effects tier (blurred shadows + fade
|
||||
/// saveLayer are too expensive on weak GPUs) and when the user turned the
|
||||
/// Focus Glow setting off (#1278). The crisp focus border remains.
|
||||
@@ -77,12 +83,25 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
if (_disabled) return;
|
||||
if (widget.isFocused == oldWidget.isFocused) return;
|
||||
if (widget.isFocused) {
|
||||
_controller.show();
|
||||
// Start hidden, then fade in next frame.
|
||||
_visible = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isFocused) setState(() => _visible = true);
|
||||
});
|
||||
if (_portalInTree) {
|
||||
// The portal from a previous glow session is still attached (mid
|
||||
// fade-out, or the rail held the glow back during a vertical scroll):
|
||||
// show() would assert during build, so defer it out of the frame and
|
||||
// fade back in from wherever the fade-out got to.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !widget.isFocused) return;
|
||||
if (!_controller.isShowing) _controller.show();
|
||||
setState(() => _visible = true);
|
||||
});
|
||||
} else {
|
||||
// Detached: a build-time show() only records pending state, applied
|
||||
// when this build mounts the portal. Start hidden, fade in next frame.
|
||||
_controller.show();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isFocused) setState(() => _visible = true);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
|
||||
setState(() => _visible = false);
|
||||
@@ -98,13 +117,18 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_disabled) return widget.child;
|
||||
if (_disabled) {
|
||||
_portalInTree = false;
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
// Gate the LeaderLayer to the focused card only: when not focused and not
|
||||
// mid-fade, return the bare child (no OverlayPortal, no leader).
|
||||
if (!widget.isFocused && !_controller.isShowing) {
|
||||
_portalInTree = false;
|
||||
return widget.child;
|
||||
}
|
||||
_portalInTree = true;
|
||||
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
|
||||
@@ -33,6 +33,26 @@ class DirectionalShortcutFocusNode extends FocusNode {
|
||||
node is DirectionalShortcutFocusNode && node.consumesDirectionalKeys(key);
|
||||
}
|
||||
|
||||
/// A [FocusNode] for a locked-focus row: the node spans the whole row while
|
||||
/// its owner steps an internal selection index between the row's items.
|
||||
///
|
||||
/// Swipe-step pricing follows the focused *item's* geometry (see
|
||||
/// `AppleTvRemoteTouchService`), and for a locked-focus row the node's own
|
||||
/// rect is the row — screen-wide — which would price a swipe step at the
|
||||
/// travel cap and make the row feel dead. The owner instead vends the
|
||||
/// selected item's global rect here; a null return (item not built yet,
|
||||
/// unknown geometry) falls back to the fixed step distance.
|
||||
///
|
||||
/// Like [DirectionalShortcutFocusNode], the fact rides on the node because it
|
||||
/// depends on what has focus, not on where a widget sits.
|
||||
class LockedFocusRowNode extends FocusNode {
|
||||
LockedFocusRowNode({required this.focusedItemRect, super.debugLabel, super.skipTraversal});
|
||||
|
||||
/// Global rect of the row's currently selected item, evaluated per swipe
|
||||
/// frame so selection moves need no syncing.
|
||||
final Rect? Function() focusedItemRect;
|
||||
}
|
||||
|
||||
/// Whether [event] is evidence that the viewer wants to navigate by focus.
|
||||
///
|
||||
/// This is the single answer to two questions that must never disagree:
|
||||
|
||||
@@ -11,19 +11,10 @@ import 'owned_focus_node_binding.dart';
|
||||
typedef FocusableActionBuilder = Widget Function(BuildContext context, FocusableActionBuildState state);
|
||||
|
||||
class FocusableActionBuildState {
|
||||
final FocusNode focusNode;
|
||||
final bool isFocused;
|
||||
final bool showFocus;
|
||||
final bool isKeyboardMode;
|
||||
final Duration animationDuration;
|
||||
|
||||
const FocusableActionBuildState({
|
||||
required this.focusNode,
|
||||
required this.isFocused,
|
||||
required this.showFocus,
|
||||
required this.isKeyboardMode,
|
||||
required this.animationDuration,
|
||||
});
|
||||
const FocusableActionBuildState({required this.showFocus, required this.animationDuration});
|
||||
}
|
||||
|
||||
class FocusableAction {
|
||||
@@ -41,6 +32,12 @@ class FocusableAction {
|
||||
final Widget? child;
|
||||
final FocusableActionBuilder? builder;
|
||||
|
||||
/// Row gap between this action and the one before it, overriding the bar's
|
||||
/// uniform [FocusableActionBar.spacing]. Lets visually joined pairs (the
|
||||
/// detail screen's split Play button) sit tighter than the rest of the row.
|
||||
/// Ignored on the first action.
|
||||
final double? spacingBefore;
|
||||
|
||||
const FocusableAction({
|
||||
this.icon = Symbols.circle_rounded,
|
||||
this.iconColor,
|
||||
@@ -53,6 +50,7 @@ class FocusableAction {
|
||||
this.onPressed,
|
||||
this.child,
|
||||
this.builder,
|
||||
this.spacingBefore,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,7 +76,6 @@ class FocusableActionBar extends StatefulWidget {
|
||||
final ValueChanged<bool>? onFocusChange;
|
||||
|
||||
final double spacing;
|
||||
final MainAxisSize mainAxisSize;
|
||||
|
||||
const FocusableActionBar({
|
||||
super.key,
|
||||
@@ -90,7 +87,6 @@ class FocusableActionBar extends StatefulWidget {
|
||||
this.onBack,
|
||||
this.onFocusChange,
|
||||
this.spacing = 0,
|
||||
this.mainAxisSize = MainAxisSize.min,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -134,8 +130,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
void didUpdateWidget(FocusableActionBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_shouldRebuildFocusNodes(oldWidget)) {
|
||||
_disposeNodes();
|
||||
_initNodes();
|
||||
_rebindNodes(oldWidget.actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,31 +143,120 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
return false;
|
||||
}
|
||||
|
||||
void _initNodes() {
|
||||
_focusBindings = [];
|
||||
_focusNodes = [];
|
||||
_focusStates = List<bool>.filled(widget.actions.length, false);
|
||||
for (var i = 0; i < widget.actions.length; i++) {
|
||||
final index = i;
|
||||
final binding = OwnedFocusNodeBinding();
|
||||
binding.bind(
|
||||
externalNode: widget.actions[i].focusNode,
|
||||
debugLabel: widget.actions[i].debugLabel ?? 'ActionBar[$i]',
|
||||
listener: () {
|
||||
final hasFocus = _focusNodes[index].hasFocus;
|
||||
if (_focusStates[index] != hasFocus) {
|
||||
setState(() => _focusStates[index] = hasFocus);
|
||||
}
|
||||
_notifyRowFocusIfChanged();
|
||||
},
|
||||
);
|
||||
_focusBindings.add(binding);
|
||||
_focusNodes.add(binding.node);
|
||||
_focusStates[i] = binding.node.hasFocus;
|
||||
/// Stable identity for matching an action across list changes: the supplied
|
||||
/// [FocusableAction.focusNode] first, else [FocusableAction.debugLabel].
|
||||
/// Unlabeled actions have no identity and only ever match positionally.
|
||||
static Object? _actionIdentity(FocusableAction action) => action.focusNode ?? action.debugLabel;
|
||||
|
||||
/// Whether the action-list shape is unchanged: same length and every
|
||||
/// labeled identity present in both lists sits at the index it had. Only
|
||||
/// then does an unlabeled action's position provably still refer to the
|
||||
/// same conceptual action.
|
||||
bool _unlabeledPositionsStable(List<FocusableAction> oldActions) {
|
||||
if (oldActions.length != widget.actions.length) return false;
|
||||
for (var i = 0; i < oldActions.length; i++) {
|
||||
final identity = _actionIdentity(oldActions[i]);
|
||||
if (identity == null) continue;
|
||||
for (var j = 0; j < widget.actions.length; j++) {
|
||||
if (j != i && _actionIdentity(widget.actions[j]) == identity) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
OwnedFocusNodeBinding _createBinding(FocusableAction action, int index) {
|
||||
final binding = OwnedFocusNodeBinding();
|
||||
binding.bind(
|
||||
externalNode: action.focusNode,
|
||||
debugLabel: action.debugLabel ?? 'ActionBar[$index]',
|
||||
listener: () => _handleBindingFocusChange(binding),
|
||||
);
|
||||
return binding;
|
||||
}
|
||||
|
||||
/// Index resolved at call time: a binding survives action-list changes, so
|
||||
/// a listener must not capture the slot it was created for.
|
||||
void _handleBindingFocusChange(OwnedFocusNodeBinding binding) {
|
||||
final index = _focusBindings.indexOf(binding);
|
||||
if (index != -1) {
|
||||
final hasFocus = binding.node.hasFocus;
|
||||
if (_focusStates[index] != hasFocus) {
|
||||
setState(() => _focusStates[index] = hasFocus);
|
||||
}
|
||||
}
|
||||
_notifyRowFocusIfChanged();
|
||||
}
|
||||
|
||||
void _initNodes() {
|
||||
_focusBindings = [for (var i = 0; i < widget.actions.length; i++) _createBinding(widget.actions[i], i)];
|
||||
_focusNodes = [for (final binding in _focusBindings) binding.node];
|
||||
_focusStates = [for (final node in _focusNodes) node.hasFocus];
|
||||
_hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
|
||||
}
|
||||
|
||||
/// Rebind on an action-list change, reusing the binding of every action
|
||||
/// whose identity survives so its focus node is not disposed out from under
|
||||
/// the user (media detail's watchlist action arrives asynchronously and
|
||||
/// used to drop D-pad focus with the wholesale rebuild). A removed focused
|
||||
/// action is disposed with its listener already detached, so the genuine
|
||||
/// row-focus loss is reported through [_notifyRowFocusIfChanged] here.
|
||||
void _rebindNodes(List<FocusableAction> oldActions) {
|
||||
final oldBindings = _focusBindings;
|
||||
final focusedOldIndex = _focusNodes.indexWhere((node) => node.hasFocus);
|
||||
|
||||
final claimed = List<bool>.filled(oldBindings.length, false);
|
||||
final unlabeledPositionsStable = _unlabeledPositionsStable(oldActions);
|
||||
int? matchOldIndex(int newIndex) {
|
||||
final identity = _actionIdentity(widget.actions[newIndex]);
|
||||
if (identity != null) {
|
||||
for (var i = 0; i < oldActions.length; i++) {
|
||||
if (!claimed[i] && _actionIdentity(oldActions[i]) == identity) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Positional reuse is the only option for an unlabeled action, but it
|
||||
// is only provably correct while the list shape is unchanged; across an
|
||||
// insertion/removal its successor is unknowable, so fall through to a
|
||||
// fresh binding — genuinely dropping focus beats silently retargeting
|
||||
// the focused binding (and its next Select) onto a different action.
|
||||
if (unlabeledPositionsStable && !claimed[newIndex] && _actionIdentity(oldActions[newIndex]) == null) {
|
||||
return newIndex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var focusedNewIndex = -1;
|
||||
final newBindings = <OwnedFocusNodeBinding>[];
|
||||
for (var i = 0; i < widget.actions.length; i++) {
|
||||
final oldIndex = matchOldIndex(i);
|
||||
if (oldIndex == null) {
|
||||
newBindings.add(_createBinding(widget.actions[i], i));
|
||||
continue;
|
||||
}
|
||||
claimed[oldIndex] = true;
|
||||
if (oldIndex == focusedOldIndex) focusedNewIndex = i;
|
||||
newBindings.add(oldBindings[oldIndex]);
|
||||
}
|
||||
|
||||
_focusBindings = newBindings;
|
||||
_focusNodes = [for (final binding in newBindings) binding.node];
|
||||
_focusStates = [for (final node in _focusNodes) node.hasFocus];
|
||||
|
||||
for (var i = 0; i < oldBindings.length; i++) {
|
||||
if (!claimed[i]) oldBindings[i].dispose();
|
||||
}
|
||||
|
||||
// The focused action survived: keep focus on it. The keyed row keeps its
|
||||
// Focus element attached across reorders, so this only re-requests focus
|
||||
// if the framework unfocused the node during widget churn.
|
||||
if (focusedNewIndex != -1 &&
|
||||
widget.actions[focusedNewIndex].onPressed != null &&
|
||||
!_focusNodes[focusedNewIndex].hasFocus) {
|
||||
_focusNodes[focusedNewIndex].requestFocus();
|
||||
}
|
||||
_notifyRowFocusIfChanged();
|
||||
}
|
||||
|
||||
void _notifyRowFocusIfChanged() {
|
||||
final hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
|
||||
if (_hasAnyFocus == hasAnyFocus) return;
|
||||
@@ -198,10 +282,10 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
final row = Row(
|
||||
mainAxisSize: widget.mainAxisSize,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < widget.actions.length; i++) ...[
|
||||
if (i > 0 && widget.spacing > 0) SizedBox(width: widget.spacing),
|
||||
if (i > 0 && _gapBefore(i) > 0) SizedBox(width: _gapBefore(i)),
|
||||
_buildButton(i, isKeyboard, duration),
|
||||
],
|
||||
],
|
||||
@@ -215,22 +299,21 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
return AnimatedOpacity(opacity: isKeyboard && !_hasAnyFocus ? 0.6 : 1.0, duration: duration, child: row);
|
||||
}
|
||||
|
||||
double _gapBefore(int index) => widget.actions[index].spacingBefore ?? widget.spacing;
|
||||
|
||||
Widget _buildButton(int index, bool isKeyboard, Duration duration) {
|
||||
final action = widget.actions[index];
|
||||
final enabled = action.onPressed != null;
|
||||
final isFocused = _focusStates[index];
|
||||
final showFocus = isFocused && isKeyboard;
|
||||
final opacity = isKeyboard && _hasAnyFocus && !isFocused ? 0.6 : 1.0;
|
||||
final buildState = FocusableActionBuildState(
|
||||
focusNode: _focusNodes[index],
|
||||
isFocused: isFocused,
|
||||
showFocus: showFocus,
|
||||
isKeyboardMode: isKeyboard,
|
||||
animationDuration: duration,
|
||||
);
|
||||
final buildState = FocusableActionBuildState(showFocus: showFocus, animationDuration: duration);
|
||||
final customChild = action.builder?.call(context, buildState);
|
||||
|
||||
return Focus(
|
||||
// Keyed on the binding so an insertion moves the element with its node
|
||||
// instead of detaching (and thereby unfocusing) it slot by slot.
|
||||
key: ObjectKey(_focusBindings[index]),
|
||||
focusNode: _focusNodes[index],
|
||||
canRequestFocus: enabled,
|
||||
autofocus: action.autofocus && enabled,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/scroll_utils.dart';
|
||||
import 'owned_focus_node_binding.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'dpad_select_long_press_controller.dart';
|
||||
import 'key_event_utils.dart';
|
||||
@@ -92,8 +93,12 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
// Same convention as FocusableTileStateMixin: a chip inside a
|
||||
// scrollable strip (TabChipStrip, filter bars) reveals itself on
|
||||
// focus; a no-op when no ancestor scrollable exists.
|
||||
if (focusNode.hasFocus) scrollContextToCenter(context);
|
||||
// focus; a no-op when no ancestor scrollable exists. Keyboard/D-pad
|
||||
// sessions only — pointer-mode focus is programmatic and invisible,
|
||||
// so revealing it would yank the strip (issue #2031).
|
||||
if (focusNode.hasFocus && InputModeTracker.currentMode == InputMode.keyboard) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,25 +38,14 @@ bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputTy
|
||||
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||
|
||||
enum TvTextInputAutoOpenBehavior {
|
||||
/// Resolve per presentation: [onFirstFocus] for the native tvOS keyboard —
|
||||
/// arriving at a field opens it once, but returning to it during D-pad
|
||||
/// traversal does not, since it is a modal full-screen surface and
|
||||
/// re-raising it on every pass makes a form untraversable. [onFocus] for the
|
||||
/// in-app Flutter overlay, which is cheap, non-modal, and involves no UIKit
|
||||
/// first responder.
|
||||
/// Resolve per presentation: open-on-first-focus for the native tvOS
|
||||
/// keyboard — arriving at a field opens it once, but returning to it during
|
||||
/// D-pad traversal does not, since it is a modal full-screen surface and
|
||||
/// re-raising it on every pass makes a form untraversable. Open-on-focus for
|
||||
/// the in-app Flutter overlay, which is cheap, non-modal, and involves no
|
||||
/// UIKit first responder.
|
||||
automatic,
|
||||
|
||||
/// Open the selected TV text input presentation whenever the field receives
|
||||
/// focus. On Apple TV this raises the system keyboard on every focus entry,
|
||||
/// including plain D-pad traversal — prefer [automatic] unless the field is
|
||||
/// the sole purpose of its screen.
|
||||
onFocus,
|
||||
|
||||
/// Open on the field's first focus, then stay closed on later focus entries.
|
||||
/// Explicit tap/select still opens it, as does the first focus after a
|
||||
/// focus-node or presentation change.
|
||||
onFirstFocus,
|
||||
|
||||
/// Keep initial focus on the field without opening text input, then open it
|
||||
/// automatically on later focus entries. Explicit tap/select still opens it.
|
||||
afterFirstFocus,
|
||||
@@ -238,9 +227,32 @@ KeyEventResult _handleInputKey({
|
||||
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
|
||||
}
|
||||
|
||||
if (onBack != null && key.isBackKey) {
|
||||
if (event is KeyDownEvent) onBack();
|
||||
return finish(KeyEventResult.handled, 'onBack');
|
||||
if (onBack != null && event.logicalKey.isBackKey) {
|
||||
// On TV the native text-input path can swallow the matching KeyUp (the
|
||||
// closing IME session eats it), so back fires on KeyDown — the same
|
||||
// down-only shape as [handleBackKeyAction]'s Apple TV branch, coordinator
|
||||
// mark included so a parallel back dispatch still dedupes. Elsewhere the
|
||||
// shared handler's KeyUp semantics apply.
|
||||
if (PlatformDetector.isTV()) {
|
||||
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return finish(KeyEventResult.handled, 'onBack');
|
||||
if (event is KeyDownEvent) {
|
||||
BackKeyCoordinator.markHandled();
|
||||
onBack();
|
||||
// onBack may move focus (empty search field -> sidebar); the matching
|
||||
// KeyUp is then delivered to the NEW focus chain, whose shared
|
||||
// handlers act on KeyUp — a second back action. Arm the suppressor
|
||||
// (after onBack, so a modal opened by it cannot clear the arming) so
|
||||
// whichever chain receives the KeyUp swallows it. This cannot pin:
|
||||
// the suppressor's hardware observer clears the armed state once the
|
||||
// physical press ends, and if the IME swallows that KeyUp entirely,
|
||||
// the next back KeyDown is treated as stale arming and passes
|
||||
// through — see _KeyUpSuppressor.
|
||||
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
||||
}
|
||||
return finish(KeyEventResult.handled, 'onBack');
|
||||
}
|
||||
final backResult = handleBackKeyAction(event, onBack);
|
||||
if (backResult != KeyEventResult.ignored) return finish(backResult, 'onBack');
|
||||
}
|
||||
|
||||
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
|
||||
@@ -906,21 +918,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
// field should still open it — otherwise typing always costs two
|
||||
// presses — but re-raising it every time D-pad traversal passes back
|
||||
// over the field makes a multi-field form unusable, so `automatic`
|
||||
// resolves to `onFirstFocus` there. Android TV's native IME is a docked
|
||||
// opens only on first focus there. Android TV's native IME is a docked
|
||||
// soft keyboard that does not take over the screen, so it keeps the
|
||||
// historical auto-open. Explicit modes stay literal on both: a caller
|
||||
// that asks for onFocus gets onFocus.
|
||||
// historical auto-open.
|
||||
case TvTextInputAutoOpenBehavior.automatic:
|
||||
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
case TvTextInputAutoOpenBehavior.onFirstFocus:
|
||||
if (_hasSeenNativeTextInputFocus) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
case TvTextInputAutoOpenBehavior.onFocus:
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenNativeTextInputFocus) {
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
@@ -1052,11 +1056,6 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
// The Flutter overlay is an in-app, non-modal widget with no UIKit first
|
||||
// responder behind it, so opening it on focus costs nothing.
|
||||
case TvTextInputAutoOpenBehavior.automatic:
|
||||
case TvTextInputAutoOpenBehavior.onFocus:
|
||||
return true;
|
||||
case TvTextInputAutoOpenBehavior.onFirstFocus:
|
||||
if (_hasSeenTvKeyboardFocus) return false;
|
||||
_hasSeenTvKeyboardFocus = true;
|
||||
return true;
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenTvKeyboardFocus) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/scroll_utils.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
import 'owned_focus_node_binding.dart';
|
||||
|
||||
/// Manages the internal/external FocusNode lifecycle for list-tile widgets and
|
||||
@@ -39,7 +40,10 @@ mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (effectiveFocusNode.hasFocus) {
|
||||
// Reveal on focus is keyboard/D-pad-only: pointer-mode focus is
|
||||
// programmatic and invisible, so revealing it would yank the scroll view
|
||||
// (issue #2031).
|
||||
if (effectiveFocusNode.hasFocus && InputModeTracker.currentMode == InputMode.keyboard) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +161,6 @@ class FocusableWrapper extends StatefulWidget {
|
||||
/// Short press triggers [onSelect].
|
||||
final bool enableLongPress;
|
||||
|
||||
/// Duration for long-press detection.
|
||||
final Duration longPressDuration;
|
||||
|
||||
/// Whether to use background color instead of border for focus indicator.
|
||||
/// Useful for video controls where outline doesn't look good.
|
||||
final bool useBackgroundFocus;
|
||||
@@ -223,7 +220,6 @@ class FocusableWrapper extends StatefulWidget {
|
||||
this.canRequestFocus = true,
|
||||
this.onKeyEvent,
|
||||
this.enableLongPress = false,
|
||||
this.longPressDuration = const Duration(milliseconds: 500),
|
||||
this.useBackgroundFocus = false,
|
||||
this.focusColor,
|
||||
this.disableScale = false,
|
||||
@@ -323,8 +319,12 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
_animationController?.reverse();
|
||||
}
|
||||
|
||||
// Auto-scroll into view
|
||||
if (hasFocus && widget.autoScroll) {
|
||||
// Auto-scroll into view. Keyboard/D-pad sessions only: pointer-mode
|
||||
// focus is invisible and only ever parked or restored programmatically
|
||||
// (entry focus targets, a context menu re-focusing its trigger on
|
||||
// close), so revealing it would yank the viewport away from wherever
|
||||
// the user scrolled (issue #2031).
|
||||
if (hasFocus && widget.autoScroll && InputModeTracker.currentMode == InputMode.keyboard) {
|
||||
_scrollIntoView();
|
||||
}
|
||||
|
||||
@@ -458,7 +458,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
if (widget.enableLongPress) {
|
||||
final result = _selectLongPress.handleKeyEvent(
|
||||
event,
|
||||
duration: widget.longPressDuration,
|
||||
isOwnerActive: () => mounted,
|
||||
onShortPress: () => widget.onSelect?.call(),
|
||||
onLongPress: () => widget.onLongPress?.call(),
|
||||
|
||||
@@ -47,6 +47,19 @@ class InputModeTracker extends StatefulWidget {
|
||||
return of(context, listen: listen) == InputMode.keyboard;
|
||||
}
|
||||
|
||||
/// Live input mode, readable without a [BuildContext].
|
||||
///
|
||||
/// Focus-change listeners fire during key dispatch, before the inherited
|
||||
/// provider has rebuilt for a mode flip, so a context read through [of]
|
||||
/// is one frame stale on the first navigation key of a keyboard session.
|
||||
/// The tracker's own state is updated synchronously by its
|
||||
/// [HardwareKeyboard] handler, which runs before focus traversal moves
|
||||
/// focus. Falls back to the platform default when no tracker is mounted.
|
||||
static InputMode get currentMode => _instance?._mode ?? _defaultMode;
|
||||
|
||||
/// Keyboard/D-pad on TV, pointer everywhere else.
|
||||
static InputMode get _defaultMode => PlatformDetector.isTV() ? InputMode.keyboard : InputMode.pointer;
|
||||
|
||||
/// Whether system back must be blocked because the dpad key handler owns
|
||||
/// back navigation: on Android in keyboard mode (TV/gamepad), letting the
|
||||
/// system back through as well double-pops the route.
|
||||
@@ -74,8 +87,7 @@ class InputModeTracker extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
// Default to keyboard mode on Android TV, pointer mode elsewhere
|
||||
InputMode _mode = TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer;
|
||||
InputMode _mode = InputModeTracker._defaultMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -134,7 +146,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
Widget build(BuildContext context) {
|
||||
// Non-desktop TVs keep keyboard mode across synthetic pointer events, but
|
||||
// their controls remain pointer-reachable for engine-generated taps.
|
||||
if (TvDetectionService.isTVSync() && !PlatformDetector.isDesktopOS()) {
|
||||
if (PlatformDetector.isTV() && !PlatformDetector.isDesktopOS()) {
|
||||
return _InputModeProvider(mode: _mode, child: widget.child);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,11 +76,10 @@ KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
|
||||
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
|
||||
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
|
||||
// a non-keyboard device, but the same down-only handling is still required.
|
||||
// The suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here
|
||||
// because onBack typically calls Navigator.pop, swapping the focus tree
|
||||
// before the matching KeyUp is dispatched — the orphaned KeyUp then never
|
||||
// reaches a consumeIfSuppressed call, pinning the suppressor armed and
|
||||
// silently swallowing the next press's KeyDown.
|
||||
// No suppressor arming is needed here: onBack typically calls
|
||||
// Navigator.pop, swapping the focus tree before the matching KeyUp is
|
||||
// dispatched — but the orphaned KeyUp lands on the new chain, where this
|
||||
// same branch consumes it silently.
|
||||
if (PlatformDetector.isAppleTV()) {
|
||||
if (event is KeyDownEvent) {
|
||||
BackKeyCoordinator.markHandled();
|
||||
@@ -246,10 +245,10 @@ class BackKeySuppressorObserver extends NavigatorObserver {
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
|
||||
// so the suppressor isn't needed and arming it would pin state across the
|
||||
// pop's focus-tree swap. (The atomic engine fix delivers KeyDown+KeyUp in
|
||||
// a single recognizer Began callback, so didPop fires squarely inside the
|
||||
// window where BackKeyPressTracker.isBackKeyDown is true.)
|
||||
// so the suppressor isn't needed. (The atomic engine fix delivers
|
||||
// KeyDown+KeyUp in a single recognizer Began callback, so didPop fires
|
||||
// squarely inside the window where BackKeyPressTracker.isBackKeyDown is
|
||||
// true.)
|
||||
if (PlatformDetector.isAppleTV()) return;
|
||||
if (BackKeyPressTracker.isBackKeyDown) {
|
||||
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
||||
|
||||
@@ -32,4 +32,12 @@ class HubFocusMemory {
|
||||
final remembered = _perHubMemory[hubKey];
|
||||
return (remembered ?? fallback).clamp(0, itemCount - 1);
|
||||
}
|
||||
|
||||
/// Rewrite a hub's remembered index in place after its content reordered
|
||||
/// underneath it (a Continue Watching refresh, not user navigation). Unlike
|
||||
/// [setForHub] this never moves the cross-hub column hint, and it is a no-op
|
||||
/// for hubs without memory.
|
||||
void remapForHub(String hubKey, int index) {
|
||||
if (_perHubMemory.containsKey(hubKey)) _perHubMemory[hubKey] = index;
|
||||
}
|
||||
}
|
||||
|
||||
+191
-26
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Səyahətçini istifadə et",
|
||||
"or": "və ya",
|
||||
"connectToMediaBrowser": "${product}-a qoşul",
|
||||
"quickConnect": "Sürətli Qoşulma",
|
||||
"useQuickConnect": "Sürətli Qoşulmanı istifadə et",
|
||||
"quickConnectInstructions": "Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.",
|
||||
"quickConnectWaiting": "Təsdiq gözlənilir…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Səsi söndür",
|
||||
"ok": "Oldu",
|
||||
"off": "Söndürülüb",
|
||||
"options": "Seçimlər",
|
||||
"seasonNumber": "Mövsüm ${number}",
|
||||
"episodeNumberTitle": "Seriya ${number} - ${title}",
|
||||
"chapterNumber": "Hissə ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Film",
|
||||
"show": "Serial",
|
||||
"season": "Sezon",
|
||||
"episode": "Epizod",
|
||||
"artist": "İfaçı",
|
||||
"album": "Albom",
|
||||
"track": "Mahnı",
|
||||
"collection": "Kolleksiya",
|
||||
"playlist": "Pleylist",
|
||||
"clip": "Klip",
|
||||
"photo": "Şəkil",
|
||||
"folder": "Qovluq"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lisenziyalar",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Ekran miqyası",
|
||||
"compact": "Sıx",
|
||||
"comfortable": "Rəhat",
|
||||
"gridSpacing": "Tor aralığı",
|
||||
"gridSpacingTight": "Sıx",
|
||||
"gridSpacingNormal": "Normal",
|
||||
"gridSpacingSpacious": "Geniş",
|
||||
"tvCornerSpotlightBackdrop": "Künc işıqlandırma fonu",
|
||||
"tvCornerSpotlightBackdropDescription": "Arxa fonu ekranı örtmək əvəzinə sağ üst küncdə göstər",
|
||||
"viewMode": "Baxış rejimi",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Avadanlıq kod açılması",
|
||||
"hardwareDecodingDescription": "Mümkün olduqda avadanlıq sürətləndirməsini istifadə et",
|
||||
"bufferSize": "Bufer həcmi",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"bufferSizeAuto": "Avtomatik (Tövsiyə olunan)",
|
||||
"bufferSizeWarning": "${heap}MB yaddaş əlçatandır. ${size}MB bufer oynatmaya təsir edə bilər.",
|
||||
"playbackBuffer": "Oxutma buferi",
|
||||
"playbackBufferAuto": "Avtomatik (tövsiyə olunur)",
|
||||
"playbackBufferLarge": "Böyük",
|
||||
"playbackBufferExtraLarge": "Çox böyük",
|
||||
"playbackBufferDescription": "Qeyri-sabit əlaqələrə qarşı daha çox buferləyir. Bufer ölçüsü ilə də məhdudlaşır.",
|
||||
"defaultQualityTitle": "Defolt keyfiyyət",
|
||||
"cellularQualityTitle": "Mobil şəbəkədə defolt keyfiyyət",
|
||||
"cellularQualitySameAsDefault": "Defolt keyfiyyətlə eyni",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musiqi keyfiyyəti",
|
||||
"subtitleStyling": "Altyazı tənzimləmələri",
|
||||
"subtitleStylingDescription": "Altyazı görünüşünü özünüləşdirin",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Hər məzmun üçün səs və altyazı seçimlərini yadda saxla",
|
||||
"followServerTrackSelections": "Hər epizod üçün serverin trek seçimlərini istifadə et",
|
||||
"followServerTrackSelectionsDescription": "Epizod dəyişəndə cari seçimi köçürmək əvəzinə serverdə seçilmiş səs və altyazını tətbiq et",
|
||||
"resumeMusicOnLaunch": "Musiqi sessiyasını yadda saxla",
|
||||
"resumeMusicOnLaunchDescription": "Tətbiq açılanda son mahnını dayandırıldığı yerdə fasilədə aç",
|
||||
"showChapterMarkersOnTimeline": "Zaman çubuğunda hissə işarələrini göstər",
|
||||
"showChapterMarkersOnTimelineDescription": "Zaman çubuğunu hissə sərhədlərinə böl",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Xüsusi bölmələr epizod sırasına görə",
|
||||
"specialsOrderingDescription": "Xüsusi bölmələrin serialın izləmə sırasındakı yeri",
|
||||
"specialsOrderingServer": "Server sırasını izlə",
|
||||
"specialsOrderingAirDate": "Yayım tarixinə görə qarışdır",
|
||||
"specialsOrderingLast": "Adi mövsümlərdən sonra",
|
||||
"clickVideoTogglesPlayback": "Oynat/fasilə üçün videoya toxun",
|
||||
"clickVideoTogglesPlaybackDescription": "İdarəetməni göstərmək əvəzinə oynatmaq/fasilə etmək üçün videoya toxun.",
|
||||
"videoPlayerControls": "Video oynadıcı idarəetmələri",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Qısayol artıq ${action} üçün təyin edilib",
|
||||
"shortcutUpdated": "${action} üçün qısayol yeniləndi",
|
||||
"saveFailed": "Dəyişikliklər yadda saxlanıla bilmədi. Təzədən cəhd edin.",
|
||||
"autoSkip": "Avtomatik ötür",
|
||||
"autoPlayAndSkip": "Avtomatik oynat və ötür",
|
||||
"autoPlayNextEpisode": "Növbəti seriyanı avtomatik oynat",
|
||||
"autoPlayNextEpisodeDescription": "Bir seriya bitdikdə növbətisini avtomatik başlat",
|
||||
"playNextCountdown": "Növbəti seriya geri sayımı",
|
||||
"playNextCountdownImmediate": "Dərhal oynat",
|
||||
"autoSkipIntro": "Girişi avtomatik ötür",
|
||||
"autoSkipIntroDescription": "Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür",
|
||||
"autoSkipCredits": "Titrləri avtomatik ötür",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Oynatma zamanı tətbiqdən çıxdıqda avtomatik PiP rejiminə keç",
|
||||
"matchContentFrameRate": "Kadr tezliyini uyğunlaşdır",
|
||||
"matchContentFrameRateDescription": "Ekran yenilənmə tezliyini video məzmununa uyğunlaşdır",
|
||||
"matchContentResolution": "Məzmunun görüntü keyfiyyətinə uyğunlaş",
|
||||
"matchContentResolutionDescription": "Ekranı videonun öz görüntü keyfiyyətinə keçirir ki, miqyaslandırmanı televizorunuz etsin. Oxutma zamanı menyular və altyazılar da miqyaslandırılır",
|
||||
"matchRefreshRate": "Yenilənmə tezliyini uyğunlaşdır",
|
||||
"matchRefreshRateDescription": "Tam ekranda ekran yenilənmə tezliyini uyğunlaşdır",
|
||||
"matchDynamicRange": "Dinamik diapazonu uyğunlaşdır",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Daxili DV7-ni məcburi et",
|
||||
"dvConversionDv81Description": "Dolby Vision profile 8.1-ə çevrilməni məcburi et",
|
||||
"dvConversionHevcStripDescription": "Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et",
|
||||
"deinterlace": "Deinterleysinq",
|
||||
"deinterlaceDescription": "Sətirlərarası videodakı darama artefaktlarını aradan qaldır (yalnız mpv oynadıcısı)",
|
||||
"requireProfileSelectionOnOpen": "Açılışda profil soruş",
|
||||
"requireProfileSelectionOnOpenDescription": "Tətbiq hər dəfə açıldıqda profil seçimini göstər",
|
||||
"forceTvMode": "TV rejimini məcburi et",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Plex Discover və qoşulmuş izləmə xidmətlərindəki məzmunla Kəşf et nişanını göstər",
|
||||
"liveTvDefaultFavorites": "Canlı TV-də sevimli kanalları defolt et",
|
||||
"liveTvDefaultFavoritesDescription": "Canlı TV açıldıqda yalnız sevimli kanalları göstər",
|
||||
"general": "Ümumi",
|
||||
"generalDescription": "Dil, başlanğıc və pəncərə davranışı",
|
||||
"languageAndRegion": "Dil və Region",
|
||||
"startup": "Başlanğıc",
|
||||
"display": "Ekran",
|
||||
"libraryAndCards": "Kitabxana və kartlar",
|
||||
"homeScreen": "Ana ekran",
|
||||
"navigation": "Naviqasiya",
|
||||
"window": "Pəncərə",
|
||||
"content": "Məzmun",
|
||||
"liveTv": "Canlı TV",
|
||||
"player": "Oynadıcı",
|
||||
"subtitlesAndConfig": "Altyazılar və konfiqurasiya",
|
||||
"videoAndDisplay": "Video və Ekran",
|
||||
"audio": "Səs",
|
||||
"quality": "Keyfiyyət",
|
||||
"subtitles": "Altyazılar",
|
||||
"seekAndTiming": "Sarğı və vaxt tənzimləməsi",
|
||||
"behavior": "Davranış",
|
||||
"gestures": "Jestlər",
|
||||
"gestureBrightnessSwipe": "Parlaqlıq sürüşdürməsi",
|
||||
"gestureBrightnessSwipeDescription": "Parlaqlığı tənzimləmək üçün sol kənarda yuxarı və ya aşağı sürüşdürün",
|
||||
"gestureVolumeSwipe": "Səs sürüşdürməsi",
|
||||
"gestureVolumeSwipeDescription": "Səsi tənzimləmək üçün sağ kənarda yuxarı və ya aşağı sürüşdürün",
|
||||
"gesturePinchToZoom": "Çimdiklə yaxınlaşdır",
|
||||
"gesturePinchToZoomDescription": "Yaxınlaşdırmaq və ya uzaqlaşdırmaq üçün videoda çimdik hərəkəti edin",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "İdarəetmələr",
|
||||
"rememberPlayerChanges": "Pleyer dəyişikliklərini yadda saxla",
|
||||
"rememberPlayerChangesDescription": "Oxutma zamanı edilən dəyişikliklərin harada saxlanacağı və yenidən tətbiq ediləcəyi",
|
||||
"scopePlaybackSpeed": "Oxutma sürəti",
|
||||
"scopeShaderPreset": "Şader ön ayarı",
|
||||
"scopeAspectRatio": "Tərəf nisbəti",
|
||||
"scopeSyncOffsets": "Audio və subtitr sinxronizasiyası",
|
||||
"playerScopeOff": "Saxlama",
|
||||
"playerScopeGlobal": "Hər yerdə",
|
||||
"playerScopeLibrary": "Kitabxana üzrə",
|
||||
"playerScopeTitle": "Serial və ya film üzrə",
|
||||
"exportDialogTitle": "Plezy tənzimləmələrini ixrac et"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Səs",
|
||||
"subtitlesLabel": "Altyazı",
|
||||
"resetToZero": "0ms-yə sıfırla",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} sonra oynadılır",
|
||||
"playsEarlier": "${label} əvvəl oynadılır",
|
||||
"noOffset": "Ofset yoxdur",
|
||||
"letterbox": "Geniş ekran (Letterbox)",
|
||||
"fillScreen": "Ekrana doldur",
|
||||
"stretch": "Gərmək",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Cihaz PiP rejimini dəstəkləmir",
|
||||
"voSwitchFailed": "PiP üçün video çıxışı dəyişdirilə bilmədi",
|
||||
"failed": "PiP rejimi başladılarkən xəta",
|
||||
"prepareFailed": "PiP rejimi hazırlana bilmədi",
|
||||
"unknown": "Xəta baş verdi: ${error}"
|
||||
},
|
||||
"chapters": "Hissələr",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "${product} qoşulmasını düzəliş et",
|
||||
"editMediaBrowserIntro": "${serverName} üçün URL-lər əlavə edin və ya silin. Plezy əlçatan olan ən aşağı gecikməli URL-i istifadə edəcək."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Hesab tərcihləri",
|
||||
"hubSubtitleSingle": "${account} hesabında saxlanılan səs, altyazı və kitabxana seçimləri",
|
||||
"hubSubtitleMultiple": "${count} hesabda saxlanılan səs, altyazı və kitabxana seçimləri",
|
||||
"pickAccount": "Hər hesab öz tərcihlərini saxlayır. Düzəliş etmək üçün birini seçin.",
|
||||
"storedOnAccount": "Bu seçimlər hesabın özündə saxlanılır, ona görə də ona daxil olan hər tətbiq onlardan istifadə edir — digər cihazlarınızdakı Plezy də daxil olmaqla.",
|
||||
"noAccounts": "Konfiqurasiya ediləcək hesab yoxdur",
|
||||
"noAccountsHint": "Plex-ə daxil olun və ya Jellyfin və ya Emby serverinə qoşulun; o hesabda saxlanılan tərcihlər burada görünəcək.",
|
||||
"unavailable": "Bu hesaba çatmaq olmur",
|
||||
"loadFailed": "Bu tərcihlər yüklənə bilmədi",
|
||||
"noPreference": "Tərcih yoxdur",
|
||||
"notSet": "Təyin edilməyib",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Səs və altyazılar",
|
||||
"libraryDisplay": "Kitabxana",
|
||||
"personalMedia": "Şəxsi media"
|
||||
},
|
||||
"preferredAudioLanguage": "Üstünlük verilən səs dili",
|
||||
"autoSelectAudio": "Səsi dilə görə seç",
|
||||
"autoSelectAudioDescription": "Söndürüldükdə faylın defolt olaraq işarələdiyi səs treki istifadə olunur.",
|
||||
"preferredSubtitleLanguage": "Üstünlük verilən altyazı dili",
|
||||
"subtitleMode": "Altyazıları aç",
|
||||
"subtitleModes": {
|
||||
"none": "Əl ilə seçilmiş",
|
||||
"noneDescription": "Altyazıları heç vaxt özbaşına açma.",
|
||||
"defaultMode": "Trek bayraqlarına əməl et",
|
||||
"defaultModeDescription": "Hər altyazı trekində saxlanılan defolt və məcburi bayraqları istifadə et.",
|
||||
"always": "Həmişə aktivdir",
|
||||
"alwaysDescription": "Üstünlük verilən dildə altyazı treki mövcuddursa, onu aç.",
|
||||
"onlyForced": "Yalnız məcburi altyazılar",
|
||||
"onlyForcedDescription": "Yalnız məcburi işarələnmiş trekləri yüklə.",
|
||||
"smart": "Xarici səs olduqda göstərilən",
|
||||
"smartDescription": "Altyazıları yalnız səs başqa dildə olduqda aç."
|
||||
},
|
||||
"subtitleAccessibility": "SDH altyazıları",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "SDH olmayan altyazılara üstünlük ver",
|
||||
"preferSdh": "SDH altyazılarına üstünlük ver",
|
||||
"onlySdh": "Yalnız SDH altyazıları",
|
||||
"onlyNonSdh": "Yalnız SDH olmayan altyazılar"
|
||||
},
|
||||
"forcedSubtitles": "Məcburi altyazılar",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Məcburi olmayan altyazılara üstünlük ver",
|
||||
"preferForced": "Məcburi altyazılara üstünlük ver",
|
||||
"onlyForced": "Yalnız məcburi altyazılar",
|
||||
"onlyNonForced": "Yalnız məcburi olmayan altyazılar"
|
||||
},
|
||||
"displayMissingEpisodes": "Çatışmayan seriyaları göstər",
|
||||
"displayMissingEpisodesDescription": "Serverin bildiyi, lakin faylı olmayan seriyaları siyahıya al.",
|
||||
"hidePlayedInLatest": "Baxılmış elementləri Son əlavə olunanlarda gizlət",
|
||||
"hidePlayedInLatestDescription": "Artıq baxdığınız elementləri serverin Son əlavə olunanlar sətirlərində göstərmə.",
|
||||
"displayCollectionsView": "Kolleksiyalar görünüşünü göstər",
|
||||
"displayCollectionsViewDescription": "Kitabxanalarınızla yanaşı serverin Kolleksiyalar görünüşünü də təklif et.",
|
||||
"rewatchingInNextUp": "Təkrar baxılan serialları Sırada saxla",
|
||||
"rewatchingInNextUpDescription": "Bir serialı bitirdikdən sonra yenidən başlasanız, Sırada serialı atmaq əvəzinə təkrar baxışı izləyir.",
|
||||
"watchedIndicator": "Baxıldı göstəriciləri",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Heç vaxt",
|
||||
"moviesAndShows": "Kinolar və TV şoular",
|
||||
"movies": "Yalnız kinolar",
|
||||
"shows": "Yalnız TV şoular"
|
||||
},
|
||||
"mediaReviewsVisibility": "Reytinq və rəylər",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "İstifadəçilər və tənqidçilər",
|
||||
"usersOnly": "Yalnız istifadəçilər",
|
||||
"criticsOnly": "Yalnız tənqidçilər",
|
||||
"nobody": "Gizlədilib"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Kəşf et",
|
||||
"noContentAvailable": "Məzmun əlçatan deyil",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "${username} (${email}) üçün server tapılmadı",
|
||||
"failedToLoadServers": "Serverlər yüklənə bilmədi: ${error}"
|
||||
"failedToLoadServers": "Serverlər yüklənə bilmədi: ${error}",
|
||||
"noValidServers": "Bu hesabda istifadəyə yararlı server tapılmadı"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Başlıq",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} populyar",
|
||||
"rankAiring": "#${n} yayımlanır",
|
||||
"rankRated": "#${n} qiymətləndirilib",
|
||||
"rankFavorited": "#${n} favorit",
|
||||
"rankTrending": "#${n} trenddə",
|
||||
"rankSeasonal": "${season} üçün #${n}",
|
||||
"watchingNow": "${n} izləyir",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Bilinməyən kanal",
|
||||
"live": "CANLI",
|
||||
"reloadGuide": "Bələdçini yenilə",
|
||||
"searchGuide": "Bələdçidə axtar",
|
||||
"searchHint": "Kanal və proqram axtar",
|
||||
"searchNoResults": "\"${query}\" üçün uyğunluq tapılmadı",
|
||||
"channelsSection": "Kanallar",
|
||||
"programsSection": "Proqramlar",
|
||||
"now": "İndi",
|
||||
"today": "Bu gün",
|
||||
"tomorrow": "Sabah",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Bələdçi yenilənməsi tələb olundu",
|
||||
"rulesProcessRequested": "Qaydaların yenidən qiymətləndirilməsi tələb olundu",
|
||||
"recordShow": "Şounu yaz",
|
||||
"recordSettings": {
|
||||
"startEarly": "Erkən başla (saniyə)",
|
||||
"endLate": "Gec bitir (saniyə)",
|
||||
"newOnly": "Yalnız yeni epizodlar",
|
||||
"anyChannel": "İstənilən kanalda yaz",
|
||||
"anyTime": "İstənilən vaxt yaz",
|
||||
"skipInLibrary": "Kitabxanada artıq olan epizodları ötür",
|
||||
"keepUpTo": "Saxlanılacaq epizodlar",
|
||||
"keepUpToHint": "0 bütün epizodları saxlayır"
|
||||
},
|
||||
"startingInMinutes": "${minutes} dəq sonra başlayır",
|
||||
"dayAtTime": "${day}, saat ${time}",
|
||||
"invalidPlaybackData": "${product} etibarsız Canlı TV oynatma məlumatı qaytardı",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Hamısını təkrarla",
|
||||
"repeatOne": "Birini təkrarla",
|
||||
"instantMixNoServer": "Ani miks üçün heç bir server mövcud deyil",
|
||||
"noAudioUrl": "${track} üçün səs URL-i mövcud deyil"
|
||||
"instantMixFailed": "Anında qarışıq yüklənə bilmədi",
|
||||
"instantMixEmpty": "Anında qarışıq heç bir mahnı qaytarmadı",
|
||||
"noAudioUrl": "${track} üçün səs URL-i mövcud deyil",
|
||||
"discography": {
|
||||
"singlesAndEps": "Single-lar və EP-lər",
|
||||
"live": "Canlı",
|
||||
"compilations": "Kompilyasiyalar"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Birlikdə İzlə",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Təşkilatçı",
|
||||
"hostBadge": "TƏŞKİLATÇI",
|
||||
"youAreHost": "Təşkilatçı sizsiniz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Başqaları ilə izlənilir",
|
||||
"endSession": "Seansı bitir",
|
||||
"leaveSession": "Seansdan çıx",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Sil",
|
||||
"guestSwitchUnavailable": "Keçid etmək olmadı — eyniləşdirmə üçün server əlçatan deyil",
|
||||
"guestSwitchFailed": "Keçid etmək olmadı — məzmun tapılmadı",
|
||||
"defaultDisplayName": "İstifadəçi"
|
||||
"defaultDisplayName": "İstifadəçi",
|
||||
"errors": {
|
||||
"timedOut": "Rele serveri vaxtında cavab vermədi",
|
||||
"connectionLost": "Bağlantı seans hazır olmamış kəsildi",
|
||||
"invalidRelayResponse": "Rele serveri gözlənilməz cavab göndərdi",
|
||||
"sessionEnded": "Təşkilatçı seansı bitirdi"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Yükləmələr",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Bu tətbiqi idarə etmək üçün mobil cihazınızı istifadə edin",
|
||||
"startServer": "Serveri başlat",
|
||||
"stopServer": "Serveri dayandır",
|
||||
"minimize": "Yığ"
|
||||
"minimize": "Yığ",
|
||||
"manualAddressHint": "Əl ilə bağlantı ünvanı:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Eyni Plex hesabına sahib Plezy cihazları burada görünür",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Çözücü",
|
||||
"rawDecoder": "Xam çözücü",
|
||||
"tunneling": "Tünelləmə",
|
||||
"passthrough": "Birbaşa ötürmə",
|
||||
"aspect": "Nisbət",
|
||||
"rotation": "Dönmə",
|
||||
"dvSource": "DV mənbəyi",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Prodüser",
|
||||
"country": "Ölkə",
|
||||
"collection": "Kolleksiya",
|
||||
"label": "Etiket",
|
||||
"style": "Stil",
|
||||
"mood": "Əhval-ruhiyyə"
|
||||
"label": "Etiket"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Uyğunlaşdır...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Hissəvi əlçatandır",
|
||||
"statusRequested": "Sorğu göndərildi",
|
||||
"statusProcessing": "Emal edilir",
|
||||
"statusBlocklisted": "Bloklanmış",
|
||||
"couldNotReach": "${url} ünvanına çatmaq olmadı: ${error}",
|
||||
"noInstanceAtUrl": "${url} ünvanında Seerr instansiyası yoxdur (HTTP ${status})",
|
||||
"invalidUrl": "https://seerr.example.com kimi server ünvanı daxil edin",
|
||||
"quickConnectUnsupported": "Bu Seerr nüsxəsi Sürətli Qoşulmanı dəstəkləmir. Seerr 3.4 və ya daha yeni versiya tələb olunur.",
|
||||
"notInitialized": "Bu Seerr instansiyasının ilkin quraşdırılması tamamlanmayıb",
|
||||
"noPlexTokenForReauth": "Yenidən daxil olmaq üçün Plex tokeni mövcud deyil",
|
||||
"noStoredCredentials": "Yenidən daxil olmaq üçün yadda saxlanmış giriş məlumatları mövcud deyil",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Xidmətlər",
|
||||
"hubSubtitle": "İzləmə tərəqqisini eyniləşdirin və yeni başlıqlar sorğulayın.",
|
||||
"integrations": "İnteqrasiyalar",
|
||||
"notConnected": "Qoşulmayıb",
|
||||
"connectedAs": "@${username} olaraq qoşuldu",
|
||||
"scrobble": "Tərəqqini avtomatik izlə",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Başqa profilin qoşulmasını yenidən istifadə edin.",
|
||||
"invalidCredentials": "İstifadəçi adı və ya şifrə yanlışdır",
|
||||
"authResponseNotJson": "Autentifikasiya cavabı etibarlı JSON deyildi",
|
||||
"authResponseIncomplete": "Serverin giriş cavabı natamam idi",
|
||||
"quickConnectRejected": "Quick Connect server tərəfindən rədd edildi",
|
||||
"quickConnectNotJson": "Quick Connect cavabı etibarlı JSON deyildi",
|
||||
"quickConnectMissingFields": "Quick Connect cavabında kod və ya məxfi açar yoxdur",
|
||||
|
||||
+191
-26
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Използвай браузър",
|
||||
"or": "или",
|
||||
"connectToMediaBrowser": "Свържи се с ${product}",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Използвай Quick Connect",
|
||||
"quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.",
|
||||
"quickConnectWaiting": "Изчакване на одобрение…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Заглуши",
|
||||
"ok": "OK",
|
||||
"off": "Изкл.",
|
||||
"options": "Опции",
|
||||
"seasonNumber": "Сезон ${number}",
|
||||
"episodeNumberTitle": "Епизод ${number} - ${title}",
|
||||
"chapterNumber": "Глава ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "Н/Д",
|
||||
"url": "URL",
|
||||
"letterKeys": "АБВ"
|
||||
"letterKeys": "АБВ",
|
||||
"mediaKind": {
|
||||
"movie": "Филм",
|
||||
"show": "Сериал",
|
||||
"season": "Сезон",
|
||||
"episode": "Епизод",
|
||||
"artist": "Изпълнител",
|
||||
"album": "Албум",
|
||||
"track": "Песен",
|
||||
"collection": "Колекция",
|
||||
"playlist": "Плейлист",
|
||||
"clip": "Клип",
|
||||
"photo": "Снимка",
|
||||
"folder": "Папка"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Лицензи",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Мащаб на дисплея",
|
||||
"compact": "Компактна",
|
||||
"comfortable": "Удобна",
|
||||
"gridSpacing": "Разстояние на мрежата",
|
||||
"gridSpacingTight": "Плътно",
|
||||
"gridSpacingNormal": "Нормално",
|
||||
"gridSpacingSpacious": "Просторно",
|
||||
"tvCornerSpotlightBackdrop": "Фон с акцент в ъгъла",
|
||||
"tvCornerSpotlightBackdropDescription": "Показвай акцентното изображение в горния десен ъгъл, вместо на целия екран",
|
||||
"viewMode": "Режим на изглед",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Хардуерно декодиране",
|
||||
"hardwareDecodingDescription": "Използвай хардуерно ускорение, когато е налично",
|
||||
"bufferSize": "Размер на буфера",
|
||||
"bufferSizeMB": "${size} MB",
|
||||
"bufferSizeAuto": "Автоматично (препоръчително)",
|
||||
"bufferSizeWarning": "Налична памет: ${heap} MB. Буфер от ${size} MB може да повлияе на възпроизвеждането.",
|
||||
"playbackBuffer": "Буфер за възпроизвеждане",
|
||||
"playbackBufferAuto": "Автоматично (препоръчително)",
|
||||
"playbackBufferLarge": "Голям",
|
||||
"playbackBufferExtraLarge": "Изключително голям",
|
||||
"playbackBufferDescription": "Буферира повече при нестабилни връзки. Също ограничен от размера на буфера.",
|
||||
"defaultQualityTitle": "Качество по подразбиране",
|
||||
"cellularQualityTitle": "Качество по подразбиране при мобилни данни",
|
||||
"cellularQualitySameAsDefault": "Същото като качеството по подразбиране",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Качество на музиката",
|
||||
"subtitleStyling": "Стил на субтитрите",
|
||||
"subtitleStylingDescription": "Настройване на вида на субтитрите",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Запомняй избора на аудиопътечка и субтитри за всяко заглавие",
|
||||
"followServerTrackSelections": "Използвай избора на пътечки от сървъра за всеки епизод",
|
||||
"followServerTrackSelectionsDescription": "При смяна на епизода прилагай избраните на сървъра аудио и субтитри, вместо да се пренася текущият избор",
|
||||
"resumeMusicOnLaunch": "Запомняне на музикалната сесия",
|
||||
"resumeMusicOnLaunchDescription": "При стартиране на приложението отваряй последната песен на пауза от мястото, докъдето е стигнала",
|
||||
"showChapterMarkersOnTimeline": "Показвай маркери на глави върху времевата линия",
|
||||
"showChapterMarkersOnTimelineDescription": "Разделяй времевата линия на сегменти по границите на главите",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Специални епизоди в реда на епизодите",
|
||||
"specialsOrderingDescription": "Къде се възпроизвеждат специалните епизоди в реда за гледане на сериала",
|
||||
"specialsOrderingServer": "Следвай реда на сървъра",
|
||||
"specialsOrderingAirDate": "Подреждай по дата на излъчване",
|
||||
"specialsOrderingLast": "След редовните сезони",
|
||||
"clickVideoTogglesPlayback": "Клик върху видеото превключва възпроизвеждане/пауза",
|
||||
"clickVideoTogglesPlaybackDescription": "Клик върху видеото пуска/паузира вместо да показва контролите.",
|
||||
"videoPlayerControls": "Контроли на видео плейъра",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Клавишната комбинация вече е назначена за ${action}",
|
||||
"shortcutUpdated": "Клавишната комбинация е обновена за ${action}",
|
||||
"saveFailed": "Промените не можаха да бъдат запазени. Опитайте отново.",
|
||||
"autoSkip": "Автоматично прескачане",
|
||||
"autoPlayAndSkip": "Автоматично пускане и прескачане",
|
||||
"autoPlayNextEpisode": "Автоматично пускане на следващия епизод",
|
||||
"autoPlayNextEpisodeDescription": "Пускай следващия епизод автоматично, когато текущият свърши",
|
||||
"playNextCountdown": "Отброяване до следващия епизод",
|
||||
"playNextCountdownImmediate": "Пусни веднага",
|
||||
"autoSkipIntro": "Автоматично прескачане на интро",
|
||||
"autoSkipIntroDescription": "Автоматично прескачай интро маркери след няколко секунди",
|
||||
"autoSkipCredits": "Автоматично прескачане на финални надписи",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Автоматично включвай режима картина в картината при излизане от приложението по време на възпроизвеждане",
|
||||
"matchContentFrameRate": "Напасване към кадровата честота на съдържанието",
|
||||
"matchContentFrameRateDescription": "Напасни честотата на опресняване на дисплея към видео съдържанието",
|
||||
"matchContentResolution": "Съобразяване с разделителната способност на съдържанието",
|
||||
"matchContentResolutionDescription": "Превключва дисплея към собствената разделителна способност на видеото, за да се погрижи телевизорът за мащабирането. По време на възпроизвеждане менютата и субтитрите също се мащабират",
|
||||
"matchRefreshRate": "Напасване на честотата на опресняване",
|
||||
"matchRefreshRateDescription": "Напасни честотата на опресняване на дисплея при цял екран",
|
||||
"matchDynamicRange": "Напасване на динамичния диапазон",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване",
|
||||
"dvConversionDv81Description": "Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1",
|
||||
"dvConversionHevcStripDescription": "Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток",
|
||||
"deinterlace": "Деинтерлейсинг",
|
||||
"deinterlaceDescription": "Премахва гребеновидните артефакти от интерлейсирано видео (само за mpv плейъра)",
|
||||
"requireProfileSelectionOnOpen": "Питай за профил при отваряне на приложението",
|
||||
"requireProfileSelectionOnOpenDescription": "Показвай избор на профил всеки път при отваряне на приложението",
|
||||
"forceTvMode": "Принуди TV режим",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Показва раздела „Открий“ със съдържание от Plex Discover и свързаните тракери",
|
||||
"liveTvDefaultFavorites": "По подразбиране към любими канали",
|
||||
"liveTvDefaultFavoritesDescription": "Показвай само любими канали при отваряне на телевизия на живо",
|
||||
"general": "Общи",
|
||||
"generalDescription": "Език, стартиране и поведение на прозореца",
|
||||
"languageAndRegion": "Език и регион",
|
||||
"startup": "Стартиране",
|
||||
"display": "Дисплей",
|
||||
"libraryAndCards": "Библиотека и карти",
|
||||
"homeScreen": "Начален екран",
|
||||
"navigation": "Навигация",
|
||||
"window": "Прозорец",
|
||||
"content": "Съдържание",
|
||||
"liveTv": "Телевизия на живо",
|
||||
"player": "Плейър",
|
||||
"subtitlesAndConfig": "Субтитри и конфигурация",
|
||||
"videoAndDisplay": "Видео и дисплей",
|
||||
"audio": "Аудио",
|
||||
"quality": "Качество",
|
||||
"subtitles": "Субтитри",
|
||||
"seekAndTiming": "Търсене и време",
|
||||
"behavior": "Поведение",
|
||||
"gestures": "Жестове",
|
||||
"gestureBrightnessSwipe": "Плъзгане за яркост",
|
||||
"gestureBrightnessSwipeDescription": "Плъзни нагоре или надолу по левия ръб, за да регулираш яркостта",
|
||||
"gestureVolumeSwipe": "Плъзгане за сила на звука",
|
||||
"gestureVolumeSwipeDescription": "Плъзни нагоре или надолу по десния ръб, за да регулираш силата на звука",
|
||||
"gesturePinchToZoom": "Стискане за мащабиране",
|
||||
"gesturePinchToZoomDescription": "Стисни видеото, за да увеличиш или намалиш мащаба",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Контроли",
|
||||
"rememberPlayerChanges": "Запомняне на промените в плейъра",
|
||||
"rememberPlayerChangesDescription": "Къде се записва и откъде се прилага отново промяна, направена по време на възпроизвеждане",
|
||||
"scopePlaybackSpeed": "Скорост на възпроизвеждане",
|
||||
"scopeShaderPreset": "Предварителна настройка на шейдъра",
|
||||
"scopeAspectRatio": "Съотношение на страните",
|
||||
"scopeSyncOffsets": "Синхронизация на аудио и субтитри",
|
||||
"playerScopeOff": "Не запазвай",
|
||||
"playerScopeGlobal": "Навсякъде",
|
||||
"playerScopeLibrary": "По библиотека",
|
||||
"playerScopeTitle": "По сериал или филм",
|
||||
"exportDialogTitle": "Експортиране на настройките на Plezy"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Аудио",
|
||||
"subtitlesLabel": "Субтитри",
|
||||
"resetToZero": "Нулирай до 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} се възпроизвежда по-късно",
|
||||
"playsEarlier": "${label} се възпроизвежда по-рано",
|
||||
"noOffset": "Без отместване",
|
||||
"letterbox": "Черни ленти",
|
||||
"fillScreen": "Запълни екрана",
|
||||
"stretch": "Разтегни",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Устройството не поддържа режим картина в картината",
|
||||
"voSwitchFailed": "Неуспешна смяна на видео изхода за режим картина в картината",
|
||||
"failed": "Режимът картина в картината не успя да стартира",
|
||||
"prepareFailed": "Режимът картина в картината не можа да бъде подготвен",
|
||||
"unknown": "Възникна грешка: ${error}"
|
||||
},
|
||||
"chapters": "Глави",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Редактирай връзката с ${product}",
|
||||
"editMediaBrowserIntro": "Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Предпочитания на акаунта",
|
||||
"hubSubtitleSingle": "Опции за аудио, субтитри и библиотека, запазени на ${account}",
|
||||
"hubSubtitleMultiple": "Опции за аудио, субтитри и библиотека, запазени на ${count} акаунта",
|
||||
"pickAccount": "Всеки акаунт съхранява собствени предпочитания. Изберете кой да редактирате.",
|
||||
"storedOnAccount": "Тези опции се запазват на самия акаунт, така че всяко приложение, вписано в него, ги използва — включително Plezy на другите ви устройства.",
|
||||
"noAccounts": "Няма акаунти за настройка",
|
||||
"noAccountsHint": "Влезте в Plex или свържете Jellyfin или Emby сървър и предпочитанията, запазени на този акаунт, ще се появят тук.",
|
||||
"unavailable": "Акаунтът не може да бъде достигнат",
|
||||
"loadFailed": "Предпочитанията не можаха да бъдат заредени",
|
||||
"noPreference": "Без предпочитание",
|
||||
"notSet": "Не е зададено",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Аудио и субтитри",
|
||||
"libraryDisplay": "Библиотека",
|
||||
"personalMedia": "Лична медия"
|
||||
},
|
||||
"preferredAudioLanguage": "Предпочитан аудио език",
|
||||
"autoSelectAudio": "Избирай аудио по език",
|
||||
"autoSelectAudioDescription": "При изключено се запазва аудиопътечката, която файлът маркира като подразбираща се.",
|
||||
"preferredSubtitleLanguage": "Предпочитан език за субтитри",
|
||||
"subtitleMode": "Включване на субтитри",
|
||||
"subtitleModes": {
|
||||
"none": "Ръчно избрани",
|
||||
"noneDescription": "Никога не включвай субтитри автоматично.",
|
||||
"defaultMode": "Следвай флаговете на пътечката",
|
||||
"defaultModeDescription": "Използвай флаговете по подразбиране и принудителните, съхранени на всяка пътечка със субтитри.",
|
||||
"always": "Винаги включени",
|
||||
"alwaysDescription": "Включвай пътечка със субтитри на предпочитания език, когато има такава.",
|
||||
"onlyForced": "Само принудителни субтитри",
|
||||
"onlyForcedDescription": "Зареждай само пътечките, маркирани като принудителни.",
|
||||
"smart": "Показване при чуждоезично аудио",
|
||||
"smartDescription": "Включвай субтитри само когато аудиото е на друг език."
|
||||
},
|
||||
"subtitleAccessibility": "SDH субтитри",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Предпочитай субтитри без SDH",
|
||||
"preferSdh": "Предпочитай SDH субтитри",
|
||||
"onlySdh": "Само SDH субтитри",
|
||||
"onlyNonSdh": "Само субтитри без SDH"
|
||||
},
|
||||
"forcedSubtitles": "Принудителни субтитри",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Предпочитай субтитри без принудителни",
|
||||
"preferForced": "Предпочитай принудителни субтитри",
|
||||
"onlyForced": "Само принудителни субтитри",
|
||||
"onlyNonForced": "Само субтитри без принудителни"
|
||||
},
|
||||
"displayMissingEpisodes": "Показвай липсващи епизоди",
|
||||
"displayMissingEpisodesDescription": "Изброявай епизоди, за които сървърът знае, но няма файл.",
|
||||
"hidePlayedInLatest": "Скривай изгледаните елементи в „Последни“",
|
||||
"hidePlayedInLatestDescription": "Не включвай вече изгледаните елементи в редовете „Последни“ на сървъра.",
|
||||
"displayCollectionsView": "Показвай изгледа „Колекции“",
|
||||
"displayCollectionsViewDescription": "Предлагай изгледа „Колекции“ на сървъра редом с библиотеките ви.",
|
||||
"rewatchingInNextUp": "Запазвай повторно гледаните сериали в „Следва“",
|
||||
"rewatchingInNextUpDescription": "Когато завършите сериал и го пуснете отново, „Следва“ проследява повторното гледане, вместо да премахва сериала.",
|
||||
"watchedIndicator": "Индикатори за изгледано",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Никога",
|
||||
"moviesAndShows": "Филми и ТВ сериали",
|
||||
"movies": "Само филми",
|
||||
"shows": "Само ТВ сериали"
|
||||
},
|
||||
"mediaReviewsVisibility": "Оценки и ревюта",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Потребители и критици",
|
||||
"usersOnly": "Само потребители",
|
||||
"criticsOnly": "Само критици",
|
||||
"nobody": "Скрити"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Открий",
|
||||
"noContentAvailable": "Няма налично съдържание",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Не са намерени сървъри за ${username} (${email})",
|
||||
"failedToLoadServers": "Неуспешно зареждане на сървъри: ${error}"
|
||||
"failedToLoadServers": "Неуспешно зареждане на сървъри: ${error}",
|
||||
"noValidServers": "Не бяха намерени използваеми сървъри в този акаунт"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Заглавие",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} популярен",
|
||||
"rankAiring": "#${n} в ефир",
|
||||
"rankRated": "#${n} оценен",
|
||||
"rankFavorited": "#${n} най-харесван",
|
||||
"rankTrending": "#${n} в тенденция",
|
||||
"rankSeasonal": "#${n} през ${season}",
|
||||
"watchingNow": "${n} гледат",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Неизвестен канал",
|
||||
"live": "НА ЖИВО",
|
||||
"reloadGuide": "Презареди ТВ програмата",
|
||||
"searchGuide": "Търсене в програмата",
|
||||
"searchHint": "Търсене на канали и предавания",
|
||||
"searchNoResults": "Няма съвпадения за \"${query}\"",
|
||||
"channelsSection": "Канали",
|
||||
"programsSection": "Предавания",
|
||||
"now": "Сега",
|
||||
"today": "Днес",
|
||||
"tomorrow": "Утре",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Заявено е опресняване на ТВ програмата",
|
||||
"rulesProcessRequested": "Заявена е преоценка на правилата",
|
||||
"recordShow": "Запиши предаването",
|
||||
"recordSettings": {
|
||||
"startEarly": "Започване по-рано (секунди)",
|
||||
"endLate": "Приключване по-късно (секунди)",
|
||||
"newOnly": "Само нови епизоди",
|
||||
"anyChannel": "Записване от всеки канал",
|
||||
"anyTime": "Записване по всяко време",
|
||||
"skipInLibrary": "Пропускане на епизоди, които вече са в библиотеката",
|
||||
"keepUpTo": "Епизоди за запазване",
|
||||
"keepUpToHint": "0 запазва всички епизоди"
|
||||
},
|
||||
"startingInMinutes": "Започва след ${minutes} мин",
|
||||
"dayAtTime": "${day} в ${time}",
|
||||
"invalidPlaybackData": "${product} върна невалидни данни за възпроизвеждане на телевизия на живо",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Повтаряне на всички",
|
||||
"repeatOne": "Повтаряне на една",
|
||||
"instantMixNoServer": "Няма наличен сървър за незабавен микс",
|
||||
"noAudioUrl": "Няма наличен URL за аудиото на ${track}"
|
||||
"instantMixFailed": "Мигновеният микс не можа да бъде зареден",
|
||||
"instantMixEmpty": "Мигновеният микс не върна песни",
|
||||
"noAudioUrl": "Няма наличен URL за аудиото на ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Сингли и EP",
|
||||
"live": "Концертни",
|
||||
"compilations": "Компилации"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Гледане заедно",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Организатор",
|
||||
"hostBadge": "ОРГАНИЗАТОР",
|
||||
"youAreHost": "Вие сте организаторът",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Гледате с други",
|
||||
"endSession": "Край на сесията",
|
||||
"leaveSession": "Напусни сесията",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Премахни",
|
||||
"guestSwitchUnavailable": "Превключването не е възможно — сървърът е недостъпен за синхронизация",
|
||||
"guestSwitchFailed": "Превключването не е възможно — съдържанието не е намерено на този сървър",
|
||||
"defaultDisplayName": "Потребител"
|
||||
"defaultDisplayName": "Потребител",
|
||||
"errors": {
|
||||
"timedOut": "Релейният сървър не отговори навреме",
|
||||
"connectionLost": "Връзката се затвори, преди сесията да е готова",
|
||||
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
|
||||
"sessionEnded": "Организаторът прекрати сесията"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Изтегляния",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Използвайте мобилното си устройство, за да управлявате това приложение",
|
||||
"startServer": "Стартирай сървър",
|
||||
"stopServer": "Спри сървър",
|
||||
"minimize": "Минимизирай"
|
||||
"minimize": "Минимизирай",
|
||||
"manualAddressHint": "Ръчен адрес за връзка:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Plezy устройства със същия Plex акаунт се показват тук",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Декодер",
|
||||
"rawDecoder": "Суров декодер",
|
||||
"tunneling": "Тунелиране",
|
||||
"passthrough": "Директно предаване",
|
||||
"aspect": "Съотношение",
|
||||
"rotation": "Завъртане",
|
||||
"dvSource": "DV източник",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Продуцент",
|
||||
"country": "Държава",
|
||||
"collection": "Колекция",
|
||||
"label": "Етикет",
|
||||
"style": "Стил",
|
||||
"mood": "Настроение"
|
||||
"label": "Етикет"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Съпостави...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Частично налично",
|
||||
"statusRequested": "Заявено",
|
||||
"statusProcessing": "Обработва се",
|
||||
"statusBlocklisted": "В списъка с блокирани",
|
||||
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
|
||||
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
|
||||
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
|
||||
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
|
||||
"noPlexTokenForReauth": "Няма наличен Plex токен за повторен вход",
|
||||
"noStoredCredentials": "Няма запазени данни за повторен вход",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Услуги",
|
||||
"hubSubtitle": "Синхронизирай прогреса на гледане и заявявай нови заглавия.",
|
||||
"integrations": "Интеграции",
|
||||
"notConnected": "Няма връзка",
|
||||
"connectedAs": "Свързан като @${username}",
|
||||
"scrobble": "Проследявай прогреса автоматично",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Използвай връзка от друг профил. PIN-защитените профили изискват PIN.",
|
||||
"invalidCredentials": "Невалидно потребителско име или парола",
|
||||
"authResponseNotJson": "Отговорът при удостоверяване не беше валиден JSON",
|
||||
"authResponseIncomplete": "Отговорът за вход от сървъра беше непълен",
|
||||
"quickConnectRejected": "Quick Connect беше отхвърлен от сървъра",
|
||||
"quickConnectNotJson": "Отговорът на Quick Connect не беше валиден JSON",
|
||||
"quickConnectMissingFields": "В отговора на Quick Connect липсва код или таен ключ",
|
||||
|
||||
+191
-26
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Brug browseren",
|
||||
"or": "eller",
|
||||
"connectToMediaBrowser": "Opret forbindelse til ${product}",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Brug Quick Connect",
|
||||
"quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.",
|
||||
"quickConnectWaiting": "Venter på godkendelse…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Lydløs",
|
||||
"ok": "OK",
|
||||
"off": "Fra",
|
||||
"options": "Valgmuligheder",
|
||||
"seasonNumber": "Sæson ${number}",
|
||||
"episodeNumberTitle": "Episode ${number} – ${title}",
|
||||
"chapterNumber": "Kapitel ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Film",
|
||||
"show": "Serie",
|
||||
"season": "Sæson",
|
||||
"episode": "Afsnit",
|
||||
"artist": "Kunstner",
|
||||
"album": "Album",
|
||||
"track": "Nummer",
|
||||
"collection": "Samling",
|
||||
"playlist": "Afspilningsliste",
|
||||
"clip": "Klip",
|
||||
"photo": "Foto",
|
||||
"folder": "Mappe"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenser",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Skalering",
|
||||
"compact": "Kompakt",
|
||||
"comfortable": "Komfortabel",
|
||||
"gridSpacing": "Gitterafstand",
|
||||
"gridSpacingTight": "Tæt",
|
||||
"gridSpacingNormal": "Normal",
|
||||
"gridSpacingSpacious": "Rummelig",
|
||||
"tvCornerSpotlightBackdrop": "Fremhævet baggrundsbillede i hjørnet",
|
||||
"tvCornerSpotlightBackdropDescription": "Vis fremhævet grafik i øverste højre hjørne i stedet for at fylde skærmen",
|
||||
"viewMode": "Visningstilstand",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Hardwaredekodning",
|
||||
"hardwareDecodingDescription": "Brug hardwareacceleration, når den er tilgængelig",
|
||||
"bufferSize": "Bufferstørrelse",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"bufferSizeAuto": "Automatisk (anbefalet)",
|
||||
"bufferSizeWarning": "${heap} MB hukommelse tilgængelig. En buffer på ${size} MB kan påvirke afspilningen.",
|
||||
"playbackBuffer": "Afspilningsbuffer",
|
||||
"playbackBufferAuto": "Auto (anbefalet)",
|
||||
"playbackBufferLarge": "Stor",
|
||||
"playbackBufferExtraLarge": "Ekstra stor",
|
||||
"playbackBufferDescription": "Bufrer mere mod ustabile forbindelser. Begrænses også af bufferstørrelsen.",
|
||||
"defaultQualityTitle": "Standardkvalitet",
|
||||
"cellularQualityTitle": "Standardkvalitet på mobildata",
|
||||
"cellularQualitySameAsDefault": "Samme som standardkvalitet",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musikkvalitet",
|
||||
"subtitleStyling": "Undertekststil",
|
||||
"subtitleStylingDescription": "Tilpas underteksters udseende",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Husk valget af lyd og undertekster for hver titel",
|
||||
"followServerTrackSelections": "Brug serverens sporvalg for hvert afsnit",
|
||||
"followServerTrackSelectionsDescription": "Ved afsnitsskift anvendes lyden og underteksterne valgt på serveren i stedet for at videreføre det aktuelle valg",
|
||||
"resumeMusicOnLaunch": "Husk musiksession",
|
||||
"resumeMusicOnLaunchDescription": "Åbn den seneste sang på pause der, hvor den slap, når appen starter",
|
||||
"showChapterMarkersOnTimeline": "Vis kapitelmarkører på tidslinjen",
|
||||
"showChapterMarkersOnTimelineDescription": "Opdel tidslinjen ved kapitelgrænser",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Specialafsnit i episoderekkefølge",
|
||||
"specialsOrderingDescription": "Hvor specialafsnit afspilles i seriens visningsrækkefølge",
|
||||
"specialsOrderingServer": "Følg serverrækkefølgen",
|
||||
"specialsOrderingAirDate": "Indflet efter udsendelsesdato",
|
||||
"specialsOrderingLast": "Efter almindelige sæsoner",
|
||||
"clickVideoTogglesPlayback": "Klik på videoen for at skifte mellem afspilning og pause",
|
||||
"clickVideoTogglesPlaybackDescription": "Klik på videoen for at afspille eller sætte på pause i stedet for at vise betjeningsknapperne.",
|
||||
"videoPlayerControls": "Videoafspillerkontroller",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Genvejen er allerede tildelt ${action}",
|
||||
"shortcutUpdated": "Genvejen for ${action} er opdateret",
|
||||
"saveFailed": "Ændringerne kunne ikke gemmes. Prøv igen.",
|
||||
"autoSkip": "Automatisk spring",
|
||||
"autoPlayAndSkip": "Autoafspilning og spring",
|
||||
"autoPlayNextEpisode": "Autoafspil næste afsnit",
|
||||
"autoPlayNextEpisodeDescription": "Start automatisk næste afsnit, når et afsnit slutter",
|
||||
"playNextCountdown": "Nedtælling til næste afsnit",
|
||||
"playNextCountdownImmediate": "Afspil med det samme",
|
||||
"autoSkipIntro": "Spring intro over automatisk",
|
||||
"autoSkipIntroDescription": "Spring automatisk intromarkører over efter få sekunder",
|
||||
"autoSkipCredits": "Spring rulletekster over automatisk",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Skift automatisk til billede-i-billede, når du forlader appen under afspilning",
|
||||
"matchContentFrameRate": "Tilpas billedhastigheden til indholdet",
|
||||
"matchContentFrameRateDescription": "Tilpas skærmens opdateringsfrekvens til videoindhold",
|
||||
"matchContentResolution": "Tilpas til indholdets opløsning",
|
||||
"matchContentResolutionDescription": "Skifter skærmen til videoens oprindelige opløsning, så dit tv står for opskaleringen. Menuer og undertekster opskaleres også under afspilning",
|
||||
"matchRefreshRate": "Tilpas opdateringsfrekvensen",
|
||||
"matchRefreshRateDescription": "Tilpas skærmens opdateringsfrekvens i fuld skærm",
|
||||
"matchDynamicRange": "Tilpas dynamikområdet",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen",
|
||||
"dvConversionDv81Description": "Tving inline RPU-konvertering til Dolby Vision profil 8.1",
|
||||
"dvConversionHevcStripDescription": "Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC",
|
||||
"deinterlace": "Deinterlacing",
|
||||
"deinterlaceDescription": "Fjern kamartefakter fra interlaced video (kun mpv-afspiller)",
|
||||
"requireProfileSelectionOnOpen": "Spørg om profil ved åbning",
|
||||
"requireProfileSelectionOnOpenDescription": "Vis profilvalg hver gang appen åbnes",
|
||||
"forceTvMode": "Gennemtving TV-tilstand",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Vis fanen Opdag med indhold fra Plex Discover og tilknyttede trackere",
|
||||
"liveTvDefaultFavorites": "Vis favoritkanaler som standard",
|
||||
"liveTvDefaultFavoritesDescription": "Vis kun favoritkanaler ved åbning af Live TV",
|
||||
"general": "Generelt",
|
||||
"generalDescription": "Sprog, opstart og vinduesadfærd",
|
||||
"languageAndRegion": "Sprog og region",
|
||||
"startup": "Opstart",
|
||||
"display": "Skærm",
|
||||
"libraryAndCards": "Bibliotek og kort",
|
||||
"homeScreen": "Startskærm",
|
||||
"navigation": "Navigation",
|
||||
"window": "Vindue",
|
||||
"content": "Indhold",
|
||||
"liveTv": "Live TV",
|
||||
"player": "Afspiller",
|
||||
"subtitlesAndConfig": "Undertekster og konfiguration",
|
||||
"videoAndDisplay": "Video og skærm",
|
||||
"audio": "Lyd",
|
||||
"quality": "Kvalitet",
|
||||
"subtitles": "Undertekster",
|
||||
"seekAndTiming": "Søgning og timing",
|
||||
"behavior": "Adfærd",
|
||||
"gestures": "Bevægelser",
|
||||
"gestureBrightnessSwipe": "Lysstyrke-strygning",
|
||||
"gestureBrightnessSwipeDescription": "Stryg op eller ned i venstre kant for at justere lysstyrken",
|
||||
"gestureVolumeSwipe": "Lydstyrke-strygning",
|
||||
"gestureVolumeSwipeDescription": "Stryg op eller ned i højre kant for at justere lydstyrken",
|
||||
"gesturePinchToZoom": "Klem for at zoome",
|
||||
"gesturePinchToZoomDescription": "Klem på videoen for at zoome ind eller ud",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Kontroller",
|
||||
"rememberPlayerChanges": "Husk afspillerændringer",
|
||||
"rememberPlayerChangesDescription": "Hvor en ændring under afspilning gemmes og anvendes igen",
|
||||
"scopePlaybackSpeed": "Afspilningshastighed",
|
||||
"scopeShaderPreset": "Shader-forudindstilling",
|
||||
"scopeAspectRatio": "Billedformat",
|
||||
"scopeSyncOffsets": "Synkronisering af lyd og undertekster",
|
||||
"playerScopeOff": "Gem ikke",
|
||||
"playerScopeGlobal": "Overalt",
|
||||
"playerScopeLibrary": "Pr. bibliotek",
|
||||
"playerScopeTitle": "Pr. serie eller film",
|
||||
"exportDialogTitle": "Eksportér Plezy-indstillinger"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Lyd",
|
||||
"subtitlesLabel": "Undertekster",
|
||||
"resetToZero": "Nulstil til 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} afspilles senere",
|
||||
"playsEarlier": "${label} afspilles tidligere",
|
||||
"noOffset": "Ingen forskydning",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Fyld skærm",
|
||||
"stretch": "Stræk",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Enheden understøtter ikke billede-i-billede",
|
||||
"voSwitchFailed": "Kunne ikke skifte videooutput til billede-i-billede",
|
||||
"failed": "Billede-i-billede kunne ikke starte",
|
||||
"prepareFailed": "Billede-i-billede kunne ikke forberedes",
|
||||
"unknown": "Der opstod en fejl: ${error}"
|
||||
},
|
||||
"chapters": "Kapitler",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Rediger ${product}-forbindelse",
|
||||
"editMediaBrowserIntro": "Tilføj eller fjern URL'er for ${serverName}. Plezy bruger den tilgængelige URL med laveste latenstid."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Kontopræferencer",
|
||||
"hubSubtitleSingle": "Lyd-, undertekst- og biblioteksindstillinger gemt på ${account}",
|
||||
"hubSubtitleMultiple": "Lyd-, undertekst- og biblioteksindstillinger gemt på ${count} konti",
|
||||
"pickAccount": "Hver konto gemmer sine egne præferencer. Vælg den, du vil redigere.",
|
||||
"storedOnAccount": "Disse indstillinger gemmes på selve kontoen, så alle apps, der er logget ind på den, bruger dem — inklusive Plezy på dine andre enheder.",
|
||||
"noAccounts": "Ingen konti at konfigurere",
|
||||
"noAccountsHint": "Log ind på Plex, eller forbind en Jellyfin- eller Emby-server, så vises de præferencer, der er gemt på kontoen, her.",
|
||||
"unavailable": "Kan ikke nå denne konto",
|
||||
"loadFailed": "Kunne ikke indlæse disse præferencer",
|
||||
"noPreference": "Ingen præference",
|
||||
"notSet": "Ikke indstillet",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Lyd og undertekster",
|
||||
"libraryDisplay": "Bibliotek",
|
||||
"personalMedia": "Personlige medier"
|
||||
},
|
||||
"preferredAudioLanguage": "Foretrukket lydsprog",
|
||||
"autoSelectAudio": "Vælg lyd ud fra sprog",
|
||||
"autoSelectAudioDescription": "Fra beholder det lydspor, som filen markerer som standard.",
|
||||
"preferredSubtitleLanguage": "Foretrukket undertekstsprog",
|
||||
"subtitleMode": "Slå undertekster til",
|
||||
"subtitleModes": {
|
||||
"none": "Manuelt valgt",
|
||||
"noneDescription": "Slår aldrig undertekster til af sig selv.",
|
||||
"defaultMode": "Følg sporets flag",
|
||||
"defaultModeDescription": "Brug standard- og tvungne flag, der er gemt på hvert undertekstspor.",
|
||||
"always": "Altid aktiveret",
|
||||
"alwaysDescription": "Slå et undertekstspor til på det foretrukne sprog, når der findes ét.",
|
||||
"onlyForced": "Kun tvungne undertekster",
|
||||
"onlyForcedDescription": "Indlæs kun spor, der er markeret som tvungne.",
|
||||
"smart": "Vis ved fremmedsproget lyd",
|
||||
"smartDescription": "Slå kun undertekster til, når lyden er på et andet sprog."
|
||||
},
|
||||
"subtitleAccessibility": "SDH-undertekster",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Foretræk ikke-SDH-undertekster",
|
||||
"preferSdh": "Foretræk SDH-undertekster",
|
||||
"onlySdh": "Kun SDH-undertekster",
|
||||
"onlyNonSdh": "Kun ikke-SDH-undertekster"
|
||||
},
|
||||
"forcedSubtitles": "Tvungne undertekster",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Foretræk ikke-tvungne undertekster",
|
||||
"preferForced": "Foretræk tvungne undertekster",
|
||||
"onlyForced": "Kun tvungne undertekster",
|
||||
"onlyNonForced": "Kun ikke-tvungne undertekster"
|
||||
},
|
||||
"displayMissingEpisodes": "Vis manglende afsnit",
|
||||
"displayMissingEpisodesDescription": "Vis afsnit, som serveren kender til, men som ikke har nogen fil.",
|
||||
"hidePlayedInLatest": "Skjul sete elementer i Seneste",
|
||||
"hidePlayedInLatestDescription": "Hold elementer, du allerede har set, ude af serverens Seneste-rækker.",
|
||||
"displayCollectionsView": "Vis samlingsvisningen",
|
||||
"displayCollectionsViewDescription": "Vis serverens samlingsvisning sammen med dine biblioteker.",
|
||||
"rewatchingInNextUp": "Behold gensete serier i Næste afsnit",
|
||||
"rewatchingInNextUpDescription": "Når du er færdig med en serie og ser den igen, følger Næste afsnit med i gensynet i stedet for at fjerne serien.",
|
||||
"watchedIndicator": "Set-indikatorer",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Aldrig",
|
||||
"moviesAndShows": "Film og TV-serier",
|
||||
"movies": "Kun film",
|
||||
"shows": "Kun TV-serier"
|
||||
},
|
||||
"mediaReviewsVisibility": "Bedømmelser og anmeldelser",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Brugere og anmeldere",
|
||||
"usersOnly": "Kun brugere",
|
||||
"criticsOnly": "Kun anmeldere",
|
||||
"nobody": "Skjult"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Opdag",
|
||||
"noContentAvailable": "Intet indhold tilgængeligt",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Ingen servere fundet for ${username} (${email})",
|
||||
"failedToLoadServers": "Kunne ikke indlæse servere: ${error}"
|
||||
"failedToLoadServers": "Kunne ikke indlæse servere: ${error}",
|
||||
"noValidServers": "Der blev ikke fundet nogen brugbare servere på denne konto"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titel",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} populær",
|
||||
"rankAiring": "#${n} på luften",
|
||||
"rankRated": "#${n} bedømt",
|
||||
"rankFavorited": "#${n} favorit",
|
||||
"rankTrending": "#${n} populær lige nu",
|
||||
"rankSeasonal": "#${n} i ${season}",
|
||||
"watchingNow": "${n} ser med",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Ukendt kanal",
|
||||
"live": "LIVE",
|
||||
"reloadGuide": "Genindlæs guide",
|
||||
"searchGuide": "Søg i guiden",
|
||||
"searchHint": "Søg efter kanaler og programmer",
|
||||
"searchNoResults": "Ingen match for \"${query}\"",
|
||||
"channelsSection": "Kanaler",
|
||||
"programsSection": "Programmer",
|
||||
"now": "Nu",
|
||||
"today": "I dag",
|
||||
"tomorrow": "I morgen",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Der er anmodet om en opdatering af guiden",
|
||||
"rulesProcessRequested": "Der er anmodet om en ny evaluering af reglerne",
|
||||
"recordShow": "Optag program",
|
||||
"recordSettings": {
|
||||
"startEarly": "Start tidligere (sekunder)",
|
||||
"endLate": "Slut senere (sekunder)",
|
||||
"newOnly": "Kun nye afsnit",
|
||||
"anyChannel": "Optag på alle kanaler",
|
||||
"anyTime": "Optag på alle tidspunkter",
|
||||
"skipInLibrary": "Spring afsnit over, der allerede er i biblioteket",
|
||||
"keepUpTo": "Afsnit der skal beholdes",
|
||||
"keepUpToHint": "0 beholder alle afsnit"
|
||||
},
|
||||
"startingInMinutes": "Starter om ${minutes} min",
|
||||
"dayAtTime": "${day} kl. ${time}",
|
||||
"invalidPlaybackData": "${product} returnerede ugyldige afspilningsdata for Live TV",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Gentag alle",
|
||||
"repeatOne": "Gentag ét nummer",
|
||||
"instantMixNoServer": "Ingen server er tilgængelig til et øjeblikkeligt mix",
|
||||
"noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}"
|
||||
"instantMixFailed": "Kunne ikke indlæse det direkte miks",
|
||||
"instantMixEmpty": "Det direkte miks indeholdt ingen numre",
|
||||
"noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Singler og EP'er",
|
||||
"live": "Live",
|
||||
"compilations": "Opsamlinger"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Se sammen",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Vært",
|
||||
"hostBadge": "VÆRT",
|
||||
"youAreHost": "Du er vært",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Ser med andre",
|
||||
"endSession": "Afslut session",
|
||||
"leaveSession": "Forlad session",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Fjern",
|
||||
"guestSwitchUnavailable": "Kunne ikke skifte — server ikke tilgængelig for synkronisering",
|
||||
"guestSwitchFailed": "Kunne ikke skifte — indhold blev ikke fundet på denne server",
|
||||
"defaultDisplayName": "Bruger"
|
||||
"defaultDisplayName": "Bruger",
|
||||
"errors": {
|
||||
"timedOut": "Relayserveren svarede ikke i tide",
|
||||
"connectionLost": "Forbindelsen blev lukket, før sessionen var klar",
|
||||
"invalidRelayResponse": "Relayserveren sendte et uventet svar",
|
||||
"sessionEnded": "Værten afsluttede sessionen"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Brug din mobilenhed til at styre denne app",
|
||||
"startServer": "Start serveren",
|
||||
"stopServer": "Stop serveren",
|
||||
"minimize": "Minimér"
|
||||
"minimize": "Minimér",
|
||||
"manualAddressHint": "Manuel forbindelsesadresse:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Plezy-enheder med samme Plex-konto vises her",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Dekoder",
|
||||
"rawDecoder": "Rå dekoder",
|
||||
"tunneling": "Tunneling",
|
||||
"passthrough": "Passthrough",
|
||||
"aspect": "Billedformat",
|
||||
"rotation": "Rotation",
|
||||
"dvSource": "DV-kilde",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Producer",
|
||||
"country": "Land",
|
||||
"collection": "Samling",
|
||||
"label": "Etiket",
|
||||
"style": "Stil",
|
||||
"mood": "Stemning"
|
||||
"label": "Etiket"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Match...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Delvist tilgængelig",
|
||||
"statusRequested": "Anmodet",
|
||||
"statusProcessing": "Behandler",
|
||||
"statusBlocklisted": "På blokeringslisten",
|
||||
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Indtast en serveradresse som https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Denne Seerr-instans understøtter ikke Quick Connect. Den kræver Seerr 3.4 eller nyere.",
|
||||
"notInitialized": "Denne Seerr-instans har ikke fuldført førstegangsopsætningen",
|
||||
"noPlexTokenForReauth": "Intet Plex-token er tilgængeligt til at logge ind igen",
|
||||
"noStoredCredentials": "Ingen gemte loginoplysninger er tilgængelige til at logge ind igen",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
"hubSubtitle": "Synkroniser dit visningsfremskridt, og anmod om nye titler.",
|
||||
"integrations": "Integrationer",
|
||||
"notConnected": "Ikke forbundet",
|
||||
"connectedAs": "Forbundet som @${username}",
|
||||
"scrobble": "Registrer fremgang automatisk",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN.",
|
||||
"invalidCredentials": "Ugyldigt brugernavn eller ugyldig adgangskode",
|
||||
"authResponseNotJson": "Godkendelsessvaret var ikke gyldig JSON",
|
||||
"authResponseIncomplete": "Loginsvaret fra serveren var ufuldstændigt",
|
||||
"quickConnectRejected": "Quick Connect blev afvist af serveren",
|
||||
"quickConnectNotJson": "Quick Connect-svaret var ikke gyldig JSON",
|
||||
"quickConnectMissingFields": "Quick Connect-svaret mangler en kode eller hemmelighed",
|
||||
|
||||
+192
-27
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Browser verwenden",
|
||||
"or": "oder",
|
||||
"connectToMediaBrowser": "Mit ${product} verbinden",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Quick Connect verwenden",
|
||||
"quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.",
|
||||
"quickConnectWaiting": "Warte auf Bestätigung…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Stumm",
|
||||
"ok": "OK",
|
||||
"off": "Aus",
|
||||
"options": "Optionen",
|
||||
"seasonNumber": "Staffel ${number}",
|
||||
"episodeNumberTitle": "Folge ${number} – ${title}",
|
||||
"chapterNumber": "Kapitel ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N. v.",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Film",
|
||||
"show": "Serie",
|
||||
"season": "Staffel",
|
||||
"episode": "Episode",
|
||||
"artist": "Interpret",
|
||||
"album": "Album",
|
||||
"track": "Titel",
|
||||
"collection": "Sammlung",
|
||||
"playlist": "Wiedergabeliste",
|
||||
"clip": "Clip",
|
||||
"photo": "Foto",
|
||||
"folder": "Ordner"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lizenzen",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Skalierung",
|
||||
"compact": "Kompakt",
|
||||
"comfortable": "Großzügig",
|
||||
"gridSpacing": "Rasterabstand",
|
||||
"gridSpacingTight": "Eng",
|
||||
"gridSpacingNormal": "Normal",
|
||||
"gridSpacingSpacious": "Großzügig",
|
||||
"tvCornerSpotlightBackdrop": "Backdrop in der Ecke",
|
||||
"tvCornerSpotlightBackdropDescription": "Zeigt das Spotlight-Artwork oben rechts statt bildschirmfüllend",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Hardwaredekodierung",
|
||||
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
|
||||
"bufferSize": "Puffergröße",
|
||||
"bufferSizeMB": "${size} MB",
|
||||
"bufferSizeAuto": "Automatisch (empfohlen)",
|
||||
"bufferSizeWarning": "${heap} MB Speicher verfügbar. Ein Puffer von ${size} MB kann die Wiedergabe beeinträchtigen.",
|
||||
"playbackBuffer": "Wiedergabepuffer",
|
||||
"playbackBufferAuto": "Automatisch (empfohlen)",
|
||||
"playbackBufferLarge": "Groß",
|
||||
"playbackBufferExtraLarge": "Extra groß",
|
||||
"playbackBufferDescription": "Puffert mehr gegen instabile Verbindungen. Zusätzlich durch die Puffergröße begrenzt.",
|
||||
"defaultQualityTitle": "Standardqualität",
|
||||
"cellularQualityTitle": "Standardqualität im Mobilfunknetz",
|
||||
"cellularQualitySameAsDefault": "Wie Standardqualität",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musikqualität",
|
||||
"subtitleStyling": "Untertitel-Stil",
|
||||
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Audio- und Untertitelauswahl je Titel merken",
|
||||
"followServerTrackSelections": "Serverseitige Spurauswahl pro Episode verwenden",
|
||||
"followServerTrackSelectionsDescription": "Beim Episodenwechsel die auf dem Server ausgewählten Audio- und Untertitelspuren übernehmen, statt die aktuelle Auswahl zu übertragen",
|
||||
"resumeMusicOnLaunch": "Musiksitzung merken",
|
||||
"resumeMusicOnLaunchDescription": "Beim App-Start den letzten Titel pausiert an der letzten Stelle wieder öffnen",
|
||||
"showChapterMarkersOnTimeline": "Kapitelmarkierungen auf der Suchleiste anzeigen",
|
||||
"showChapterMarkersOnTimelineDescription": "Die Suchleiste an Kapitelgrenzen unterteilen",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Specials in Episodenreihenfolge",
|
||||
"specialsOrderingDescription": "Wo Specials in der Wiedergabereihenfolge einer Serie abgespielt werden",
|
||||
"specialsOrderingServer": "Serverreihenfolge folgen",
|
||||
"specialsOrderingAirDate": "Nach Ausstrahlungsdatum einfügen",
|
||||
"specialsOrderingLast": "Nach den regulären Staffeln",
|
||||
"clickVideoTogglesPlayback": "Video zum Abspielen oder Pausieren anklicken",
|
||||
"clickVideoTogglesPlaybackDescription": "Video zum Abspielen oder Pausieren anklicken, statt die Steuerung anzuzeigen.",
|
||||
"videoPlayerControls": "Videoplayer-Steuerung",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}",
|
||||
"shortcutUpdated": "Tastenkürzel aktualisiert für ${action}",
|
||||
"saveFailed": "Änderungen konnten nicht gespeichert werden. Versuche es erneut.",
|
||||
"autoSkip": "Automatisches Überspringen",
|
||||
"autoPlayAndSkip": "Autoplay & Überspringen",
|
||||
"autoPlayNextEpisode": "Nächste Episode automatisch abspielen",
|
||||
"autoPlayNextEpisodeDescription": "Die nächste Episode automatisch starten, wenn die aktuelle endet",
|
||||
"playNextCountdown": "Countdown bis zur nächsten Episode",
|
||||
"playNextCountdownImmediate": "Sofort abspielen",
|
||||
"autoSkipIntro": "Intro automatisch überspringen",
|
||||
"autoSkipIntroDescription": "Intro-Marker nach wenigen Sekunden automatisch überspringen",
|
||||
"autoSkipCredits": "Abspann automatisch überspringen",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Beim Verlassen der App während der Wiedergabe automatisch Bild-in-Bild starten",
|
||||
"matchContentFrameRate": "Inhalts-Bildrate anpassen",
|
||||
"matchContentFrameRateDescription": "Bildwiederholrate des Displays an Videoinhalt anpassen",
|
||||
"matchContentResolution": "An Auflösung des Inhalts anpassen",
|
||||
"matchContentResolutionDescription": "Wechselt die Anzeige auf die native Auflösung des Videos, damit dein Fernseher das Hochskalieren übernimmt. Menüs und Untertitel werden während der Wiedergabe ebenfalls hochskaliert",
|
||||
"matchRefreshRate": "Bildwiederholrate anpassen",
|
||||
"matchRefreshRateDescription": "Bildwiederholrate im Vollbild anpassen",
|
||||
"matchDynamicRange": "Dynamikumfang anpassen",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken",
|
||||
"dvConversionDv81Description": "Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen",
|
||||
"dvConversionHevcStripDescription": "Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben",
|
||||
"deinterlace": "Deinterlacing",
|
||||
"deinterlaceDescription": "Kammartefakte aus interlaced Videos entfernen (nur mpv-Player)",
|
||||
"requireProfileSelectionOnOpen": "Profil beim Öffnen abfragen",
|
||||
"requireProfileSelectionOnOpenDescription": "Profilauswahl bei jedem Öffnen der App anzeigen",
|
||||
"forceTvMode": "TV-Modus erzwingen",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Zeigt den Tab „Entdecken“ mit Inhalten aus Plex Discover und verbundenen Trackern an",
|
||||
"liveTvDefaultFavorites": "Standardmäßig Favoritenkanäle",
|
||||
"liveTvDefaultFavoritesDescription": "Beim Öffnen von Live TV nur Favoritenkanäle anzeigen",
|
||||
"general": "Allgemein",
|
||||
"generalDescription": "Sprache, Start und Fensterverhalten",
|
||||
"languageAndRegion": "Sprache & Region",
|
||||
"startup": "Start",
|
||||
"display": "Anzeige",
|
||||
"libraryAndCards": "Mediathek & Karten",
|
||||
"homeScreen": "Startseite",
|
||||
"navigation": "Navigation",
|
||||
"window": "Fenster",
|
||||
"content": "Inhalt",
|
||||
"liveTv": "Live-TV",
|
||||
"player": "Wiedergabe",
|
||||
"subtitlesAndConfig": "Untertitel und Konfiguration",
|
||||
"videoAndDisplay": "Video & Anzeige",
|
||||
"audio": "Audio",
|
||||
"quality": "Qualität",
|
||||
"subtitles": "Untertitel",
|
||||
"seekAndTiming": "Spulen & Timing",
|
||||
"behavior": "Verhalten",
|
||||
"gestures": "Gesten",
|
||||
"gestureBrightnessSwipe": "Wischen für Helligkeit",
|
||||
"gestureBrightnessSwipeDescription": "Wische am linken Rand nach oben oder unten, um die Helligkeit anzupassen",
|
||||
"gestureVolumeSwipe": "Wischen für Lautstärke",
|
||||
"gestureVolumeSwipeDescription": "Wische am rechten Rand nach oben oder unten, um die Lautstärke anzupassen",
|
||||
"gesturePinchToZoom": "Zum Zoomen kneifen",
|
||||
"gesturePinchToZoomDescription": "Kneife auf dem Video, um hinein- oder herauszuzoomen",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Steuerung",
|
||||
"rememberPlayerChanges": "Playeränderungen merken",
|
||||
"rememberPlayerChangesDescription": "Wo eine während der Wiedergabe vorgenommene Änderung gespeichert und erneut angewendet wird",
|
||||
"scopePlaybackSpeed": "Wiedergabegeschwindigkeit",
|
||||
"scopeShaderPreset": "Shader-Voreinstellung",
|
||||
"scopeAspectRatio": "Seitenverhältnis",
|
||||
"scopeSyncOffsets": "Audio- und Untertitelsynchronisierung",
|
||||
"playerScopeOff": "Nicht speichern",
|
||||
"playerScopeGlobal": "Überall",
|
||||
"playerScopeLibrary": "Pro Bibliothek",
|
||||
"playerScopeTitle": "Pro Serie oder Film",
|
||||
"exportDialogTitle": "Plezy-Einstellungen exportieren"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Untertitel",
|
||||
"resetToZero": "Auf 0 ms zurücksetzen",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} spielt später",
|
||||
"playsEarlier": "${label} spielt früher",
|
||||
"noOffset": "Kein Offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Bild füllen",
|
||||
"stretch": "Strecken",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Dieses Gerät unterstützt den Bild-in-Bild-Modus nicht",
|
||||
"voSwitchFailed": "Videoausgabe für Bild-in-Bild konnte nicht umgeschaltet werden",
|
||||
"failed": "Bild-in-Bild konnte nicht gestartet werden",
|
||||
"prepareFailed": "Bild-in-Bild konnte nicht vorbereitet werden",
|
||||
"unknown": "Ein Fehler ist aufgetreten: ${error}"
|
||||
},
|
||||
"chapters": "Kapitel",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Verbindung zu ${product} bearbeiten",
|
||||
"editMediaBrowserIntro": "URLs für ${serverName} hinzufügen oder entfernen. Plezy verwendet die erreichbare URL mit der geringsten Latenz."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Kontoeinstellungen",
|
||||
"hubSubtitleSingle": "Audio-, Untertitel- und Mediathek-Optionen werden auf ${account} gespeichert",
|
||||
"hubSubtitleMultiple": "Audio-, Untertitel- und Mediathek-Optionen werden auf ${count} Konten gespeichert",
|
||||
"pickAccount": "Jedes Konto speichert seine eigenen Einstellungen. Wähle das Konto, das du bearbeiten möchtest.",
|
||||
"storedOnAccount": "Diese Optionen werden auf dem Konto selbst gespeichert, sodass jede damit angemeldete App sie verwendet — auch Plezy auf deinen anderen Geräten.",
|
||||
"noAccounts": "Keine Konten zum Konfigurieren",
|
||||
"noAccountsHint": "Melde dich bei Plex an oder verbinde einen Jellyfin- oder Emby-Server — die auf diesem Konto gespeicherten Einstellungen erscheinen dann hier.",
|
||||
"unavailable": "Dieses Konto ist nicht erreichbar",
|
||||
"loadFailed": "Diese Einstellungen konnten nicht geladen werden",
|
||||
"noPreference": "Keine Einstellung",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Audio & Untertitel",
|
||||
"libraryDisplay": "Mediathek",
|
||||
"personalMedia": "Persönliche Medien"
|
||||
},
|
||||
"preferredAudioLanguage": "Bevorzugte Audiosprache",
|
||||
"autoSelectAudio": "Audio nach Sprache auswählen",
|
||||
"autoSelectAudioDescription": "Bei „Aus“ wird die Audiospur verwendet, die die Datei als Standard markiert.",
|
||||
"preferredSubtitleLanguage": "Bevorzugte Untertitelsprache",
|
||||
"subtitleMode": "Untertitel einschalten",
|
||||
"subtitleModes": {
|
||||
"none": "Manuell ausgewählt",
|
||||
"noneDescription": "Untertitel niemals von selbst einschalten.",
|
||||
"defaultMode": "Spurmarkierungen folgen",
|
||||
"defaultModeDescription": "Die Standard- und Erzwungen-Markierungen jeder Untertitelspur verwenden.",
|
||||
"always": "Immer aktiviert",
|
||||
"alwaysDescription": "Eine Untertitelspur in der bevorzugten Sprache einschalten, sofern eine vorhanden ist.",
|
||||
"onlyForced": "Nur erzwungene Untertitel",
|
||||
"onlyForcedDescription": "Nur die als erzwungen markierten Spuren laden.",
|
||||
"smart": "Bei fremdsprachigem Audio anzeigen",
|
||||
"smartDescription": "Untertitel nur einschalten, wenn das Audio in einer anderen Sprache ist."
|
||||
},
|
||||
"subtitleAccessibility": "SDH-Untertitel",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Nicht-SDH-Untertitel bevorzugen",
|
||||
"preferSdh": "SDH-Untertitel bevorzugen",
|
||||
"onlySdh": "Nur SDH-Untertitel",
|
||||
"onlyNonSdh": "Nur Nicht-SDH-Untertitel"
|
||||
},
|
||||
"forcedSubtitles": "Erzwungene Untertitel",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Nicht erzwungene Untertitel bevorzugen",
|
||||
"preferForced": "Erzwungene Untertitel bevorzugen",
|
||||
"onlyForced": "Nur erzwungene Untertitel",
|
||||
"onlyNonForced": "Nur nicht erzwungene Untertitel"
|
||||
},
|
||||
"displayMissingEpisodes": "Fehlende Episoden anzeigen",
|
||||
"displayMissingEpisodesDescription": "Episoden auflisten, die dem Server bekannt sind, für die aber keine Datei vorliegt.",
|
||||
"hidePlayedInLatest": "Gesehene Elemente in „Neueste“ ausblenden",
|
||||
"hidePlayedInLatestDescription": "Elemente, die du bereits gesehen hast, erscheinen nicht mehr in den „Neueste“-Zeilen des Servers.",
|
||||
"displayCollectionsView": "Sammlungsansicht anzeigen",
|
||||
"displayCollectionsViewDescription": "Die Sammlungsansicht des Servers zusätzlich zu deinen Mediatheken anzeigen.",
|
||||
"rewatchingInNextUp": "Erneut geschaute Serien in „Als Nächstes“ behalten",
|
||||
"rewatchingInNextUpDescription": "Beendest du eine Serie und startest sie erneut, folgt „Als Nächstes“ dem erneuten Anschauen, statt die Serie zu verwerfen.",
|
||||
"watchedIndicator": "Gesehen-Markierung",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Nie",
|
||||
"moviesAndShows": "Filme und Serien",
|
||||
"movies": "Nur Filme",
|
||||
"shows": "Nur Serien"
|
||||
},
|
||||
"mediaReviewsVisibility": "Bewertungen & Rezensionen",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Benutzer und Kritiker",
|
||||
"usersOnly": "Nur Benutzer",
|
||||
"criticsOnly": "Nur Kritiker",
|
||||
"nobody": "Ausgeblendet"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Entdecken",
|
||||
"noContentAvailable": "Kein Inhalt verfügbar",
|
||||
@@ -1008,7 +1133,7 @@
|
||||
"productionYear": "Produktionsjahr",
|
||||
"runtime": "Laufzeit",
|
||||
"officialRating": "Offizielle Bewertung",
|
||||
"premiereDate": "Premierendatum",
|
||||
"premiereDate": "Veröffentlichungsdatum",
|
||||
"startDate": "Startdatum",
|
||||
"airTime": "Sendezeit",
|
||||
"studio": "Studio",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Keine Server gefunden für ${username} (${email})",
|
||||
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}"
|
||||
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}",
|
||||
"noValidServers": "Für dieses Konto wurden keine nutzbaren Server gefunden"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titel",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} beliebt",
|
||||
"rankAiring": "#${n} derzeit ausgestrahlt",
|
||||
"rankRated": "#${n} bewertet",
|
||||
"rankFavorited": "#${n} favorisiert",
|
||||
"rankTrending": "#${n} angesagt",
|
||||
"rankSeasonal": "#${n} in ${season}",
|
||||
"watchingNow": "${n} schauen gerade",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Unbekannter Kanal",
|
||||
"live": "LIVE",
|
||||
"reloadGuide": "TV-Programm neu laden",
|
||||
"searchGuide": "TV-Programm durchsuchen",
|
||||
"searchHint": "Kanäle & Sendungen suchen",
|
||||
"searchNoResults": "Keine Treffer für „${query}“",
|
||||
"channelsSection": "Kanäle",
|
||||
"programsSection": "Sendungen",
|
||||
"now": "Jetzt",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Aktualisierung des TV-Programms angefordert",
|
||||
"rulesProcessRequested": "Regelauswertung angefordert",
|
||||
"recordShow": "Sendung aufnehmen",
|
||||
"recordSettings": {
|
||||
"startEarly": "Früher starten (Sekunden)",
|
||||
"endLate": "Später beenden (Sekunden)",
|
||||
"newOnly": "Nur neue Episoden",
|
||||
"anyChannel": "Auf jedem Sender aufnehmen",
|
||||
"anyTime": "Zu jeder Zeit aufnehmen",
|
||||
"skipInLibrary": "Episoden überspringen, die schon in der Bibliothek sind",
|
||||
"keepUpTo": "Zu behaltende Episoden",
|
||||
"keepUpToHint": "0 behält alle Episoden"
|
||||
},
|
||||
"startingInMinutes": "Beginnt in ${minutes} Min.",
|
||||
"dayAtTime": "${day} um ${time}",
|
||||
"invalidPlaybackData": "${product} hat ungültige Live-TV-Wiedergabedaten zurückgegeben",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Alle wiederholen",
|
||||
"repeatOne": "Titel wiederholen",
|
||||
"instantMixNoServer": "Für einen Instant Mix ist kein Server verfügbar",
|
||||
"noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar"
|
||||
"instantMixFailed": "Instant-Mix konnte nicht geladen werden",
|
||||
"instantMixEmpty": "Der Instant-Mix enthält keine Titel",
|
||||
"noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar",
|
||||
"discography": {
|
||||
"singlesAndEps": "Singles & EPs",
|
||||
"live": "Live",
|
||||
"compilations": "Kompilationen"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Gemeinsam Schauen",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Du bist der Host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Mit anderen schauen",
|
||||
"endSession": "Sitzung beenden",
|
||||
"leaveSession": "Sitzung verlassen",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Entfernen",
|
||||
"guestSwitchUnavailable": "Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar",
|
||||
"guestSwitchFailed": "Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden",
|
||||
"defaultDisplayName": "Benutzer"
|
||||
"defaultDisplayName": "Benutzer",
|
||||
"errors": {
|
||||
"timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet",
|
||||
"connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war",
|
||||
"invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet",
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Verwende dein Mobilgerät, um diese App zu steuern",
|
||||
"startServer": "Server starten",
|
||||
"stopServer": "Server stoppen",
|
||||
"minimize": "Minimieren"
|
||||
"minimize": "Minimieren",
|
||||
"manualAddressHint": "Manuelle Verbindungsadresse:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Plezy-Geräte mit demselben Plex-Konto erscheinen hier",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Decoder",
|
||||
"rawDecoder": "Raw-Decoder",
|
||||
"tunneling": "Tunneling",
|
||||
"passthrough": "Durchleitung",
|
||||
"aspect": "Seitenverhältnis",
|
||||
"rotation": "Drehung",
|
||||
"dvSource": "DV-Quelle",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Produzent",
|
||||
"country": "Land",
|
||||
"collection": "Sammlung",
|
||||
"label": "Label",
|
||||
"style": "Stil",
|
||||
"mood": "Stimmung"
|
||||
"label": "Label"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Zuordnen...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Teilweise verfügbar",
|
||||
"statusRequested": "Angefragt",
|
||||
"statusProcessing": "Wird verarbeitet",
|
||||
"statusBlocklisted": "Auf der Sperrliste",
|
||||
"couldNotReach": "${url} nicht erreichbar: ${error}",
|
||||
"noInstanceAtUrl": "Keine Seerr-Instanz unter ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Gib eine Serveradresse ein, z. B. https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Diese Seerr-Instanz unterstützt Quick Connect nicht. Dafür ist Seerr 3.4 oder neuer erforderlich.",
|
||||
"notInitialized": "Die Ersteinrichtung dieser Seerr-Instanz wurde noch nicht abgeschlossen",
|
||||
"noPlexTokenForReauth": "Für die erneute Anmeldung ist kein Plex-Token verfügbar",
|
||||
"noStoredCredentials": "Für die erneute Anmeldung sind keine gespeicherten Anmeldedaten verfügbar",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Dienste",
|
||||
"hubSubtitle": "Wiedergabefortschritt synchronisieren und neue Titel anfragen.",
|
||||
"integrations": "Integrationen",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"connectedAs": "Verbunden als @${username}",
|
||||
"scrobble": "Fortschritt automatisch verfolgen",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN.",
|
||||
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
||||
"authResponseNotJson": "Die Authentifizierungsantwort war kein gültiges JSON",
|
||||
"authResponseIncomplete": "Die Anmeldeantwort des Servers war unvollständig",
|
||||
"quickConnectRejected": "Quick Connect wurde vom Server abgelehnt",
|
||||
"quickConnectNotJson": "Die Quick Connect-Antwort war kein gültiges JSON",
|
||||
"quickConnectMissingFields": "In der Quick Connect-Antwort fehlt ein Code oder Geheimnis",
|
||||
|
||||
+187
-22
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Use browser",
|
||||
"or": "or",
|
||||
"connectToMediaBrowser": "Connect to ${product}",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Use Quick Connect",
|
||||
"quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.",
|
||||
"quickConnectWaiting": "Waiting for approval…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Mute",
|
||||
"ok": "OK",
|
||||
"off": "Off",
|
||||
"options": "Options",
|
||||
"seasonNumber": "Season ${number}",
|
||||
"episodeNumberTitle": "Episode ${number} - ${title}",
|
||||
"chapterNumber": "Chapter ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Movie",
|
||||
"show": "Show",
|
||||
"season": "Season",
|
||||
"episode": "Episode",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"track": "Track",
|
||||
"collection": "Collection",
|
||||
"playlist": "Playlist",
|
||||
"clip": "Clip",
|
||||
"photo": "Photo",
|
||||
"folder": "Folder"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Display Scale",
|
||||
"compact": "Compact",
|
||||
"comfortable": "Comfortable",
|
||||
"gridSpacing": "Grid Spacing",
|
||||
"gridSpacingTight": "Tight",
|
||||
"gridSpacingNormal": "Normal",
|
||||
"gridSpacingSpacious": "Spacious",
|
||||
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
|
||||
"tvCornerSpotlightBackdropDescription": "Show spotlight artwork in the top-right corner instead of filling the screen",
|
||||
"viewMode": "View Mode",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Hardware Decoding",
|
||||
"hardwareDecodingDescription": "Use hardware acceleration when available",
|
||||
"bufferSize": "Buffer Size",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"bufferSizeAuto": "Auto (Recommended)",
|
||||
"bufferSizeWarning": "${heap}MB memory available. A ${size}MB buffer may affect playback.",
|
||||
"playbackBuffer": "Playback Buffer",
|
||||
"playbackBufferAuto": "Auto (Recommended)",
|
||||
"playbackBufferLarge": "Large",
|
||||
"playbackBufferExtraLarge": "Extra Large",
|
||||
"playbackBufferDescription": "Buffer more against unstable connections. Also limited by Buffer Size.",
|
||||
"defaultQualityTitle": "Default Quality",
|
||||
"cellularQualityTitle": "Default Quality on Cellular",
|
||||
"cellularQualitySameAsDefault": "Same as Default Quality",
|
||||
"directPlayCoveredQuality": "Play Smaller Videos at Original Quality",
|
||||
"directPlayCoveredQualityDescription": "Direct play videos already within the quality limit instead of transcoding them",
|
||||
"musicQualityTitle": "Music Quality",
|
||||
"subtitleStyling": "Subtitle Styling",
|
||||
"subtitleStylingDescription": "Customize subtitle appearance",
|
||||
@@ -193,6 +213,8 @@
|
||||
"rememberTrackSelectionsDescription": "Remember audio and subtitle choices per title",
|
||||
"followServerTrackSelections": "Use server's per-episode track selections",
|
||||
"followServerTrackSelectionsDescription": "On episode change, apply the audio and subtitles selected on the server instead of carrying over the current choice",
|
||||
"resumeMusicOnLaunch": "Remember music session",
|
||||
"resumeMusicOnLaunchDescription": "Reopen the last song paused where it left off when the app starts",
|
||||
"showChapterMarkersOnTimeline": "Show chapter markers on seek bar",
|
||||
"showChapterMarkersOnTimelineDescription": "Segment the seek bar at chapter boundaries",
|
||||
"specialsOrdering": "Specials in episode order",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Shortcut already assigned to ${action}",
|
||||
"shortcutUpdated": "Shortcut updated for ${action}",
|
||||
"saveFailed": "Could not save changes. Try again.",
|
||||
"autoSkip": "Auto Skip",
|
||||
"autoPlayAndSkip": "Auto-Play & Skip",
|
||||
"autoPlayNextEpisode": "Auto-Play Next Episode",
|
||||
"autoPlayNextEpisodeDescription": "Start the next episode automatically when one ends",
|
||||
"playNextCountdown": "Play Next Countdown",
|
||||
"playNextCountdownImmediate": "Play immediately",
|
||||
"autoSkipIntro": "Auto Skip Intro",
|
||||
"autoSkipIntroDescription": "Automatically skip intro markers after a few seconds",
|
||||
"autoSkipCredits": "Auto Skip Credits",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Automatically enter picture-in-picture when you leave the app during playback",
|
||||
"matchContentFrameRate": "Match Content Frame Rate",
|
||||
"matchContentFrameRateDescription": "Match display refresh rate to video content",
|
||||
"matchContentResolution": "Match Content Resolution",
|
||||
"matchContentResolutionDescription": "Switch the display to the video's native resolution so your TV handles upscaling. Menus and subtitles are upscaled too during playback",
|
||||
"matchRefreshRate": "Match Refresh Rate",
|
||||
"matchRefreshRateDescription": "Match display refresh rate in fullscreen",
|
||||
"matchDynamicRange": "Match Dynamic Range",
|
||||
@@ -310,7 +338,7 @@
|
||||
"audioDownmixNormalize": "Normalize Volume on Downmix",
|
||||
"audioDownmixNormalizeDescription": "Lower the mix to prevent clipping. Turn off to keep the original volume (may distort loud scenes).",
|
||||
"dvConversionMode": "Dolby Vision Conversion",
|
||||
"dvConversionModeDescription": "Choose how ExoPlayer handles Dolby Vision Profile 7 files.",
|
||||
"dvConversionModeDescription": "Choose how Dolby Vision Profile 7 files are handled.",
|
||||
"dvConversionAuto": "Auto",
|
||||
"dvConversionNative": "Native / Disabled",
|
||||
"dvConversionDv81": "P7 → P8.1",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Force native DV7 and suppress DV conversion retry",
|
||||
"dvConversionDv81Description": "Force inline RPU conversion to Dolby Vision profile 8.1",
|
||||
"dvConversionHevcStripDescription": "Strip Dolby Vision RPU/EL layers and present plain HEVC",
|
||||
"deinterlace": "Deinterlacing",
|
||||
"deinterlaceDescription": "Remove combing artifacts from interlaced video (mpv player only)",
|
||||
"requireProfileSelectionOnOpen": "Ask for profile on app open",
|
||||
"requireProfileSelectionOnOpenDescription": "Show profile selection every time the app is opened",
|
||||
"forceTvMode": "Force TV mode",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Display the Explore tab with content from Plex Discover and connected trackers",
|
||||
"liveTvDefaultFavorites": "Default to Favorite Channels",
|
||||
"liveTvDefaultFavoritesDescription": "Show only favorite channels when opening Live TV",
|
||||
"general": "General",
|
||||
"generalDescription": "Language, startup, and window behavior",
|
||||
"languageAndRegion": "Language & Region",
|
||||
"startup": "Startup",
|
||||
"display": "Display",
|
||||
"libraryAndCards": "Library & Cards",
|
||||
"homeScreen": "Home Screen",
|
||||
"navigation": "Navigation",
|
||||
"window": "Window",
|
||||
"content": "Content",
|
||||
"liveTv": "Live TV",
|
||||
"player": "Player",
|
||||
"subtitlesAndConfig": "Subtitles & Configuration",
|
||||
"videoAndDisplay": "Video & Display",
|
||||
"audio": "Audio",
|
||||
"quality": "Quality",
|
||||
"subtitles": "Subtitles",
|
||||
"seekAndTiming": "Seek & Timing",
|
||||
"behavior": "Behavior",
|
||||
"gestures": "Gestures",
|
||||
"gestureBrightnessSwipe": "Brightness Swipe",
|
||||
"gestureBrightnessSwipeDescription": "Swipe up or down on the left edge to adjust brightness",
|
||||
"gestureVolumeSwipe": "Volume Swipe",
|
||||
"gestureVolumeSwipeDescription": "Swipe up or down on the right edge to adjust volume",
|
||||
"gesturePinchToZoom": "Pinch to Zoom",
|
||||
"gesturePinchToZoomDescription": "Pinch on the video to zoom in or out",
|
||||
"rememberBrightnessLevel": "Remember Brightness Level",
|
||||
"rememberBrightnessLevelDescription": "Start playback at the brightness set by the last swipe",
|
||||
"controls": "Controls",
|
||||
"rememberPlayerChanges": "Remember Player Changes",
|
||||
"rememberPlayerChangesDescription": "Where a change made during playback is saved and reapplied from",
|
||||
"scopePlaybackSpeed": "Playback Speed",
|
||||
"scopeShaderPreset": "Shader Preset",
|
||||
"scopeAspectRatio": "Aspect Ratio",
|
||||
"scopeSyncOffsets": "Audio & Subtitle Sync",
|
||||
"playerScopeOff": "Don't save",
|
||||
"playerScopeGlobal": "Everywhere",
|
||||
"playerScopeLibrary": "Per library",
|
||||
"playerScopeTitle": "Per show or movie",
|
||||
"exportDialogTitle": "Export Plezy settings"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Subtitles",
|
||||
"resetToZero": "Reset to 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} plays later",
|
||||
"playsEarlier": "${label} plays earlier",
|
||||
"noOffset": "No offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Fill screen",
|
||||
"stretch": "Stretch",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Device doesn't support picture-in-picture mode",
|
||||
"voSwitchFailed": "Failed to switch video output for picture-in-picture",
|
||||
"failed": "Picture-in-picture failed to start",
|
||||
"prepareFailed": "Picture-in-picture could not be prepared",
|
||||
"unknown": "An error occurred: ${error}"
|
||||
},
|
||||
"chapters": "Chapters",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Edit ${product} connection",
|
||||
"editMediaBrowserIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Account preferences",
|
||||
"hubSubtitleSingle": "Audio, subtitle and library options saved on ${account}",
|
||||
"hubSubtitleMultiple": "Audio, subtitle and library options saved on ${count} accounts",
|
||||
"pickAccount": "Every account stores its own preferences. Pick the one to edit.",
|
||||
"storedOnAccount": "These options are saved on the account itself, so every app signed in to it uses them — including Plezy on your other devices.",
|
||||
"noAccounts": "No accounts to configure",
|
||||
"noAccountsHint": "Sign in to Plex, or connect a Jellyfin or Emby server, and the preferences stored on that account show up here.",
|
||||
"unavailable": "Can't reach this account",
|
||||
"loadFailed": "Couldn't load these preferences",
|
||||
"noPreference": "No preference",
|
||||
"notSet": "Not set",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Audio & subtitles",
|
||||
"libraryDisplay": "Library",
|
||||
"personalMedia": "Personal media"
|
||||
},
|
||||
"preferredAudioLanguage": "Preferred audio language",
|
||||
"autoSelectAudio": "Choose audio by language",
|
||||
"autoSelectAudioDescription": "Off keeps whatever audio track the file marks as default.",
|
||||
"preferredSubtitleLanguage": "Preferred subtitle language",
|
||||
"subtitleMode": "Turn subtitles on",
|
||||
"subtitleModes": {
|
||||
"none": "Manually selected",
|
||||
"noneDescription": "Never turn subtitles on by itself.",
|
||||
"defaultMode": "Follow the track flags",
|
||||
"defaultModeDescription": "Use the default and forced flags stored on each subtitle track.",
|
||||
"always": "Always enabled",
|
||||
"alwaysDescription": "Turn on a subtitle track in the preferred language whenever one exists.",
|
||||
"onlyForced": "Only forced subtitles",
|
||||
"onlyForcedDescription": "Load only the tracks marked as forced.",
|
||||
"smart": "Shown with foreign audio",
|
||||
"smartDescription": "Turn subtitles on only when the audio is in another language."
|
||||
},
|
||||
"subtitleAccessibility": "SDH subtitles",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Prefer non-SDH subtitles",
|
||||
"preferSdh": "Prefer SDH subtitles",
|
||||
"onlySdh": "Only SDH subtitles",
|
||||
"onlyNonSdh": "Only non-SDH subtitles"
|
||||
},
|
||||
"forcedSubtitles": "Forced subtitles",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Prefer non-forced subtitles",
|
||||
"preferForced": "Prefer forced subtitles",
|
||||
"onlyForced": "Only forced subtitles",
|
||||
"onlyNonForced": "Only non-forced subtitles"
|
||||
},
|
||||
"displayMissingEpisodes": "Show missing episodes",
|
||||
"displayMissingEpisodesDescription": "List episodes the server knows about but has no file for.",
|
||||
"hidePlayedInLatest": "Hide watched items in Latest",
|
||||
"hidePlayedInLatestDescription": "Leave items you have already watched out of the server's Latest rows.",
|
||||
"displayCollectionsView": "Show the Collections view",
|
||||
"displayCollectionsViewDescription": "Offer the server's Collections view alongside your libraries.",
|
||||
"rewatchingInNextUp": "Keep rewatched shows in Next Up",
|
||||
"rewatchingInNextUpDescription": "Once you finish a show, start it again and Next Up follows the rewatch instead of dropping the show.",
|
||||
"watchedIndicator": "Watched indicators",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Never",
|
||||
"moviesAndShows": "Movies and TV shows",
|
||||
"movies": "Movies only",
|
||||
"shows": "TV shows only"
|
||||
},
|
||||
"mediaReviewsVisibility": "Ratings & reviews",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Users and critics",
|
||||
"usersOnly": "Users only",
|
||||
"criticsOnly": "Critics only",
|
||||
"nobody": "Hidden"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Discover",
|
||||
"noContentAvailable": "No content available",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "No servers found for ${username} (${email})",
|
||||
"failedToLoadServers": "Failed to load servers: ${error}"
|
||||
"failedToLoadServers": "Failed to load servers: ${error}",
|
||||
"noValidServers": "No usable servers were found on this account"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Title",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} popular",
|
||||
"rankAiring": "#${n} airing",
|
||||
"rankRated": "#${n} rated",
|
||||
"rankFavorited": "#${n} favorited",
|
||||
"rankTrending": "#${n} trending",
|
||||
"rankSeasonal": "#${n} in ${season}",
|
||||
"watchingNow": "${n} watching",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Unknown channel",
|
||||
"live": "LIVE",
|
||||
"reloadGuide": "Reload Guide",
|
||||
"searchGuide": "Search Guide",
|
||||
"searchHint": "Search channels & programs",
|
||||
"searchNoResults": "No matches for \"${query}\"",
|
||||
"channelsSection": "Channels",
|
||||
"programsSection": "Programs",
|
||||
"now": "Now",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Guide refresh requested",
|
||||
"rulesProcessRequested": "Rule re-evaluation requested",
|
||||
"recordShow": "Record show",
|
||||
"recordSettings": {
|
||||
"startEarly": "Start early (seconds)",
|
||||
"endLate": "End late (seconds)",
|
||||
"newOnly": "Only new episodes",
|
||||
"anyChannel": "Record on any channel",
|
||||
"anyTime": "Record at any time",
|
||||
"skipInLibrary": "Skip episodes already in the library",
|
||||
"keepUpTo": "Episodes to keep",
|
||||
"keepUpToHint": "0 keeps every episode"
|
||||
},
|
||||
"startingInMinutes": "Starting in ${minutes} min",
|
||||
"dayAtTime": "${day} at ${time}",
|
||||
"invalidPlaybackData": "${product} returned invalid Live TV playback data",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Repeat all",
|
||||
"repeatOne": "Repeat one",
|
||||
"instantMixNoServer": "No server is available for an instant mix",
|
||||
"noAudioUrl": "No audio URL is available for ${track}"
|
||||
"instantMixFailed": "Failed to load the instant mix",
|
||||
"instantMixEmpty": "The instant mix came back with no tracks",
|
||||
"noAudioUrl": "No audio URL is available for ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Singles & EPs",
|
||||
"live": "Live",
|
||||
"compilations": "Compilations"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Watch Together",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "You are the host",
|
||||
"makeHost": "Make host",
|
||||
"makeHostQuestion": "Transfer host?",
|
||||
"makeHostConfirm": "${name} will control playback and drive the session for everyone.",
|
||||
"transfer": "Transfer",
|
||||
"hostChangedTo": "${name} is now the host",
|
||||
"youAreNowHost": "You are now the host",
|
||||
"hostTransferFailed": "Couldn't make ${name} the host",
|
||||
"watchingWithOthers": "Watching with others",
|
||||
"endSession": "End Session",
|
||||
"leaveSession": "Leave Session",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Remove",
|
||||
"guestSwitchUnavailable": "Couldn't switch — server unavailable for sync",
|
||||
"guestSwitchFailed": "Couldn't switch — content not found on this server",
|
||||
"defaultDisplayName": "User"
|
||||
"defaultDisplayName": "User",
|
||||
"errors": {
|
||||
"timedOut": "The relay did not respond in time",
|
||||
"connectionLost": "The connection closed before the session was ready",
|
||||
"invalidRelayResponse": "The relay sent an unexpected response",
|
||||
"sessionEnded": "The host ended the session"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Use your mobile device to control this app",
|
||||
"startServer": "Start Server",
|
||||
"stopServer": "Stop Server",
|
||||
"minimize": "Minimize"
|
||||
"minimize": "Minimize",
|
||||
"manualAddressHint": "Manual connection address:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Plezy devices with the same Plex account appear here",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Decoder",
|
||||
"rawDecoder": "Raw Decoder",
|
||||
"tunneling": "Tunneling",
|
||||
"passthrough": "Passthrough",
|
||||
"aspect": "Aspect",
|
||||
"rotation": "Rotation",
|
||||
"dvSource": "DV Source",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Producer",
|
||||
"country": "Country",
|
||||
"collection": "Collection",
|
||||
"label": "Label",
|
||||
"style": "Style",
|
||||
"mood": "Mood"
|
||||
"label": "Label"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Match...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Partially available",
|
||||
"statusRequested": "Requested",
|
||||
"statusProcessing": "Processing",
|
||||
"statusBlocklisted": "Blocklisted",
|
||||
"couldNotReach": "Could not reach ${url}: ${error}",
|
||||
"noInstanceAtUrl": "No Seerr instance at ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Enter a server address like https://seerr.example.com",
|
||||
"quickConnectUnsupported": "This Seerr instance does not support Quick Connect. It needs Seerr 3.4 or newer.",
|
||||
"notInitialized": "This Seerr instance has not completed first-run setup",
|
||||
"noPlexTokenForReauth": "No Plex token is available to sign in again",
|
||||
"noStoredCredentials": "No stored credentials are available to sign in again",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"hubSubtitle": "Sync watch progress and request new titles.",
|
||||
"integrations": "Integrations",
|
||||
"notConnected": "Not connected",
|
||||
"connectedAs": "Connected as @${username}",
|
||||
"scrobble": "Track progress automatically",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Reuse another profile's connection. PIN-protected profiles require a PIN.",
|
||||
"invalidCredentials": "Invalid username or password",
|
||||
"authResponseNotJson": "The authentication response was not valid JSON",
|
||||
"authResponseIncomplete": "The sign-in response from the server was incomplete",
|
||||
"quickConnectRejected": "Quick Connect was rejected by the server",
|
||||
"quickConnectNotJson": "The Quick Connect response was not valid JSON",
|
||||
"quickConnectMissingFields": "The Quick Connect response is missing a code or secret",
|
||||
|
||||
+192
-27
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Usar navegador",
|
||||
"or": "o",
|
||||
"connectToMediaBrowser": "Conectar a ${product}",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Usar Quick Connect",
|
||||
"quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.",
|
||||
"quickConnectWaiting": "Esperando aprobación…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Silenciar",
|
||||
"ok": "OK",
|
||||
"off": "Desactivado",
|
||||
"options": "Opciones",
|
||||
"seasonNumber": "Temporada ${number}",
|
||||
"episodeNumberTitle": "Episodio ${number} - ${title}",
|
||||
"chapterNumber": "Capítulo ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/D",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Película",
|
||||
"show": "Serie",
|
||||
"season": "Temporada",
|
||||
"episode": "Episodio",
|
||||
"artist": "Artista",
|
||||
"album": "Álbum",
|
||||
"track": "Pista",
|
||||
"collection": "Colección",
|
||||
"playlist": "Lista de reproducción",
|
||||
"clip": "Clip",
|
||||
"photo": "Foto",
|
||||
"folder": "Carpeta"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licencias",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Escala de visualización",
|
||||
"compact": "Compacto",
|
||||
"comfortable": "Cómodo",
|
||||
"gridSpacing": "Espaciado de la cuadrícula",
|
||||
"gridSpacingTight": "Ajustado",
|
||||
"gridSpacingNormal": "Normal",
|
||||
"gridSpacingSpacious": "Espacioso",
|
||||
"tvCornerSpotlightBackdrop": "Imagen destacada en la esquina",
|
||||
"tvCornerSpotlightBackdropDescription": "Mostrar la imagen destacada en la esquina superior derecha en lugar de ocupar toda la pantalla",
|
||||
"viewMode": "Modo de vista",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Decodificación por hardware",
|
||||
"hardwareDecodingDescription": "Usar aceleración por hardware cuando esté disponible",
|
||||
"bufferSize": "Tamaño del búfer",
|
||||
"bufferSizeMB": "${size} MB",
|
||||
"bufferSizeAuto": "Automático (recomendado)",
|
||||
"bufferSizeWarning": "${heap} MB de memoria disponible. Un búfer de ${size} MB puede afectar a la reproducción.",
|
||||
"playbackBuffer": "Búfer de reproducción",
|
||||
"playbackBufferAuto": "Automático (recomendado)",
|
||||
"playbackBufferLarge": "Grande",
|
||||
"playbackBufferExtraLarge": "Extra grande",
|
||||
"playbackBufferDescription": "Almacena más en búfer para conexiones inestables. También limitado por el tamaño del búfer.",
|
||||
"defaultQualityTitle": "Calidad predeterminada",
|
||||
"cellularQualityTitle": "Calidad predeterminada en datos móviles",
|
||||
"cellularQualitySameAsDefault": "Igual que la calidad predeterminada",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Calidad de música",
|
||||
"subtitleStyling": "Estilo de subtítulos",
|
||||
"subtitleStylingDescription": "Personalizar la apariencia de los subtítulos",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Recordar opciones de audio y subtítulos por título",
|
||||
"followServerTrackSelections": "Usar la selección de pistas del servidor por episodio",
|
||||
"followServerTrackSelectionsDescription": "Al cambiar de episodio, aplicar el audio y los subtítulos seleccionados en el servidor en lugar de mantener la elección actual",
|
||||
"resumeMusicOnLaunch": "Recordar la sesión de música",
|
||||
"resumeMusicOnLaunchDescription": "Al iniciar la aplicación, reabrir la última canción en pausa donde se quedó",
|
||||
"showChapterMarkersOnTimeline": "Mostrar marcadores de capítulos en la barra de progreso",
|
||||
"showChapterMarkersOnTimelineDescription": "Dividir la barra de progreso en los límites de capítulos",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Especiales en orden de episodios",
|
||||
"specialsOrderingDescription": "Dónde se reproducen los especiales en el orden de visualización de una serie",
|
||||
"specialsOrderingServer": "Seguir orden del servidor",
|
||||
"specialsOrderingAirDate": "Intercalar por fecha de emisión",
|
||||
"specialsOrderingLast": "Después de las temporadas normales",
|
||||
"clickVideoTogglesPlayback": "Clic en el video para reproducir/pausar",
|
||||
"clickVideoTogglesPlaybackDescription": "Haz clic en el video para reproducir/pausar en vez de mostrar controles.",
|
||||
"videoPlayerControls": "Controles del reproductor de video",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "El atajo ya está asignado a ${action}",
|
||||
"shortcutUpdated": "Atajo actualizado para ${action}",
|
||||
"saveFailed": "No se pudieron guardar los cambios. Inténtalo de nuevo.",
|
||||
"autoSkip": "Salto automático",
|
||||
"autoPlayAndSkip": "Reproducción automática y saltos",
|
||||
"autoPlayNextEpisode": "Reproducción automática del siguiente episodio",
|
||||
"autoPlayNextEpisodeDescription": "Iniciar automáticamente el siguiente episodio cuando termine el actual",
|
||||
"playNextCountdown": "Cuenta atrás para el siguiente episodio",
|
||||
"playNextCountdownImmediate": "Reproducir de inmediato",
|
||||
"autoSkipIntro": "Saltar introducción automáticamente",
|
||||
"autoSkipIntroDescription": "Saltar automáticamente los marcadores de introducción después de unos segundos",
|
||||
"autoSkipCredits": "Saltar créditos automáticamente",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Activar automáticamente el modo de imagen en imagen al salir de la aplicación durante la reproducción",
|
||||
"matchContentFrameRate": "Ajustar frecuencia de actualización",
|
||||
"matchContentFrameRateDescription": "Ajustar la frecuencia de pantalla al contenido de video",
|
||||
"matchContentResolution": "Ajustar a la resolución del contenido",
|
||||
"matchContentResolutionDescription": "Cambia la pantalla a la resolución nativa del vídeo para que tu televisor se encargue del escalado. Los menús y los subtítulos también se escalan durante la reproducción",
|
||||
"matchRefreshRate": "Ajustar frecuencia de refresco",
|
||||
"matchRefreshRateDescription": "Ajustar la frecuencia de pantalla en pantalla completa",
|
||||
"matchDynamicRange": "Ajustar rango dinámico",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Forzar DV7 nativo y suprimir el reintento de conversión DV",
|
||||
"dvConversionDv81Description": "Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision",
|
||||
"dvConversionHevcStripDescription": "Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional",
|
||||
"deinterlace": "Desentrelazado",
|
||||
"deinterlaceDescription": "Elimina los artefactos de peine del video entrelazado (solo reproductor mpv)",
|
||||
"requireProfileSelectionOnOpen": "Pedir perfil al abrir la aplicación",
|
||||
"requireProfileSelectionOnOpenDescription": "Mostrar selección de perfil cada vez que se abre la aplicación",
|
||||
"forceTvMode": "Forzar modo TV",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Muestra la pestaña Explorar con contenido de Plex Discover y servicios de seguimiento conectados",
|
||||
"liveTvDefaultFavorites": "Canales favoritos por defecto",
|
||||
"liveTvDefaultFavoritesDescription": "Mostrar solo canales favoritos al abrir TV en vivo",
|
||||
"general": "General",
|
||||
"generalDescription": "Idioma, inicio y comportamiento de la ventana",
|
||||
"languageAndRegion": "Idioma y región",
|
||||
"startup": "Inicio",
|
||||
"display": "Pantalla",
|
||||
"libraryAndCards": "Biblioteca y tarjetas",
|
||||
"homeScreen": "Pantalla de inicio",
|
||||
"navigation": "Navegación",
|
||||
"window": "Ventana",
|
||||
"content": "Contenido",
|
||||
"liveTv": "TV en vivo",
|
||||
"player": "Reproductor",
|
||||
"subtitlesAndConfig": "Subtítulos y configuración",
|
||||
"videoAndDisplay": "Video y pantalla",
|
||||
"audio": "Audio",
|
||||
"quality": "Calidad",
|
||||
"subtitles": "Subtítulos",
|
||||
"seekAndTiming": "Desplazamiento y tiempos",
|
||||
"behavior": "Comportamiento",
|
||||
"gestures": "Gestos",
|
||||
"gestureBrightnessSwipe": "Gesto de brillo",
|
||||
"gestureBrightnessSwipeDescription": "Desliza hacia arriba o abajo en el borde izquierdo para ajustar el brillo",
|
||||
"gestureVolumeSwipe": "Gesto de volumen",
|
||||
"gestureVolumeSwipeDescription": "Desliza hacia arriba o abajo en el borde derecho para ajustar el volumen",
|
||||
"gesturePinchToZoom": "Pellizcar para hacer zoom",
|
||||
"gesturePinchToZoomDescription": "Pellizca el video para acercar o alejar",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Controles",
|
||||
"rememberPlayerChanges": "Recordar cambios del reproductor",
|
||||
"rememberPlayerChangesDescription": "Dónde se guarda y se vuelve a aplicar un cambio realizado durante la reproducción",
|
||||
"scopePlaybackSpeed": "Velocidad de reproducción",
|
||||
"scopeShaderPreset": "Preajuste de sombreado",
|
||||
"scopeAspectRatio": "Relación de aspecto",
|
||||
"scopeSyncOffsets": "Sincronización de audio y subtítulos",
|
||||
"playerScopeOff": "No guardar",
|
||||
"playerScopeGlobal": "En todas partes",
|
||||
"playerScopeLibrary": "Por biblioteca",
|
||||
"playerScopeTitle": "Por serie o película",
|
||||
"exportDialogTitle": "Exportar ajustes de Plezy"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Subtítulos",
|
||||
"resetToZero": "Restablecer a 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} se reproduce más tarde",
|
||||
"playsEarlier": "${label} se reproduce antes",
|
||||
"noOffset": "Sin desfase",
|
||||
"letterbox": "Con bandas negras",
|
||||
"fillScreen": "Llenar pantalla",
|
||||
"stretch": "Estirar",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "El dispositivo no admite el modo de imagen en imagen",
|
||||
"voSwitchFailed": "No se pudo cambiar la salida de video para el modo de imagen en imagen",
|
||||
"failed": "No se pudo iniciar el modo de imagen en imagen",
|
||||
"prepareFailed": "No se pudo preparar el modo de imagen en imagen",
|
||||
"unknown": "Ocurrió un error: ${error}"
|
||||
},
|
||||
"chapters": "Capítulos",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Editar conexión de ${product}",
|
||||
"editMediaBrowserIntro": "Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Preferencias de cuenta",
|
||||
"hubSubtitleSingle": "Opciones de audio, subtítulos y biblioteca guardadas en ${account}",
|
||||
"hubSubtitleMultiple": "Opciones de audio, subtítulos y biblioteca guardadas en ${count} cuentas",
|
||||
"pickAccount": "Cada cuenta guarda sus propias preferencias. Elige la que quieras editar.",
|
||||
"storedOnAccount": "Estas opciones se guardan en la propia cuenta, así que cualquier aplicación con la sesión iniciada las usa, incluido Plezy en tus otros dispositivos.",
|
||||
"noAccounts": "No hay cuentas que configurar",
|
||||
"noAccountsHint": "Inicia sesión con Plex o conecta un servidor de Jellyfin o Emby y las preferencias guardadas en esa cuenta aparecerán aquí.",
|
||||
"unavailable": "No se puede acceder a esta cuenta",
|
||||
"loadFailed": "No se pudieron cargar estas preferencias",
|
||||
"noPreference": "Sin preferencia",
|
||||
"notSet": "No establecido",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Audio y subtítulos",
|
||||
"libraryDisplay": "Biblioteca",
|
||||
"personalMedia": "Medios personales"
|
||||
},
|
||||
"preferredAudioLanguage": "Idioma de audio preferido",
|
||||
"autoSelectAudio": "Elegir audio por idioma",
|
||||
"autoSelectAudioDescription": "Desactivado mantiene la pista de audio que el archivo marca como predeterminada.",
|
||||
"preferredSubtitleLanguage": "Idioma de subtítulos preferido",
|
||||
"subtitleMode": "Activar subtítulos",
|
||||
"subtitleModes": {
|
||||
"none": "Seleccionado manualmente",
|
||||
"noneDescription": "No activar los subtítulos por sí solo.",
|
||||
"defaultMode": "Seguir las marcas de la pista",
|
||||
"defaultModeDescription": "Usar las marcas de predeterminada y forzada guardadas en cada pista de subtítulos.",
|
||||
"always": "Siempre activado",
|
||||
"alwaysDescription": "Activar una pista de subtítulos en el idioma preferido siempre que exista.",
|
||||
"onlyForced": "Solo subtítulos forzados",
|
||||
"onlyForcedDescription": "Cargar solo las pistas marcadas como forzadas.",
|
||||
"smart": "Mostrar con audio extranjero",
|
||||
"smartDescription": "Activar los subtítulos solo cuando el audio está en otro idioma."
|
||||
},
|
||||
"subtitleAccessibility": "Subtítulos SDH",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Preferir subtítulos no SDH",
|
||||
"preferSdh": "Preferir subtítulos SDH",
|
||||
"onlySdh": "Solo subtítulos SDH",
|
||||
"onlyNonSdh": "Solo subtítulos no SDH"
|
||||
},
|
||||
"forcedSubtitles": "Subtítulos forzados",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Preferir subtítulos no forzados",
|
||||
"preferForced": "Preferir subtítulos forzados",
|
||||
"onlyForced": "Solo subtítulos forzados",
|
||||
"onlyNonForced": "Solo subtítulos no forzados"
|
||||
},
|
||||
"displayMissingEpisodes": "Mostrar episodios faltantes",
|
||||
"displayMissingEpisodesDescription": "Enumera los episodios que el servidor conoce pero para los que no tiene ningún archivo.",
|
||||
"hidePlayedInLatest": "Ocultar elementos vistos en Recientes",
|
||||
"hidePlayedInLatestDescription": "Deja fuera de las filas de Recientes del servidor los elementos que ya has visto.",
|
||||
"displayCollectionsView": "Mostrar la vista de colecciones",
|
||||
"displayCollectionsViewDescription": "Ofrecer la vista de colecciones del servidor junto a tus bibliotecas.",
|
||||
"rewatchingInNextUp": "Mantener las series revistas en A continuación",
|
||||
"rewatchingInNextUpDescription": "Cuando termines una serie y la vuelvas a empezar, A continuación seguirá tu revisión en lugar de descartarla.",
|
||||
"watchedIndicator": "Indicadores de visto",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Nunca",
|
||||
"moviesAndShows": "Películas y series de TV",
|
||||
"movies": "Solo películas",
|
||||
"shows": "Solo series de TV"
|
||||
},
|
||||
"mediaReviewsVisibility": "Valoraciones y reseñas",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Usuarios y críticos",
|
||||
"usersOnly": "Solo usuarios",
|
||||
"criticsOnly": "Solo críticos",
|
||||
"nobody": "Ocultos"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Descubrir",
|
||||
"noContentAvailable": "No hay contenido disponible",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "No se encontraron servidores para ${username} (${email})",
|
||||
"failedToLoadServers": "Error al cargar servidores: ${error}"
|
||||
"failedToLoadServers": "Error al cargar servidores: ${error}",
|
||||
"noValidServers": "No se encontraron servidores utilizables en esta cuenta"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Título",
|
||||
@@ -1132,10 +1258,9 @@
|
||||
"searchPrompt": "Busca películas y series en ${source}.",
|
||||
"searchFailed": "La búsqueda falló. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"badge": {
|
||||
"rankPopular": "#${n} popular",
|
||||
"rankPopular": "#${n} más popular",
|
||||
"rankAiring": "#${n} en emisión",
|
||||
"rankRated": "#${n} puntuado",
|
||||
"rankFavorited": "#${n} favorito",
|
||||
"rankTrending": "#${n} en tendencia",
|
||||
"rankSeasonal": "#${n} en ${season}",
|
||||
"watchingNow": "${n} viendo",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Canal desconocido",
|
||||
"live": "EN VIVO",
|
||||
"reloadGuide": "Recargar guía",
|
||||
"searchGuide": "Buscar en la guía",
|
||||
"searchHint": "Buscar canales y programas",
|
||||
"searchNoResults": "Sin coincidencias para \"${query}\"",
|
||||
"channelsSection": "Canales",
|
||||
"programsSection": "Programas",
|
||||
"now": "Ahora",
|
||||
"today": "Hoy",
|
||||
"tomorrow": "Mañana",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Solicitada actualización de la guía",
|
||||
"rulesProcessRequested": "Solicitada reevaluación de reglas",
|
||||
"recordShow": "Grabar programa",
|
||||
"recordSettings": {
|
||||
"startEarly": "Empezar antes (segundos)",
|
||||
"endLate": "Terminar después (segundos)",
|
||||
"newOnly": "Solo episodios nuevos",
|
||||
"anyChannel": "Grabar en cualquier canal",
|
||||
"anyTime": "Grabar a cualquier hora",
|
||||
"skipInLibrary": "Omitir episodios que ya están en la biblioteca",
|
||||
"keepUpTo": "Episodios que conservar",
|
||||
"keepUpToHint": "0 conserva todos los episodios"
|
||||
},
|
||||
"startingInMinutes": "Empieza en ${minutes} min",
|
||||
"dayAtTime": "${day} a las ${time}",
|
||||
"invalidPlaybackData": "${product} devolvió datos de reproducción de TV en vivo no válidos",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Repetir todo",
|
||||
"repeatOne": "Repetir una",
|
||||
"instantMixNoServer": "No hay ningún servidor disponible para una mezcla instantánea",
|
||||
"noAudioUrl": "No hay ninguna URL de audio disponible para ${track}"
|
||||
"instantMixFailed": "Error al cargar la mezcla instantánea",
|
||||
"instantMixEmpty": "La mezcla instantánea no devolvió ninguna canción",
|
||||
"noAudioUrl": "No hay ninguna URL de audio disponible para ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Sencillos y EP",
|
||||
"live": "En directo",
|
||||
"compilations": "Recopilaciones"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Ver juntos",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Anfitrión",
|
||||
"hostBadge": "ANFITRIÓN",
|
||||
"youAreHost": "Eres el anfitrión",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Viendo contenido con otras personas",
|
||||
"endSession": "Finalizar sesión",
|
||||
"leaveSession": "Salir de la sesión",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Eliminar",
|
||||
"guestSwitchUnavailable": "No se pudo cambiar — servidor no disponible para sincronización",
|
||||
"guestSwitchFailed": "No se pudo cambiar — contenido no encontrado en este servidor",
|
||||
"defaultDisplayName": "Usuario"
|
||||
"defaultDisplayName": "Usuario",
|
||||
"errors": {
|
||||
"timedOut": "El servidor de retransmisión no respondió a tiempo",
|
||||
"connectionLost": "La conexión se cerró antes de que la sesión estuviera lista",
|
||||
"invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada",
|
||||
"sessionEnded": "El anfitrión finalizó la sesión"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Descargas",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Usa tu dispositivo móvil para controlar esta aplicación",
|
||||
"startServer": "Iniciar servidor",
|
||||
"stopServer": "Detener servidor",
|
||||
"minimize": "Minimizar"
|
||||
"minimize": "Minimizar",
|
||||
"manualAddressHint": "Dirección de conexión manual:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Los dispositivos Plezy con la misma cuenta Plex aparecen aquí",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Decodificador",
|
||||
"rawDecoder": "Decodificador sin procesar",
|
||||
"tunneling": "Túnel",
|
||||
"passthrough": "Transferencia directa",
|
||||
"aspect": "Aspecto",
|
||||
"rotation": "Rotación",
|
||||
"dvSource": "Origen DV",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Productor",
|
||||
"country": "País",
|
||||
"collection": "Colección",
|
||||
"label": "Etiqueta",
|
||||
"style": "Estilo",
|
||||
"mood": "Estado de ánimo"
|
||||
"label": "Etiqueta"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Asociar...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Parcialmente disponible",
|
||||
"statusRequested": "Solicitado",
|
||||
"statusProcessing": "Procesando",
|
||||
"statusBlocklisted": "En la lista de bloqueo",
|
||||
"couldNotReach": "No se pudo conectar con ${url}: ${error}",
|
||||
"noInstanceAtUrl": "No hay ninguna instancia de Seerr en ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Introduce una dirección de servidor como https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Esta instancia de Seerr no admite Quick Connect. Necesita Seerr 3.4 o más reciente.",
|
||||
"notInitialized": "Esta instancia de Seerr no ha completado la configuración inicial",
|
||||
"noPlexTokenForReauth": "No hay ningún token de Plex disponible para volver a iniciar sesión",
|
||||
"noStoredCredentials": "No hay credenciales guardadas disponibles para volver a iniciar sesión",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Servicios",
|
||||
"hubSubtitle": "Sincroniza tu progreso de visualización y solicita nuevos títulos.",
|
||||
"integrations": "Integraciones",
|
||||
"notConnected": "No conectado",
|
||||
"connectedAs": "Conectado como @${username}",
|
||||
"scrobble": "Registrar progreso automáticamente",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN.",
|
||||
"invalidCredentials": "Usuario o contraseña no válidos",
|
||||
"authResponseNotJson": "La respuesta de autenticación no era un JSON válido",
|
||||
"authResponseIncomplete": "La respuesta de inicio de sesión del servidor estaba incompleta",
|
||||
"quickConnectRejected": "Quick Connect fue rechazado por el servidor",
|
||||
"quickConnectNotJson": "La respuesta de Quick Connect no era un JSON válido",
|
||||
"quickConnectMissingFields": "La respuesta de Quick Connect no incluye el código o el secreto",
|
||||
|
||||
+192
-27
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Utiliser le navigateur",
|
||||
"or": "ou",
|
||||
"connectToMediaBrowser": "Se connecter à ${product}",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Utiliser Quick Connect",
|
||||
"quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.",
|
||||
"quickConnectWaiting": "En attente d'approbation…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Couper le son",
|
||||
"ok": "OK",
|
||||
"off": "Désactivé",
|
||||
"options": "Options",
|
||||
"seasonNumber": "Saison ${number}",
|
||||
"episodeNumberTitle": "Épisode ${number} – ${title}",
|
||||
"chapterNumber": "Chapitre ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Film",
|
||||
"show": "Série",
|
||||
"season": "Saison",
|
||||
"episode": "Épisode",
|
||||
"artist": "Artiste",
|
||||
"album": "Album",
|
||||
"track": "Titre",
|
||||
"collection": "Collection",
|
||||
"playlist": "Playlist",
|
||||
"clip": "Clip",
|
||||
"photo": "Photo",
|
||||
"folder": "Dossier"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licences",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Échelle d'affichage",
|
||||
"compact": "Compact",
|
||||
"comfortable": "Confortable",
|
||||
"gridSpacing": "Espacement de la grille",
|
||||
"gridSpacingTight": "Resserré",
|
||||
"gridSpacingNormal": "Normal",
|
||||
"gridSpacingSpacious": "Aéré",
|
||||
"tvCornerSpotlightBackdrop": "Illustration en vedette dans le coin",
|
||||
"tvCornerSpotlightBackdropDescription": "Afficher l’illustration en vedette dans le coin supérieur droit plutôt qu’en plein écran",
|
||||
"viewMode": "Mode d'affichage",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Décodage matériel",
|
||||
"hardwareDecodingDescription": "Utiliser l’accélération matérielle lorsqu’elle est disponible",
|
||||
"bufferSize": "Taille du tampon",
|
||||
"bufferSizeMB": "${size} Mo",
|
||||
"bufferSizeAuto": "Automatique (recommandé)",
|
||||
"bufferSizeWarning": "${heap} Mo de mémoire disponible. Un tampon de ${size} Mo peut affecter la lecture.",
|
||||
"playbackBuffer": "Tampon de lecture",
|
||||
"playbackBufferAuto": "Auto (recommandé)",
|
||||
"playbackBufferLarge": "Grand",
|
||||
"playbackBufferExtraLarge": "Très grand",
|
||||
"playbackBufferDescription": "Tampon supplémentaire contre les connexions instables. Également limité par la taille du tampon.",
|
||||
"defaultQualityTitle": "Qualité par défaut",
|
||||
"cellularQualityTitle": "Qualité par défaut sur les données mobiles",
|
||||
"cellularQualitySameAsDefault": "Identique à la qualité par défaut",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Qualité de la musique",
|
||||
"subtitleStyling": "Style des sous-titres",
|
||||
"subtitleStylingDescription": "Personnaliser l’apparence des sous-titres",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Mémoriser les choix audio et sous-titres par titre",
|
||||
"followServerTrackSelections": "Utiliser les pistes sélectionnées sur le serveur pour chaque épisode",
|
||||
"followServerTrackSelectionsDescription": "Au changement d'épisode, appliquer l'audio et les sous-titres sélectionnés sur le serveur au lieu de conserver le choix en cours",
|
||||
"resumeMusicOnLaunch": "Mémoriser la session musicale",
|
||||
"resumeMusicOnLaunchDescription": "Au lancement de l'application, rouvrir le dernier titre en pause là où il s'était arrêté",
|
||||
"showChapterMarkersOnTimeline": "Afficher les marqueurs de chapitres sur la barre de lecture",
|
||||
"showChapterMarkersOnTimelineDescription": "Segmenter la barre de lecture aux limites des chapitres",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Épisodes spéciaux dans l’ordre des épisodes",
|
||||
"specialsOrderingDescription": "Position des épisodes spéciaux dans l’ordre de visionnage d’une série",
|
||||
"specialsOrderingServer": "Suivre l’ordre du serveur",
|
||||
"specialsOrderingAirDate": "Intercaler selon la date de diffusion",
|
||||
"specialsOrderingLast": "Après les saisons normales",
|
||||
"clickVideoTogglesPlayback": "Cliquer sur la vidéo pour alterner entre lecture et pause",
|
||||
"clickVideoTogglesPlaybackDescription": "Cliquer sur la vidéo pour lire ou mettre en pause plutôt que d’afficher les commandes",
|
||||
"videoPlayerControls": "Commandes du lecteur vidéo",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Raccourci déjà attribué à ${action}",
|
||||
"shortcutUpdated": "Raccourci mis à jour pour ${action}",
|
||||
"saveFailed": "Impossible d’enregistrer les modifications. Réessayez.",
|
||||
"autoSkip": "Saut automatique",
|
||||
"autoPlayAndSkip": "Lecture automatique et saut",
|
||||
"autoPlayNextEpisode": "Lire automatiquement l'épisode suivant",
|
||||
"autoPlayNextEpisodeDescription": "Lancer automatiquement l'épisode suivant lorsqu'un épisode se termine",
|
||||
"playNextCountdown": "Compte à rebours avant l'épisode suivant",
|
||||
"playNextCountdownImmediate": "Lire immédiatement",
|
||||
"autoSkipIntro": "Passer automatiquement l’introduction",
|
||||
"autoSkipIntroDescription": "Passer automatiquement les marqueurs d’introduction après quelques secondes",
|
||||
"autoSkipCredits": "Passer automatiquement le générique",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Passer en mode image dans l’image si vous quittez l’application pendant la lecture",
|
||||
"matchContentFrameRate": "Adapter la fréquence d’images au contenu",
|
||||
"matchContentFrameRateDescription": "Adapter la fréquence de rafraîchissement de l’écran au contenu vidéo",
|
||||
"matchContentResolution": "Adapter à la résolution du contenu",
|
||||
"matchContentResolutionDescription": "Bascule l’affichage sur la résolution native de la vidéo pour que votre téléviseur gère la mise à l’échelle. Les menus et les sous-titres sont eux aussi mis à l’échelle pendant la lecture",
|
||||
"matchRefreshRate": "Adapter la fréquence de rafraîchissement",
|
||||
"matchRefreshRateDescription": "Adapter la fréquence d'affichage en plein écran",
|
||||
"matchDynamicRange": "Adapter la plage dynamique",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV",
|
||||
"dvConversionDv81Description": "Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision",
|
||||
"dvConversionHevcStripDescription": "Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple",
|
||||
"deinterlace": "Désentrelacement",
|
||||
"deinterlaceDescription": "Supprimer les artefacts de peignage des vidéos entrelacées (lecteur mpv uniquement)",
|
||||
"requireProfileSelectionOnOpen": "Demander le profil à l'ouverture",
|
||||
"requireProfileSelectionOnOpenDescription": "Afficher la sélection de profil à chaque ouverture de l'application",
|
||||
"forceTvMode": "Forcer le mode TV",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Afficher l’onglet Explorer avec le contenu de Plex Discover et des services de suivi connectés",
|
||||
"liveTvDefaultFavorites": "Chaînes favorites par défaut",
|
||||
"liveTvDefaultFavoritesDescription": "Afficher uniquement les chaînes favorites à l'ouverture de la TV en direct",
|
||||
"general": "Général",
|
||||
"generalDescription": "Langue, démarrage et comportement de la fenêtre",
|
||||
"languageAndRegion": "Langue et région",
|
||||
"startup": "Démarrage",
|
||||
"display": "Affichage",
|
||||
"libraryAndCards": "Bibliothèque et cartes",
|
||||
"homeScreen": "Écran d'accueil",
|
||||
"navigation": "Navigation",
|
||||
"window": "Fenêtre",
|
||||
"content": "Contenu",
|
||||
"liveTv": "TV en direct",
|
||||
"player": "Lecteur",
|
||||
"subtitlesAndConfig": "Sous-titres et configuration",
|
||||
"videoAndDisplay": "Vidéo et affichage",
|
||||
"audio": "Audio",
|
||||
"quality": "Qualité",
|
||||
"subtitles": "Sous-titres",
|
||||
"seekAndTiming": "Déplacement et minutage",
|
||||
"behavior": "Comportement",
|
||||
"gestures": "Gestes",
|
||||
"gestureBrightnessSwipe": "Balayage de luminosité",
|
||||
"gestureBrightnessSwipeDescription": "Balayez vers le haut ou le bas sur le bord gauche pour régler la luminosité",
|
||||
"gestureVolumeSwipe": "Balayage de volume",
|
||||
"gestureVolumeSwipeDescription": "Balayez vers le haut ou le bas sur le bord droit pour régler le volume",
|
||||
"gesturePinchToZoom": "Pincer pour zoomer",
|
||||
"gesturePinchToZoomDescription": "Pincez la vidéo pour zoomer ou dézoomer",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Commandes",
|
||||
"rememberPlayerChanges": "Mémoriser les modifications du lecteur",
|
||||
"rememberPlayerChangesDescription": "Où une modification effectuée pendant la lecture est enregistrée et réappliquée",
|
||||
"scopePlaybackSpeed": "Vitesse de lecture",
|
||||
"scopeShaderPreset": "Préréglage du shader",
|
||||
"scopeAspectRatio": "Format d’image",
|
||||
"scopeSyncOffsets": "Synchronisation audio et sous-titres",
|
||||
"playerScopeOff": "Ne pas enregistrer",
|
||||
"playerScopeGlobal": "Partout",
|
||||
"playerScopeLibrary": "Par bibliothèque",
|
||||
"playerScopeTitle": "Par série ou film",
|
||||
"exportDialogTitle": "Exporter les paramètres de Plezy"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Sous-titres",
|
||||
"resetToZero": "Réinitialiser à 0 ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} : lecture retardée",
|
||||
"playsEarlier": "${label} : lecture avancée",
|
||||
"noOffset": "Pas de décalage",
|
||||
"letterbox": "Format letterbox",
|
||||
"fillScreen": "Remplir l’écran",
|
||||
"stretch": "Étirer",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Cet appareil ne prend pas en charge le mode image dans l'image",
|
||||
"voSwitchFailed": "Échec du changement de sortie vidéo pour l'image dans l'image",
|
||||
"failed": "Échec du démarrage du mode image dans l'image",
|
||||
"prepareFailed": "Impossible de préparer le mode image dans l'image",
|
||||
"unknown": "Une erreur s'est produite : ${error}"
|
||||
},
|
||||
"chapters": "Chapitres",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Modifier la connexion ${product}",
|
||||
"editMediaBrowserIntro": "Ajoutez ou retirez des URL pour ${serverName}. Plezy utilisera l'URL joignable avec la latence la plus faible."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Préférences du compte",
|
||||
"hubSubtitleSingle": "Options audio, sous-titres et bibliothèque enregistrées sur ${account}",
|
||||
"hubSubtitleMultiple": "Options audio, sous-titres et bibliothèque enregistrées sur ${count} comptes",
|
||||
"pickAccount": "Chaque compte enregistre ses propres préférences. Choisissez celui à modifier.",
|
||||
"storedOnAccount": "Ces options sont enregistrées sur le compte lui-même : toutes les applications connectées les utilisent, y compris Plezy sur vos autres appareils.",
|
||||
"noAccounts": "Aucun compte à configurer",
|
||||
"noAccountsHint": "Connectez-vous à Plex, ou connectez un serveur Jellyfin ou Emby : les préférences enregistrées sur ce compte apparaîtront ici.",
|
||||
"unavailable": "Impossible de joindre ce compte",
|
||||
"loadFailed": "Impossible de charger ces préférences",
|
||||
"noPreference": "Aucune préférence",
|
||||
"notSet": "Non défini",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Audio et sous-titres",
|
||||
"libraryDisplay": "Bibliothèque",
|
||||
"personalMedia": "Médias personnels"
|
||||
},
|
||||
"preferredAudioLanguage": "Langue audio préférée",
|
||||
"autoSelectAudio": "Choisir l'audio par langue",
|
||||
"autoSelectAudioDescription": "Désactivé : la piste audio marquée par défaut dans le fichier est conservée.",
|
||||
"preferredSubtitleLanguage": "Langue de sous-titres préférée",
|
||||
"subtitleMode": "Activer les sous-titres",
|
||||
"subtitleModes": {
|
||||
"none": "Sélection manuelle",
|
||||
"noneDescription": "Ne jamais activer les sous-titres automatiquement.",
|
||||
"defaultMode": "Suivre les indicateurs des pistes",
|
||||
"defaultModeDescription": "Utiliser les indicateurs par défaut et forcés enregistrés sur chaque piste de sous-titres.",
|
||||
"always": "Toujours activé",
|
||||
"alwaysDescription": "Activer une piste de sous-titres dans la langue préférée lorsqu'elle existe.",
|
||||
"onlyForced": "Sous-titres forcés uniquement",
|
||||
"onlyForcedDescription": "Charger uniquement les pistes marquées comme forcées.",
|
||||
"smart": "Avec l'audio en langue étrangère",
|
||||
"smartDescription": "Activer les sous-titres uniquement lorsque l'audio est dans une autre langue."
|
||||
},
|
||||
"subtitleAccessibility": "Sous-titres SDH",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Préférer les sous-titres non SDH",
|
||||
"preferSdh": "Préférer les sous-titres SDH",
|
||||
"onlySdh": "Sous-titres SDH uniquement",
|
||||
"onlyNonSdh": "Sous-titres non SDH uniquement"
|
||||
},
|
||||
"forcedSubtitles": "Sous-titres forcés",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Préférer les sous-titres non forcés",
|
||||
"preferForced": "Préférer les sous-titres forcés",
|
||||
"onlyForced": "Sous-titres forcés uniquement",
|
||||
"onlyNonForced": "Sous-titres non forcés uniquement"
|
||||
},
|
||||
"displayMissingEpisodes": "Afficher les épisodes manquants",
|
||||
"displayMissingEpisodesDescription": "Répertorier les épisodes connus du serveur mais sans fichier.",
|
||||
"hidePlayedInLatest": "Masquer les éléments vus dans « Récemment ajouté »",
|
||||
"hidePlayedInLatestDescription": "Exclure les éléments déjà vus des lignes « Récemment ajouté » du serveur.",
|
||||
"displayCollectionsView": "Afficher la vue Collections",
|
||||
"displayCollectionsViewDescription": "Proposer la vue Collections du serveur en plus de vos bibliothèques.",
|
||||
"rewatchingInNextUp": "Conserver les séries revues dans « À suivre »",
|
||||
"rewatchingInNextUpDescription": "Une fois une série terminée, si vous la recommencez, « À suivre » suit le revisionnage au lieu de la retirer.",
|
||||
"watchedIndicator": "Indicateurs de visionnage",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Jamais",
|
||||
"moviesAndShows": "Films et séries TV",
|
||||
"movies": "Films uniquement",
|
||||
"shows": "Séries TV uniquement"
|
||||
},
|
||||
"mediaReviewsVisibility": "Notes et critiques",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Utilisateurs et critiques",
|
||||
"usersOnly": "Utilisateurs uniquement",
|
||||
"criticsOnly": "Critiques uniquement",
|
||||
"nobody": "Masquées"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Découvrir",
|
||||
"noContentAvailable": "Aucun contenu disponible",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Aucun serveur trouvé pour ${username} (${email})",
|
||||
"failedToLoadServers": "Échec du chargement des serveurs : ${error}"
|
||||
"failedToLoadServers": "Échec du chargement des serveurs : ${error}",
|
||||
"noValidServers": "Aucun serveur utilisable n’a été trouvé sur ce compte"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titre",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} populaire",
|
||||
"rankAiring": "#${n} en diffusion",
|
||||
"rankRated": "#${n} mieux noté",
|
||||
"rankFavorited": "#${n} favoris",
|
||||
"rankTrending": "#${n} tendance",
|
||||
"rankSeasonal": "#${n} de ${season}",
|
||||
"watchingNow": "${n} regardent",
|
||||
@@ -1237,7 +1362,7 @@
|
||||
"budget": "Budget",
|
||||
"revenue": "Box-office",
|
||||
"contentAdvisory": "Recommandation d'âge",
|
||||
"tags": "Tags",
|
||||
"tags": "Étiquettes",
|
||||
"revealSpoilerTags": "Afficher les tags spoiler",
|
||||
"links": "Liens",
|
||||
"watchOn": "Regarder sur",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Chaîne inconnue",
|
||||
"live": "EN DIRECT",
|
||||
"reloadGuide": "Recharger le guide",
|
||||
"searchGuide": "Rechercher dans le guide",
|
||||
"searchHint": "Rechercher des chaînes et des programmes",
|
||||
"searchNoResults": "Aucune correspondance pour \"${query}\"",
|
||||
"channelsSection": "Chaînes",
|
||||
"programsSection": "Programmes",
|
||||
"now": "Maintenant",
|
||||
"today": "Aujourd'hui",
|
||||
"tomorrow": "Demain",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Mise à jour du guide demandée",
|
||||
"rulesProcessRequested": "Réévaluation des règles demandée",
|
||||
"recordShow": "Enregistrer l'émission",
|
||||
"recordSettings": {
|
||||
"startEarly": "Démarrer en avance (secondes)",
|
||||
"endLate": "Terminer en retard (secondes)",
|
||||
"newOnly": "Uniquement les nouveaux épisodes",
|
||||
"anyChannel": "Enregistrer sur n’importe quelle chaîne",
|
||||
"anyTime": "Enregistrer à n’importe quelle heure",
|
||||
"skipInLibrary": "Ignorer les épisodes déjà présents dans la bibliothèque",
|
||||
"keepUpTo": "Épisodes à conserver",
|
||||
"keepUpToHint": "0 conserve tous les épisodes"
|
||||
},
|
||||
"startingInMinutes": "Commence dans ${minutes} min",
|
||||
"dayAtTime": "${day} à ${time}",
|
||||
"invalidPlaybackData": "${product} a renvoyé des données de lecture de TV en direct non valides",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Tout répéter",
|
||||
"repeatOne": "Répéter le titre",
|
||||
"instantMixNoServer": "Aucun serveur n’est disponible pour créer un mix instantané",
|
||||
"noAudioUrl": "Aucune URL audio n’est disponible pour ${track}"
|
||||
"instantMixFailed": "Impossible de charger le mix instantané",
|
||||
"instantMixEmpty": "Le mix instantané n'a renvoyé aucun titre",
|
||||
"noAudioUrl": "Aucune URL audio n’est disponible pour ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Singles et EP",
|
||||
"live": "Live",
|
||||
"compilations": "Compilations"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Regarder ensemble",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Hôte",
|
||||
"hostBadge": "HÔTE",
|
||||
"youAreHost": "Vous êtes l'hôte",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Regarder avec d'autres personnes",
|
||||
"endSession": "Terminer la session",
|
||||
"leaveSession": "Quitter la session",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Supprimer",
|
||||
"guestSwitchUnavailable": "Impossible de changer — serveur indisponible pour la synchronisation",
|
||||
"guestSwitchFailed": "Impossible de changer — contenu introuvable sur ce serveur",
|
||||
"defaultDisplayName": "Utilisateur"
|
||||
"defaultDisplayName": "Utilisateur",
|
||||
"errors": {
|
||||
"timedOut": "Le serveur relais n’a pas répondu à temps",
|
||||
"connectionLost": "La connexion s’est fermée avant que la session ne soit prête",
|
||||
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
|
||||
"sessionEnded": "L’hôte a mis fin à la session"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Téléchargements",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Utilisez votre appareil mobile pour contrôler cette application",
|
||||
"startServer": "Démarrer le serveur",
|
||||
"stopServer": "Arrêter le serveur",
|
||||
"minimize": "Réduire"
|
||||
"minimize": "Réduire",
|
||||
"manualAddressHint": "Adresse de connexion manuelle :"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Les appareils Plezy avec le même compte Plex apparaissent ici",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Décodeur",
|
||||
"rawDecoder": "Décodeur brut",
|
||||
"tunneling": "Tunnel",
|
||||
"passthrough": "Transmission directe",
|
||||
"aspect": "Format",
|
||||
"rotation": "Rotation",
|
||||
"dvSource": "Source DV",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Producteur",
|
||||
"country": "Pays",
|
||||
"collection": "Collection",
|
||||
"label": "Label",
|
||||
"style": "Style",
|
||||
"mood": "Ambiance"
|
||||
"label": "Label"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Associer...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Partiellement disponible",
|
||||
"statusRequested": "Demandé",
|
||||
"statusProcessing": "En cours de traitement",
|
||||
"statusBlocklisted": "Sur la liste de blocage",
|
||||
"couldNotReach": "Impossible de joindre ${url} : ${error}",
|
||||
"noInstanceAtUrl": "Aucune instance Seerr à l’adresse ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Saisissez une adresse de serveur comme https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Cette instance Seerr ne prend pas en charge Quick Connect. Elle nécessite Seerr 3.4 ou version ultérieure.",
|
||||
"notInitialized": "La configuration initiale de cette instance Seerr n’est pas terminée",
|
||||
"noPlexTokenForReauth": "Aucun jeton Plex n’est disponible pour se reconnecter",
|
||||
"noStoredCredentials": "Aucun identifiant enregistré n’est disponible pour se reconnecter",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"hubSubtitle": "Synchronisez votre progression et demandez de nouveaux titres.",
|
||||
"integrations": "Intégrations",
|
||||
"notConnected": "Non connecté",
|
||||
"connectedAs": "Connecté en tant que @${username}",
|
||||
"scrobble": "Suivre la progression automatiquement",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Réutiliser la connexion d'un autre profil. Les profils protégés par PIN exigent un PIN.",
|
||||
"invalidCredentials": "Nom d’utilisateur ou mot de passe incorrect",
|
||||
"authResponseNotJson": "La réponse d’authentification n’était pas au format JSON valide",
|
||||
"authResponseIncomplete": "La réponse de connexion du serveur était incomplète",
|
||||
"quickConnectRejected": "Quick Connect a été refusé par le serveur",
|
||||
"quickConnectNotJson": "La réponse de Quick Connect n’était pas au format JSON valide",
|
||||
"quickConnectMissingFields": "Il manque un code ou un secret dans la réponse de Quick Connect",
|
||||
|
||||
+191
-26
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Böngésző használata",
|
||||
"or": "vagy",
|
||||
"connectToMediaBrowser": "Csatlakozás a(z) ${product} szolgáltatáshoz",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Quick Connect használata",
|
||||
"quickConnectInstructions": "Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.",
|
||||
"quickConnectWaiting": "Várakozás a jóváhagyásra…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Némítás",
|
||||
"ok": "OK",
|
||||
"off": "Ki",
|
||||
"options": "Lehetőségek",
|
||||
"seasonNumber": "${number}. évad",
|
||||
"episodeNumberTitle": "${number}. epizód - ${title}",
|
||||
"chapterNumber": "${number}. fejezet",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Film",
|
||||
"show": "Sorozat",
|
||||
"season": "Évad",
|
||||
"episode": "Epizód",
|
||||
"artist": "Előadó",
|
||||
"album": "Album",
|
||||
"track": "Szám",
|
||||
"collection": "Gyűjtemény",
|
||||
"playlist": "Lejátszási lista",
|
||||
"clip": "Klip",
|
||||
"photo": "Fénykép",
|
||||
"folder": "Mappa"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licencek",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Kijelző méretezése",
|
||||
"compact": "Kompakt",
|
||||
"comfortable": "Kényelmes",
|
||||
"gridSpacing": "Rácstávolság",
|
||||
"gridSpacingTight": "Sűrű",
|
||||
"gridSpacingNormal": "Normál",
|
||||
"gridSpacingSpacious": "Tágas",
|
||||
"tvCornerSpotlightBackdrop": "Sarokban megjelenő kiemelt háttérkép",
|
||||
"tvCornerSpotlightBackdropDescription": "A kiemelt háttérkép megjelenítése a jobb felső sarokban a teljes képernyő helyett",
|
||||
"viewMode": "Nézetmód",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Hardveres dekódolás",
|
||||
"hardwareDecodingDescription": "Hardveres gyorsítás használata, ha elérhető",
|
||||
"bufferSize": "Puffer mérete",
|
||||
"bufferSizeMB": "${size} MB",
|
||||
"bufferSizeAuto": "Automatikus (ajánlott)",
|
||||
"bufferSizeWarning": "${heap} MB memória érhető el. A(z) ${size} MB méretű puffer befolyásolhatja a lejátszást.",
|
||||
"playbackBuffer": "Lejátszási puffer",
|
||||
"playbackBufferAuto": "Automatikus (ajánlott)",
|
||||
"playbackBufferLarge": "Nagy",
|
||||
"playbackBufferExtraLarge": "Extra nagy",
|
||||
"playbackBufferDescription": "Több puffert használ instabil kapcsolatok ellen. A pufferméret is korlátozza.",
|
||||
"defaultQualityTitle": "Alapértelmezett minőség",
|
||||
"cellularQualityTitle": "Alapértelmezett minőség mobilhálózaton",
|
||||
"cellularQualitySameAsDefault": "Ugyanaz, mint az alapértelmezett minőség",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Zene minősége",
|
||||
"subtitleStyling": "Feliratok stílusa",
|
||||
"subtitleStylingDescription": "Feliratok megjelenésének testreszabása",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Hang- és feliratválasztások megjegyzése címenként",
|
||||
"followServerTrackSelections": "A szerver epizódonkénti sávválasztásának használata",
|
||||
"followServerTrackSelectionsDescription": "Epizódváltáskor a szerveren kiválasztott hang és felirat lép életbe az aktuális választás átvitele helyett",
|
||||
"resumeMusicOnLaunch": "Zenei munkamenet megjegyzése",
|
||||
"resumeMusicOnLaunchDescription": "Az alkalmazás indításakor az utolsó szám szüneteltetve nyílik meg ott, ahol abbamaradt",
|
||||
"showChapterMarkersOnTimeline": "Fejezetjelölők megjelenítése az idősávon",
|
||||
"showChapterMarkersOnTimelineDescription": "Az idősáv felosztása a fejezetek határainál",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Különkiadások epizódsorrendben",
|
||||
"specialsOrderingDescription": "A különkiadások helye a sorozat lejátszási sorrendjében",
|
||||
"specialsOrderingServer": "Szerver szerinti sorrend",
|
||||
"specialsOrderingAirDate": "Adás dátuma szerint közbeszúrva",
|
||||
"specialsOrderingLast": "A normál évadok után",
|
||||
"clickVideoTogglesPlayback": "Kattintás a videóra a lejátszás/szünet váltásához",
|
||||
"clickVideoTogglesPlaybackDescription": "A videóra kattintva vált a lejátszás/szünet, a vezérlők megjelenítése helyett.",
|
||||
"videoPlayerControls": "Videólejátszó vezérlői",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "A billentyűparancs már hozzá van rendelve a következőhöz: ${action}",
|
||||
"shortcutUpdated": "Billentyűparancs frissítve a következőhöz: ${action}",
|
||||
"saveFailed": "Nem sikerült menteni a módosításokat. Próbáld újra.",
|
||||
"autoSkip": "Automatikus átugrás",
|
||||
"autoPlayAndSkip": "Automatikus lejátszás és átugrás",
|
||||
"autoPlayNextEpisode": "Következő epizód automatikus lejátszása",
|
||||
"autoPlayNextEpisodeDescription": "A következő epizód automatikus elindítása, amikor az aktuális véget ér",
|
||||
"playNextCountdown": "Visszaszámlálás a következő epizódig",
|
||||
"playNextCountdownImmediate": "Azonnali lejátszás",
|
||||
"autoSkipIntro": "Intró automatikus átugrása",
|
||||
"autoSkipIntroDescription": "Az intrójelölők automatikus átugrása néhány másodperc után",
|
||||
"autoSkipCredits": "Stáblista automatikus átugrása",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Lejátszás közben az alkalmazás elhagyásakor automatikusan kép a képben módra vált",
|
||||
"matchContentFrameRate": "Képkockasebesség illesztése a tartalomhoz",
|
||||
"matchContentFrameRateDescription": "A kijelző frissítési frekvenciájának igazítása a videóhoz",
|
||||
"matchContentResolution": "Igazítás a tartalom felbontásához",
|
||||
"matchContentResolutionDescription": "A kijelzőt a videó natív felbontására váltja, hogy a felskálázást a tévé végezze. Lejátszás közben a menük és a feliratok is felskálázódnak",
|
||||
"matchRefreshRate": "Frissítési frekvencia illesztése",
|
||||
"matchRefreshRateDescription": "A kijelző frissítési frekvenciájának igazítása teljes képernyőn",
|
||||
"matchDynamicRange": "Dinamikatartomány illesztése",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása",
|
||||
"dvConversionDv81Description": "A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re",
|
||||
"dvConversionHevcStripDescription": "A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés",
|
||||
"deinterlace": "Deinterlacing",
|
||||
"deinterlaceDescription": "Fésűfog-műtermékek eltávolítása a váltott soros videóból (csak mpv lejátszóval)",
|
||||
"requireProfileSelectionOnOpen": "Profil kérése az alkalmazás megnyitásakor",
|
||||
"requireProfileSelectionOnOpenDescription": "Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod",
|
||||
"forceTvMode": "TV-mód kényszerítése",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "A Plex Discover és a csatlakoztatott követők tartalmait megjelenítő Felfedezés lap megjelenítése",
|
||||
"liveTvDefaultFavorites": "Kedvenc csatornák alapértelmezés szerint",
|
||||
"liveTvDefaultFavoritesDescription": "Csak a kedvenc csatornák megjelenítése az Élő TV megnyitásakor",
|
||||
"general": "Általános",
|
||||
"generalDescription": "Nyelv, indítás és ablakviselkedés",
|
||||
"languageAndRegion": "Nyelv és régió",
|
||||
"startup": "Indítás",
|
||||
"display": "Kijelző",
|
||||
"libraryAndCards": "Könyvtár és kártyák",
|
||||
"homeScreen": "Kezdőképernyő",
|
||||
"navigation": "Navigáció",
|
||||
"window": "Ablak",
|
||||
"content": "Tartalom",
|
||||
"liveTv": "Élő TV",
|
||||
"player": "Lejátszó",
|
||||
"subtitlesAndConfig": "Feliratok és konfiguráció",
|
||||
"videoAndDisplay": "Videó és kijelző",
|
||||
"audio": "Hang",
|
||||
"quality": "Minőség",
|
||||
"subtitles": "Feliratok",
|
||||
"seekAndTiming": "Tekerés és időzítés",
|
||||
"behavior": "Viselkedés",
|
||||
"gestures": "Mozdulatok",
|
||||
"gestureBrightnessSwipe": "Fényerő-húzás",
|
||||
"gestureBrightnessSwipeDescription": "Húzd felfelé vagy lefelé a bal szélén a fényerő beállításához",
|
||||
"gestureVolumeSwipe": "Hangerő-húzás",
|
||||
"gestureVolumeSwipeDescription": "Húzd felfelé vagy lefelé a jobb szélén a hangerő beállításához",
|
||||
"gesturePinchToZoom": "Csípés a nagyításhoz",
|
||||
"gesturePinchToZoomDescription": "Csípj a videóra a nagyításhoz vagy kicsinyítéshez",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Vezérlők",
|
||||
"rememberPlayerChanges": "Lejátszó módosításainak megjegyzése",
|
||||
"rememberPlayerChangesDescription": "A lejátszás közben végzett módosítások mentési és újbóli alkalmazási helye",
|
||||
"scopePlaybackSpeed": "Lejátszási sebesség",
|
||||
"scopeShaderPreset": "Shader-előbeállítás",
|
||||
"scopeAspectRatio": "Képarány",
|
||||
"scopeSyncOffsets": "Hang- és feliratszinkronizálás",
|
||||
"playerScopeOff": "Ne mentse",
|
||||
"playerScopeGlobal": "Mindenhol",
|
||||
"playerScopeLibrary": "Könyvtáronként",
|
||||
"playerScopeTitle": "Sorozatonként vagy filmenként",
|
||||
"exportDialogTitle": "Plezy-beállítások exportálása"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Hang",
|
||||
"subtitlesLabel": "Feliratok",
|
||||
"resetToZero": "Visszaállítás 0 ms-ra",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label}: későbbre állítva",
|
||||
"playsEarlier": "${label}: korábbra állítva",
|
||||
"noOffset": "Nincs eltolás",
|
||||
"letterbox": "Fekete sávok",
|
||||
"fillScreen": "Képernyő kitöltése",
|
||||
"stretch": "Nyújtás",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Az eszköz nem támogatja a kép a képben módot",
|
||||
"voSwitchFailed": "Nem sikerült átváltani a videókimenetet a kép a képben módhoz",
|
||||
"failed": "Nem sikerült elindítani a kép a képben módot",
|
||||
"prepareFailed": "Nem sikerült előkészíteni a kép a képben módot",
|
||||
"unknown": "Hiba történt: ${error}"
|
||||
},
|
||||
"chapters": "Fejezetek",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "A(z) ${product} kapcsolat szerkesztése",
|
||||
"editMediaBrowserIntro": "Adjon hozzá vagy távolítson el URL-eket a(z) ${serverName} esetén. A Plezy a legkisebb késleltetésű, elérhető URL-t fogja használni."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Fiókbeállítások",
|
||||
"hubSubtitleSingle": "Hang-, felirat- és könyvtárbeállítások mentve a(z) ${account} fiókra",
|
||||
"hubSubtitleMultiple": "Hang-, felirat- és könyvtárbeállítások mentve ${count} fiókra",
|
||||
"pickAccount": "Minden fiók a saját beállításait tárolja. Válaszd ki a szerkesztendőt.",
|
||||
"storedOnAccount": "Ezek a beállítások magán a fiókon tárolódnak, így minden bejelentkezett alkalmazás használja őket — beleértve a Plezyt a többi eszközödön.",
|
||||
"noAccounts": "Nincs beállítható fiók",
|
||||
"noAccountsHint": "Jelentkezz be a Plexbe, vagy csatlakoztass egy Jellyfin- vagy Emby-szervert, és az azon a fiókon tárolt beállítások itt jelennek meg.",
|
||||
"unavailable": "Ez a fiók nem érhető el",
|
||||
"loadFailed": "Nem sikerült betölteni ezeket a beállításokat",
|
||||
"noPreference": "Nincs preferencia",
|
||||
"notSet": "Nincs beállítva",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Hang és feliratok",
|
||||
"libraryDisplay": "Könyvtár",
|
||||
"personalMedia": "Személyes média"
|
||||
},
|
||||
"preferredAudioLanguage": "Elsődleges hangnyelv",
|
||||
"autoSelectAudio": "Hangsáv kiválasztása nyelv alapján",
|
||||
"autoSelectAudioDescription": "Ki kapcsolásnál a fájl által alapértelmezettként megjelölt hangsáv marad.",
|
||||
"preferredSubtitleLanguage": "Elsődleges feliratnyelv",
|
||||
"subtitleMode": "Feliratok bekapcsolása",
|
||||
"subtitleModes": {
|
||||
"none": "Kézzel kiválasztva",
|
||||
"noneDescription": "Soha ne kapcsolja be magától a feliratokat.",
|
||||
"defaultMode": "A sáv jelzőinek követése",
|
||||
"defaultModeDescription": "Az egyes feliratsávokon tárolt alapértelmezett és kényszerített jelzők használata.",
|
||||
"always": "Mindig engedélyezve",
|
||||
"alwaysDescription": "Feliratsáv bekapcsolása az elsődleges nyelven, ha van ilyen.",
|
||||
"onlyForced": "Csak kényszerített feliratok",
|
||||
"onlyForcedDescription": "Csak a kényszerítettként megjelölt sávok betöltése.",
|
||||
"smart": "Idegen nyelvű hang esetén megjelenítve",
|
||||
"smartDescription": "Feliratok bekapcsolása csak akkor, ha a hang más nyelven szól."
|
||||
},
|
||||
"subtitleAccessibility": "SDH-feliratok",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Nem SDH-feliratok előnyben részesítése",
|
||||
"preferSdh": "SDH-feliratok előnyben részesítése",
|
||||
"onlySdh": "Csak SDH-feliratok",
|
||||
"onlyNonSdh": "Csak nem SDH-feliratok"
|
||||
},
|
||||
"forcedSubtitles": "Kényszerített feliratok",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Nem kényszerített feliratok előnyben részesítése",
|
||||
"preferForced": "Kényszerített feliratok előnyben részesítése",
|
||||
"onlyForced": "Csak kényszerített feliratok",
|
||||
"onlyNonForced": "Csak nem kényszerített feliratok"
|
||||
},
|
||||
"displayMissingEpisodes": "Hiányzó epizódok megjelenítése",
|
||||
"displayMissingEpisodesDescription": "Azoknak az epizódoknak a listázása, amelyeket a szerver ismer, de nincs hozzájuk fájl.",
|
||||
"hidePlayedInLatest": "Megnézett elemek elrejtése a Legújabbak között",
|
||||
"hidePlayedInLatestDescription": "A már megnézett elemek kimaradnak a szerver Legújabbak soraiból.",
|
||||
"displayCollectionsView": "Gyűjtemények nézet megjelenítése",
|
||||
"displayCollectionsViewDescription": "A szerver Gyűjtemények nézetének megjelenítése a könyvtárak mellett.",
|
||||
"rewatchingInNextUp": "Újranézett sorozatok megtartása a Következikben",
|
||||
"rewatchingInNextUpDescription": "Ha befejezel egy sorozatot, és újra elkezded, a Következik az újranézést követi, ahelyett hogy eldobná a sorozatot.",
|
||||
"watchedIndicator": "Megtekintett jelzések",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Soha",
|
||||
"moviesAndShows": "Filmek és TV-sorozatok",
|
||||
"movies": "Csak filmek",
|
||||
"shows": "Csak TV-sorozatok"
|
||||
},
|
||||
"mediaReviewsVisibility": "Értékelések és kritikák",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Felhasználók és kritikusok",
|
||||
"usersOnly": "Csak felhasználók",
|
||||
"criticsOnly": "Csak kritikusok",
|
||||
"nobody": "Rejtett"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Felfedezés",
|
||||
"noContentAvailable": "Nincs elérhető tartalom",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Nem találhatók szerverek ehhez a fiókhoz: ${username} (${email})",
|
||||
"failedToLoadServers": "Nem sikerült a szerverek betöltése: ${error}"
|
||||
"failedToLoadServers": "Nem sikerült a szerverek betöltése: ${error}",
|
||||
"noValidServers": "Nem található használható szerver ehhez a fiókhoz"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Cím",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} népszerű",
|
||||
"rankAiring": "#${n} adásban",
|
||||
"rankRated": "#${n} értékelt",
|
||||
"rankFavorited": "#${n} kedvenc",
|
||||
"rankTrending": "#${n} felkapott",
|
||||
"rankSeasonal": "#${n} a(z) ${season} évadban",
|
||||
"watchingNow": "${n} nézi",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Ismeretlen csatorna",
|
||||
"live": "ÉLŐ",
|
||||
"reloadGuide": "Műsorújság újratöltése",
|
||||
"searchGuide": "Keresés a műsorújságban",
|
||||
"searchHint": "Csatornák és műsorok keresése",
|
||||
"searchNoResults": "Nincs találat a következőre: \"${query}\"",
|
||||
"channelsSection": "Csatornák",
|
||||
"programsSection": "Műsorok",
|
||||
"now": "Most",
|
||||
"today": "Ma",
|
||||
"tomorrow": "Holnap",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Műsorújság frissítése kérve",
|
||||
"rulesProcessRequested": "Szabályok újraértékelése kérve",
|
||||
"recordShow": "Műsor rögzítése",
|
||||
"recordSettings": {
|
||||
"startEarly": "Korábbi kezdés (másodperc)",
|
||||
"endLate": "Későbbi befejezés (másodperc)",
|
||||
"newOnly": "Csak új epizódok",
|
||||
"anyChannel": "Felvétel bármelyik csatornáról",
|
||||
"anyTime": "Felvétel bármikor",
|
||||
"skipInLibrary": "A könyvtárban már meglévő epizódok kihagyása",
|
||||
"keepUpTo": "Megtartandó epizódok",
|
||||
"keepUpToHint": "A 0 minden epizódot megtart"
|
||||
},
|
||||
"startingInMinutes": "${minutes} perc múlva kezdődik",
|
||||
"dayAtTime": "${day}, ${time}",
|
||||
"invalidPlaybackData": "A(z) ${product} érvénytelen Élő TV-lejátszási adatokat adott vissza",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Összes ismétlése",
|
||||
"repeatOne": "Egy szám ismétlése",
|
||||
"instantMixNoServer": "Nem érhető el szerver az azonnali mixhez",
|
||||
"noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}"
|
||||
"instantMixFailed": "Nem sikerült betölteni az azonnali mixet",
|
||||
"instantMixEmpty": "Az azonnali mix nem adott vissza zeneszámokat",
|
||||
"noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Kislemezek és EP-k",
|
||||
"live": "Élő",
|
||||
"compilations": "Válogatások"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Watch Together (Közös nézés)",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Házigazda",
|
||||
"hostBadge": "HÁZIGAZDA",
|
||||
"youAreHost": "Te vagy a házigazda",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Nézés másokkal",
|
||||
"endSession": "Munkamenet befejezése",
|
||||
"leaveSession": "Munkamenet elhagyása",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Eltávolítás",
|
||||
"guestSwitchUnavailable": "Nem sikerült a váltás — a szerver nem érhető el szinkronizáláshoz",
|
||||
"guestSwitchFailed": "Nem sikerült a váltás — a tartalom nem található ezen a szerveren",
|
||||
"defaultDisplayName": "Felhasználó"
|
||||
"defaultDisplayName": "Felhasználó",
|
||||
"errors": {
|
||||
"timedOut": "A relészerver nem válaszolt időben",
|
||||
"connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna",
|
||||
"invalidRelayResponse": "A relészerver váratlan választ küldött",
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Letöltések",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Használd a mobileszközödet az alkalmazás vezérléséhez",
|
||||
"startServer": "Szerver indítása",
|
||||
"stopServer": "Szerver leállítása",
|
||||
"minimize": "Kis méret"
|
||||
"minimize": "Kis méret",
|
||||
"manualAddressHint": "Kézi kapcsolódási cím:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Az azonos Plex-fiókot használó Plezy-eszközök itt jelennek meg",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Dekóder",
|
||||
"rawDecoder": "Nyers dekóder",
|
||||
"tunneling": "Alagutazás",
|
||||
"passthrough": "Passthrough",
|
||||
"aspect": "Méretarány",
|
||||
"rotation": "Forgatás",
|
||||
"dvSource": "DV-forrás",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Producer",
|
||||
"country": "Ország",
|
||||
"collection": "Gyűjtemény",
|
||||
"label": "Kiadó",
|
||||
"style": "Stílus",
|
||||
"mood": "Hangulat"
|
||||
"label": "Kiadó"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Párosítás...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Részben elérhető",
|
||||
"statusRequested": "Igényelve",
|
||||
"statusProcessing": "Feldolgozás alatt",
|
||||
"statusBlocklisted": "Tiltólistán",
|
||||
"couldNotReach": "Nem sikerült elérni ezt: ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nem található Seerr-példány ezen a címen: ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Adj meg egy szervercímet, például: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Ez a Seerr-példány nem támogatja a Quick Connectet. Seerr 3.4 vagy újabb verzió szükséges.",
|
||||
"notInitialized": "Ennek a Seerr-példánynak a kezdeti beállítása még nem fejeződött be",
|
||||
"noPlexTokenForReauth": "Nem áll rendelkezésre Plex-token az újbóli bejelentkezéshez",
|
||||
"noStoredCredentials": "Nem állnak rendelkezésre mentett hitelesítő adatok az újbóli bejelentkezéshez",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Szolgáltatások",
|
||||
"hubSubtitle": "Megtekintési haladás szinkronizálása és új tartalmak igénylése.",
|
||||
"integrations": "Integrációk",
|
||||
"notConnected": "Nincs csatlakoztatva",
|
||||
"connectedAs": "@${username} néven csatlakoztatva",
|
||||
"scrobble": "Haladás automatikus követése",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges.",
|
||||
"invalidCredentials": "Érvénytelen felhasználónév vagy jelszó",
|
||||
"authResponseNotJson": "A hitelesítési válasz nem érvényes JSON",
|
||||
"authResponseIncomplete": "A szerver bejelentkezési válasza hiányos volt",
|
||||
"quickConnectRejected": "A szerver elutasította a Quick Connect-kérést",
|
||||
"quickConnectNotJson": "A Quick Connect válasza nem érvényes JSON",
|
||||
"quickConnectMissingFields": "A Quick Connect válaszából hiányzik a kód vagy a titkos kulcs",
|
||||
|
||||
+192
-27
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Usa il browser",
|
||||
"or": "o",
|
||||
"connectToMediaBrowser": "Connettiti a ${product}",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Usa Quick Connect",
|
||||
"quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.",
|
||||
"quickConnectWaiting": "In attesa di approvazione…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Disattiva audio",
|
||||
"ok": "OK",
|
||||
"off": "Disattivato",
|
||||
"options": "Opzioni",
|
||||
"seasonNumber": "Stagione ${number}",
|
||||
"episodeNumberTitle": "Episodio ${number} - ${title}",
|
||||
"chapterNumber": "Capitolo ${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/D",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Film",
|
||||
"show": "Serie",
|
||||
"season": "Stagione",
|
||||
"episode": "Episodio",
|
||||
"artist": "Artista",
|
||||
"album": "Album",
|
||||
"track": "Traccia",
|
||||
"collection": "Raccolta",
|
||||
"playlist": "Playlist",
|
||||
"clip": "Clip",
|
||||
"photo": "Foto",
|
||||
"folder": "Cartella"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenze",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Scala di visualizzazione",
|
||||
"compact": "Compatta",
|
||||
"comfortable": "Comoda",
|
||||
"gridSpacing": "Spaziatura della griglia",
|
||||
"gridSpacingTight": "Ristretta",
|
||||
"gridSpacingNormal": "Normale",
|
||||
"gridSpacingSpacious": "Ampia",
|
||||
"tvCornerSpotlightBackdrop": "Sfondo in evidenza nell'angolo",
|
||||
"tvCornerSpotlightBackdropDescription": "Mostra l'immagine in evidenza nell'angolo in alto a destra anziché a schermo intero",
|
||||
"viewMode": "Modalità di visualizzazione",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Decodifica hardware",
|
||||
"hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile",
|
||||
"bufferSize": "Dimensione buffer",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"bufferSizeAuto": "Automatica (consigliata)",
|
||||
"bufferSizeWarning": "${heap}MB di memoria disponibile. Un buffer di ${size}MB può influire sulla riproduzione.",
|
||||
"playbackBuffer": "Buffer di riproduzione",
|
||||
"playbackBufferAuto": "Auto (consigliato)",
|
||||
"playbackBufferLarge": "Grande",
|
||||
"playbackBufferExtraLarge": "Extra grande",
|
||||
"playbackBufferDescription": "Bufferizza di più contro connessioni instabili. Limitato anche dalla dimensione del buffer.",
|
||||
"defaultQualityTitle": "Qualità predefinita",
|
||||
"cellularQualityTitle": "Qualità predefinita sulla rete mobile",
|
||||
"cellularQualitySameAsDefault": "Come la qualità predefinita",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Qualità musicale",
|
||||
"subtitleStyling": "Stile sottotitoli",
|
||||
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Ricorda le scelte di audio e sottotitoli per ogni titolo",
|
||||
"followServerTrackSelections": "Usa le tracce selezionate sul server per ogni episodio",
|
||||
"followServerTrackSelectionsDescription": "Al cambio di episodio applica l'audio e i sottotitoli selezionati sul server invece di mantenere la scelta corrente",
|
||||
"resumeMusicOnLaunch": "Ricorda la sessione musicale",
|
||||
"resumeMusicOnLaunchDescription": "All'avvio dell'app riapri l'ultimo brano in pausa dal punto in cui era rimasto",
|
||||
"showChapterMarkersOnTimeline": "Mostra i marcatori dei capitoli sulla barra di avanzamento",
|
||||
"showChapterMarkersOnTimelineDescription": "Segmenta la barra di avanzamento ai confini dei capitoli",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Speciali nell'ordine degli episodi",
|
||||
"specialsOrderingDescription": "Dove vengono riprodotti gli speciali nell'ordine di visione di una serie",
|
||||
"specialsOrderingServer": "Segui l'ordine del server",
|
||||
"specialsOrderingAirDate": "Intercalati per data di trasmissione",
|
||||
"specialsOrderingLast": "Dopo le stagioni normali",
|
||||
"clickVideoTogglesPlayback": "Fai clic sul video per alternare riproduzione e pausa",
|
||||
"clickVideoTogglesPlaybackDescription": "Fai clic sul video per riprodurre o mettere in pausa anziché mostrare i controlli.",
|
||||
"videoPlayerControls": "Controlli del lettore video",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}",
|
||||
"shortcutUpdated": "Scorciatoia aggiornata per ${action}",
|
||||
"saveFailed": "Impossibile salvare le modifiche. Riprova.",
|
||||
"autoSkip": "Salto automatico",
|
||||
"autoPlayAndSkip": "Riproduzione automatica e salti",
|
||||
"autoPlayNextEpisode": "Riproduci automaticamente l'episodio successivo",
|
||||
"autoPlayNextEpisodeDescription": "Avvia automaticamente l'episodio successivo quando termina quello in riproduzione",
|
||||
"playNextCountdown": "Conto alla rovescia del successivo",
|
||||
"playNextCountdownImmediate": "Riproduci subito",
|
||||
"autoSkipIntro": "Salta automaticamente la sigla iniziale",
|
||||
"autoSkipIntroDescription": "Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi",
|
||||
"autoSkipCredits": "Salta automaticamente i titoli di coda",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Attiva automaticamente la modalità Picture-in-Picture quando esci dall'app durante la riproduzione",
|
||||
"matchContentFrameRate": "Adatta la frequenza dei fotogrammi",
|
||||
"matchContentFrameRateDescription": "Adatta la frequenza di aggiornamento dello schermo al contenuto video",
|
||||
"matchContentResolution": "Adatta alla risoluzione del contenuto",
|
||||
"matchContentResolutionDescription": "Passa lo schermo alla risoluzione nativa del video, così è la TV a occuparsi dell'upscaling. Durante la riproduzione vengono ridimensionati anche menu e sottotitoli",
|
||||
"matchRefreshRate": "Adatta la frequenza di aggiornamento",
|
||||
"matchRefreshRateDescription": "Adatta la frequenza di aggiornamento dello schermo in modalità a schermo intero",
|
||||
"matchDynamicRange": "Adatta la gamma dinamica",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV",
|
||||
"dvConversionDv81Description": "Forza la conversione RPU diretta al profilo Dolby Vision 8.1",
|
||||
"dvConversionHevcStripDescription": "Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC",
|
||||
"deinterlace": "Deinterlacciamento",
|
||||
"deinterlaceDescription": "Rimuove gli artefatti a pettine dai video interlacciati (solo lettore mpv)",
|
||||
"requireProfileSelectionOnOpen": "Chiedi di scegliere il profilo all'apertura",
|
||||
"requireProfileSelectionOnOpenDescription": "Mostra la selezione del profilo ogni volta che l'app viene aperta",
|
||||
"forceTvMode": "Forza modalità TV",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Visualizza la scheda Esplora con contenuti di Esplora di Plex e dei tracker collegati",
|
||||
"liveTvDefaultFavorites": "Apri sui canali preferiti",
|
||||
"liveTvDefaultFavoritesDescription": "Mostra solo i canali preferiti quando apri la TV in diretta",
|
||||
"general": "Generali",
|
||||
"generalDescription": "Lingua, avvio e comportamento della finestra",
|
||||
"languageAndRegion": "Lingua e regione",
|
||||
"startup": "Avvio",
|
||||
"display": "Schermo",
|
||||
"libraryAndCards": "Libreria e schede",
|
||||
"homeScreen": "Schermata iniziale",
|
||||
"navigation": "Navigazione",
|
||||
"window": "Finestra",
|
||||
"content": "Contenuti",
|
||||
"liveTv": "TV in diretta",
|
||||
"player": "Lettore",
|
||||
"subtitlesAndConfig": "Sottotitoli e impostazioni",
|
||||
"videoAndDisplay": "Video e schermo",
|
||||
"audio": "Audio",
|
||||
"quality": "Qualità",
|
||||
"subtitles": "Sottotitoli",
|
||||
"seekAndTiming": "Avanzamento e tempi",
|
||||
"behavior": "Comportamento",
|
||||
"gestures": "Gesti",
|
||||
"gestureBrightnessSwipe": "Scorrimento per la luminosità",
|
||||
"gestureBrightnessSwipeDescription": "Scorri verso l'alto o il basso sul bordo sinistro per regolare la luminosità",
|
||||
"gestureVolumeSwipe": "Scorrimento per il volume",
|
||||
"gestureVolumeSwipeDescription": "Scorri verso l'alto o il basso sul bordo destro per regolare il volume",
|
||||
"gesturePinchToZoom": "Pizzica per lo zoom",
|
||||
"gesturePinchToZoomDescription": "Pizzica il video per ingrandire o ridurre",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Controlli",
|
||||
"rememberPlayerChanges": "Ricorda le modifiche del lettore",
|
||||
"rememberPlayerChangesDescription": "Dove viene salvata e riapplicata una modifica effettuata durante la riproduzione",
|
||||
"scopePlaybackSpeed": "Velocità di riproduzione",
|
||||
"scopeShaderPreset": "Preimpostazione shader",
|
||||
"scopeAspectRatio": "Proporzioni",
|
||||
"scopeSyncOffsets": "Sincronizzazione audio e sottotitoli",
|
||||
"playerScopeOff": "Non salvare",
|
||||
"playerScopeGlobal": "Ovunque",
|
||||
"playerScopeLibrary": "Per libreria",
|
||||
"playerScopeTitle": "Per serie o film",
|
||||
"exportDialogTitle": "Esporta le impostazioni di Plezy"
|
||||
},
|
||||
"search": {
|
||||
@@ -436,7 +494,7 @@
|
||||
"interlaced": "Interlacciato",
|
||||
"anamorphic": "Anamorfico",
|
||||
"referenceFrames": "Frame di riferimento",
|
||||
"dynamicRange": "Dynamic Range",
|
||||
"dynamicRange": "Gamma dinamica",
|
||||
"dolbyVision": "Dolby Vision",
|
||||
"dolbyVisionLevel": "Dolby Vision Level",
|
||||
"dolbyVisionVersion": "Dolby Vision Version",
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Sottotitoli",
|
||||
"resetToZero": "Ripristina a 0 ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label}: riproduzione ritardata",
|
||||
"playsEarlier": "${label}: riproduzione anticipata",
|
||||
"noOffset": "Nessun ritardo",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Riempi lo schermo",
|
||||
"stretch": "Allunga",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Questo dispositivo non supporta la modalità Picture-in-Picture",
|
||||
"voSwitchFailed": "Impossibile cambiare l'uscita video per Picture-in-Picture",
|
||||
"failed": "Impossibile avviare la modalità Picture-in-Picture",
|
||||
"prepareFailed": "Impossibile preparare la modalità Picture-in-Picture",
|
||||
"unknown": "Si è verificato un errore: ${error}"
|
||||
},
|
||||
"chapters": "Capitoli",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "Modifica connessione ${product}",
|
||||
"editMediaBrowserIntro": "Aggiungi o rimuovi gli URL per ${serverName}. Plezy userà l'URL raggiungibile con la latenza più bassa."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Preferenze dell'account",
|
||||
"hubSubtitleSingle": "Opzioni di audio, sottotitoli e libreria salvate su ${account}",
|
||||
"hubSubtitleMultiple": "Opzioni di audio, sottotitoli e libreria salvate su ${count} account",
|
||||
"pickAccount": "Ogni account conserva le proprie preferenze. Scegli quale modificare.",
|
||||
"storedOnAccount": "Queste opzioni vengono salvate sull'account stesso, quindi ogni app che vi accede le usa — inclusa Plezy sugli altri tuoi dispositivi.",
|
||||
"noAccounts": "Nessun account da configurare",
|
||||
"noAccountsHint": "Accedi a Plex oppure connetti un server Jellyfin o Emby: le preferenze salvate su quell'account appariranno qui.",
|
||||
"unavailable": "Impossibile raggiungere questo account",
|
||||
"loadFailed": "Impossibile caricare queste preferenze",
|
||||
"noPreference": "Nessuna preferenza",
|
||||
"notSet": "Non impostato",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Audio e sottotitoli",
|
||||
"libraryDisplay": "Libreria",
|
||||
"personalMedia": "Media personali"
|
||||
},
|
||||
"preferredAudioLanguage": "Lingua audio preferita",
|
||||
"autoSelectAudio": "Scegli l'audio in base alla lingua",
|
||||
"autoSelectAudioDescription": "Se disattivata, viene mantenuta la traccia audio che il file indica come predefinita.",
|
||||
"preferredSubtitleLanguage": "Lingua dei sottotitoli preferita",
|
||||
"subtitleMode": "Attiva i sottotitoli",
|
||||
"subtitleModes": {
|
||||
"none": "Selezionati manualmente",
|
||||
"noneDescription": "Non attivare mai i sottotitoli automaticamente.",
|
||||
"defaultMode": "Segui le flag della traccia",
|
||||
"defaultModeDescription": "Usa le flag predefinite e forzate salvate su ogni traccia di sottotitoli.",
|
||||
"always": "Sempre attivi",
|
||||
"alwaysDescription": "Attiva una traccia di sottotitoli nella lingua preferita quando disponibile.",
|
||||
"onlyForced": "Solo sottotitoli forzati",
|
||||
"onlyForcedDescription": "Carica solo le tracce contrassegnate come forzate.",
|
||||
"smart": "Mostrati con audio straniero",
|
||||
"smartDescription": "Attiva i sottotitoli solo quando l'audio è in un'altra lingua."
|
||||
},
|
||||
"subtitleAccessibility": "Sottotitoli SDH",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "Preferisci i sottotitoli non SDH",
|
||||
"preferSdh": "Preferisci i sottotitoli SDH",
|
||||
"onlySdh": "Solo sottotitoli SDH",
|
||||
"onlyNonSdh": "Solo sottotitoli non SDH"
|
||||
},
|
||||
"forcedSubtitles": "Sottotitoli forzati",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Preferisci i sottotitoli non forzati",
|
||||
"preferForced": "Preferisci i sottotitoli forzati",
|
||||
"onlyForced": "Solo sottotitoli forzati",
|
||||
"onlyNonForced": "Solo sottotitoli non forzati"
|
||||
},
|
||||
"displayMissingEpisodes": "Mostra gli episodi mancanti",
|
||||
"displayMissingEpisodesDescription": "Elenca gli episodi noti al server ma senza un file associato.",
|
||||
"hidePlayedInLatest": "Nascondi gli elementi visti da Aggiunti di recente",
|
||||
"hidePlayedInLatestDescription": "Esclude dagli Aggiunti di recente del server gli elementi che hai già visto.",
|
||||
"displayCollectionsView": "Mostra la vista Raccolte",
|
||||
"displayCollectionsViewDescription": "Mostra la vista Raccolte del server insieme alle tue librerie.",
|
||||
"rewatchingInNextUp": "Mantieni le serie riviste in Prossimi episodi",
|
||||
"rewatchingInNextUpDescription": "Quando finisci una serie e la ricominci, Prossimi episodi segue la nuova visione invece di rimuovere la serie.",
|
||||
"watchedIndicator": "Indicatori di visione",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Mai",
|
||||
"moviesAndShows": "Film e serie TV",
|
||||
"movies": "Solo film",
|
||||
"shows": "Solo serie TV"
|
||||
},
|
||||
"mediaReviewsVisibility": "Valutazioni e recensioni",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Utenti e critici",
|
||||
"usersOnly": "Solo utenti",
|
||||
"criticsOnly": "Solo critici",
|
||||
"nobody": "Nascoste"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Esplora",
|
||||
"noContentAvailable": "Nessun contenuto disponibile",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Nessun server trovato per ${username} (${email})",
|
||||
"failedToLoadServers": "Impossibile caricare i server: ${error}"
|
||||
"failedToLoadServers": "Impossibile caricare i server: ${error}",
|
||||
"noValidServers": "Nessun server utilizzabile trovato per questo account"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titolo",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} popolari",
|
||||
"rankAiring": "#${n} in onda",
|
||||
"rankRated": "#${n} valutati",
|
||||
"rankFavorited": "#${n} preferiti",
|
||||
"rankTrending": "#${n} di tendenza",
|
||||
"rankSeasonal": "#${n} in ${season}",
|
||||
"watchingNow": "${n} in visione",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Canale sconosciuto",
|
||||
"live": "IN DIRETTA",
|
||||
"reloadGuide": "Ricarica la guida",
|
||||
"searchGuide": "Cerca nella guida",
|
||||
"searchHint": "Cerca canali e programmi",
|
||||
"searchNoResults": "Nessuna corrispondenza per \"${query}\"",
|
||||
"channelsSection": "Canali",
|
||||
"programsSection": "Programmi",
|
||||
"now": "Ora",
|
||||
"today": "Oggi",
|
||||
"tomorrow": "Domani",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Aggiornamento della guida richiesto",
|
||||
"rulesProcessRequested": "Rielaborazione delle regole richiesta",
|
||||
"recordShow": "Registra programma",
|
||||
"recordSettings": {
|
||||
"startEarly": "Inizia in anticipo (secondi)",
|
||||
"endLate": "Termina in ritardo (secondi)",
|
||||
"newOnly": "Solo nuovi episodi",
|
||||
"anyChannel": "Registra su qualsiasi canale",
|
||||
"anyTime": "Registra a qualsiasi ora",
|
||||
"skipInLibrary": "Salta gli episodi già presenti nella libreria",
|
||||
"keepUpTo": "Episodi da conservare",
|
||||
"keepUpToHint": "0 conserva tutti gli episodi"
|
||||
},
|
||||
"startingInMinutes": "Inizia tra ${minutes} min",
|
||||
"dayAtTime": "${day} alle ${time}",
|
||||
"invalidPlaybackData": "${product} ha restituito dati di riproduzione della TV in diretta non validi",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Ripeti tutto",
|
||||
"repeatOne": "Ripeti il brano",
|
||||
"instantMixNoServer": "Nessun server disponibile per un mix istantaneo",
|
||||
"noAudioUrl": "Nessun URL audio disponibile per ${track}"
|
||||
"instantMixFailed": "Impossibile caricare il mix istantaneo",
|
||||
"instantMixEmpty": "Il mix istantaneo non ha prodotto alcun brano",
|
||||
"noAudioUrl": "Nessun URL audio disponibile per ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Singoli ed EP",
|
||||
"live": "Dal vivo",
|
||||
"compilations": "Compilation"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Guarda insieme",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Sei l'host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "In visione con altri partecipanti",
|
||||
"endSession": "Termina la sessione",
|
||||
"leaveSession": "Abbandona la sessione",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Rimuovi",
|
||||
"guestSwitchUnavailable": "Impossibile cambiare — server non disponibile per la sincronizzazione",
|
||||
"guestSwitchFailed": "Impossibile cambiare — contenuto non trovato su questo server",
|
||||
"defaultDisplayName": "Utente"
|
||||
"defaultDisplayName": "Utente",
|
||||
"errors": {
|
||||
"timedOut": "Il server relay non ha risposto in tempo",
|
||||
"connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta",
|
||||
"invalidRelayResponse": "Il server relay ha inviato una risposta imprevista",
|
||||
"sessionEnded": "L’host ha terminato la sessione"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Download",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Usa il tuo dispositivo mobile per controllare questa app",
|
||||
"startServer": "Avvia server",
|
||||
"stopServer": "Arresta server",
|
||||
"minimize": "Riduci"
|
||||
"minimize": "Riduci",
|
||||
"manualAddressHint": "Indirizzo di connessione manuale:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "I dispositivi Plezy con lo stesso account Plex appaiono qui",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Decoder",
|
||||
"rawDecoder": "Decoder raw",
|
||||
"tunneling": "Tunneling",
|
||||
"passthrough": "Passthrough",
|
||||
"aspect": "Proporzioni",
|
||||
"rotation": "Rotazione",
|
||||
"dvSource": "Sorgente DV",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Produttore",
|
||||
"country": "Paese",
|
||||
"collection": "Raccolta",
|
||||
"label": "Etichetta",
|
||||
"style": "Stile",
|
||||
"mood": "Atmosfera"
|
||||
"label": "Etichetta"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Abbina...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Disponibile in parte",
|
||||
"statusRequested": "Richiesto",
|
||||
"statusProcessing": "In elaborazione",
|
||||
"statusBlocklisted": "Nella lista di blocco",
|
||||
"couldNotReach": "Impossibile raggiungere ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nessuna istanza Seerr all'indirizzo ${url} (HTTP ${status})",
|
||||
"invalidUrl": "Inserisci un indirizzo del server come https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Questa istanza Seerr non supporta Quick Connect. È necessaria la versione 3.4 o successiva di Seerr.",
|
||||
"notInitialized": "Questa istanza Seerr non ha completato la configurazione iniziale",
|
||||
"noPlexTokenForReauth": "Nessun token Plex disponibile per effettuare nuovamente l'accesso",
|
||||
"noStoredCredentials": "Nessuna credenziale salvata disponibile per effettuare nuovamente l'accesso",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Servizi",
|
||||
"hubSubtitle": "Sincronizza i progressi di visione e richiedi nuovi titoli.",
|
||||
"integrations": "Integrazioni",
|
||||
"notConnected": "Non connesso",
|
||||
"connectedAs": "Connesso come @${username}",
|
||||
"scrobble": "Registra automaticamente i progressi",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN.",
|
||||
"invalidCredentials": "Nome utente o password non validi",
|
||||
"authResponseNotJson": "La risposta di autenticazione non era un JSON valido",
|
||||
"authResponseIncomplete": "La risposta di accesso del server era incompleta",
|
||||
"quickConnectRejected": "Quick Connect è stato rifiutato dal server",
|
||||
"quickConnectNotJson": "La risposta di Quick Connect non era un JSON valido",
|
||||
"quickConnectMissingFields": "Nella risposta di Quick Connect manca un codice o un segreto",
|
||||
|
||||
+191
-26
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "ブラウザを使用",
|
||||
"or": "または",
|
||||
"connectToMediaBrowser": "${product}に接続",
|
||||
"quickConnect": "Quick Connect",
|
||||
"useQuickConnect": "Quick Connect を使う",
|
||||
"quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。",
|
||||
"quickConnectWaiting": "承認を待っています…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "ミュート",
|
||||
"ok": "OK",
|
||||
"off": "オフ",
|
||||
"options": "オプション",
|
||||
"seasonNumber": "シーズン${number}",
|
||||
"episodeNumberTitle": "エピソード${number} - ${title}",
|
||||
"chapterNumber": "チャプター${number}",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "映画",
|
||||
"show": "テレビ番組",
|
||||
"season": "シーズン",
|
||||
"episode": "エピソード",
|
||||
"artist": "アーティスト",
|
||||
"album": "アルバム",
|
||||
"track": "曲",
|
||||
"collection": "コレクション",
|
||||
"playlist": "プレイリスト",
|
||||
"clip": "クリップ",
|
||||
"photo": "写真",
|
||||
"folder": "フォルダ"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "ライセンス",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "表示スケール",
|
||||
"compact": "コンパクト",
|
||||
"comfortable": "ゆったり",
|
||||
"gridSpacing": "グリッド間隔",
|
||||
"gridSpacingTight": "密",
|
||||
"gridSpacingNormal": "標準",
|
||||
"gridSpacingSpacious": "ゆったり",
|
||||
"tvCornerSpotlightBackdrop": "画面隅の注目コンテンツ背景",
|
||||
"tvCornerSpotlightBackdropDescription": "画面全体ではなく、右上隅に注目コンテンツのアートワークを表示します",
|
||||
"viewMode": "表示モード",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "ハードウェアデコード",
|
||||
"hardwareDecodingDescription": "利用可能な場合にハードウェアアクセラレーションを使用",
|
||||
"bufferSize": "バッファサイズ",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"bufferSizeAuto": "自動(推奨)",
|
||||
"bufferSizeWarning": "${heap}MBのメモリが利用可能です。${size}MBのバッファは再生に影響する可能性があります。",
|
||||
"playbackBuffer": "再生バッファー",
|
||||
"playbackBufferAuto": "自動(推奨)",
|
||||
"playbackBufferLarge": "大",
|
||||
"playbackBufferExtraLarge": "特大",
|
||||
"playbackBufferDescription": "不安定な接続に備えてより多くのバッファーを使用します。バッファーサイズによっても制限されます。",
|
||||
"defaultQualityTitle": "デフォルト画質",
|
||||
"cellularQualityTitle": "モバイルデータ通信時のデフォルト画質",
|
||||
"cellularQualitySameAsDefault": "デフォルト画質と同じ",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "音楽の音質",
|
||||
"subtitleStyling": "字幕スタイル",
|
||||
"subtitleStylingDescription": "字幕の外観をカスタマイズ",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "タイトルごとに音声と字幕の選択を記憶します",
|
||||
"followServerTrackSelections": "サーバーのエピソードごとのトラック選択を使用",
|
||||
"followServerTrackSelectionsDescription": "エピソード切り替え時に、現在の選択を引き継ぐ代わりにサーバーで選択された音声と字幕を適用します",
|
||||
"resumeMusicOnLaunch": "音楽セッションを記憶",
|
||||
"resumeMusicOnLaunchDescription": "アプリ起動時に最後の曲を停止位置で一時停止のまま開きます",
|
||||
"showChapterMarkersOnTimeline": "シークバーにチャプターマーカーを表示",
|
||||
"showChapterMarkersOnTimelineDescription": "チャプターの境界でシークバーを区切る",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "エピソード順にスペシャルを表示",
|
||||
"specialsOrderingDescription": "シリーズの視聴順でスペシャルを再生する位置",
|
||||
"specialsOrderingServer": "サーバーの順序に従う",
|
||||
"specialsOrderingAirDate": "放送日順に挿入",
|
||||
"specialsOrderingLast": "通常シーズンの後",
|
||||
"clickVideoTogglesPlayback": "動画クリックで再生/一時停止を切替",
|
||||
"clickVideoTogglesPlaybackDescription": "コントロール表示ではなく、動画クリックで再生/一時停止します。",
|
||||
"videoPlayerControls": "動画プレーヤーコントロール",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "ショートカットは既に${action}に割り当てられています",
|
||||
"shortcutUpdated": "${action}のショートカットを更新しました",
|
||||
"saveFailed": "変更を保存できませんでした。もう一度お試しください。",
|
||||
"autoSkip": "自動スキップ",
|
||||
"autoPlayAndSkip": "自動再生とスキップ",
|
||||
"autoPlayNextEpisode": "次のエピソードを自動再生",
|
||||
"autoPlayNextEpisodeDescription": "エピソードが終了すると、次のエピソードを自動的に再生します",
|
||||
"playNextCountdown": "次回再生のカウントダウン",
|
||||
"playNextCountdownImmediate": "すぐに再生",
|
||||
"autoSkipIntro": "イントロを自動スキップ",
|
||||
"autoSkipIntroDescription": "数秒後にイントロマーカーを自動的にスキップ",
|
||||
"autoSkipCredits": "クレジットを自動スキップ",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "再生中にアプリを離れると、自動的にピクチャーインピクチャーに切り替えます",
|
||||
"matchContentFrameRate": "コンテンツのフレームレートに合わせる",
|
||||
"matchContentFrameRateDescription": "表示のリフレッシュレートを動画コンテンツに合わせます",
|
||||
"matchContentResolution": "コンテンツの解像度に合わせる",
|
||||
"matchContentResolutionDescription": "テレビ側でアップスケーリングを処理できるよう、ディスプレイを映像本来の解像度に切り替えます。再生中はメニューや字幕もアップスケールされます",
|
||||
"matchRefreshRate": "リフレッシュレートを合わせる",
|
||||
"matchRefreshRateDescription": "全画面時に表示のリフレッシュレートを合わせます",
|
||||
"matchDynamicRange": "ダイナミックレンジを合わせる",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "ネイティブ DV7 を強制し、DV 変換の再試行を抑制します",
|
||||
"dvConversionDv81Description": "Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します",
|
||||
"dvConversionHevcStripDescription": "Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います",
|
||||
"deinterlace": "デインターレース",
|
||||
"deinterlaceDescription": "インターレース映像のくし型ノイズを除去します(mpvプレーヤーのみ)",
|
||||
"requireProfileSelectionOnOpen": "アプリ起動時にプロフィールを確認",
|
||||
"requireProfileSelectionOnOpenDescription": "アプリを開くたびにプロフィール選択を表示",
|
||||
"forceTvMode": "TVモードを強制",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Plex Discoverと接続済みのトラッカーのコンテンツを「探す」タブに表示します",
|
||||
"liveTvDefaultFavorites": "お気に入りチャンネルをデフォルトに",
|
||||
"liveTvDefaultFavoritesDescription": "ライブTVを開いたときにお気に入りチャンネルのみ表示",
|
||||
"general": "一般",
|
||||
"generalDescription": "言語、起動、ウィンドウの動作",
|
||||
"languageAndRegion": "言語と地域",
|
||||
"startup": "起動",
|
||||
"display": "ディスプレイ",
|
||||
"libraryAndCards": "ライブラリとカード",
|
||||
"homeScreen": "ホーム画面",
|
||||
"navigation": "ナビゲーション",
|
||||
"window": "ウィンドウ",
|
||||
"content": "コンテンツ",
|
||||
"liveTv": "ライブTV",
|
||||
"player": "プレーヤー",
|
||||
"subtitlesAndConfig": "字幕と設定",
|
||||
"videoAndDisplay": "映像とディスプレイ",
|
||||
"audio": "音声",
|
||||
"quality": "画質",
|
||||
"subtitles": "字幕",
|
||||
"seekAndTiming": "シークとタイミング",
|
||||
"behavior": "動作",
|
||||
"gestures": "ジェスチャー",
|
||||
"gestureBrightnessSwipe": "明るさスワイプ",
|
||||
"gestureBrightnessSwipeDescription": "左端を上下にスワイプして明るさを調整します",
|
||||
"gestureVolumeSwipe": "音量スワイプ",
|
||||
"gestureVolumeSwipeDescription": "右端を上下にスワイプして音量を調整します",
|
||||
"gesturePinchToZoom": "ピンチでズーム",
|
||||
"gesturePinchToZoomDescription": "動画をピンチしてズームイン・アウト",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "コントロール",
|
||||
"rememberPlayerChanges": "プレーヤーの変更を記憶",
|
||||
"rememberPlayerChangesDescription": "再生中に行った変更を保存し、再適用する場所",
|
||||
"scopePlaybackSpeed": "再生速度",
|
||||
"scopeShaderPreset": "シェーダープリセット",
|
||||
"scopeAspectRatio": "アスペクト比",
|
||||
"scopeSyncOffsets": "音声と字幕の同期",
|
||||
"playerScopeOff": "保存しない",
|
||||
"playerScopeGlobal": "すべて",
|
||||
"playerScopeLibrary": "ライブラリごと",
|
||||
"playerScopeTitle": "作品ごと",
|
||||
"exportDialogTitle": "Plezyの設定をエクスポート"
|
||||
},
|
||||
"search": {
|
||||
@@ -596,12 +654,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "音声",
|
||||
"subtitlesLabel": "字幕",
|
||||
"resetToZero": "0msにリセット",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label}を遅らせる",
|
||||
"playsEarlier": "${label}を早める",
|
||||
"noOffset": "オフセットなし",
|
||||
"letterbox": "レターボックス",
|
||||
"fillScreen": "画面を埋める",
|
||||
"stretch": "引き延ばす",
|
||||
@@ -667,6 +720,7 @@
|
||||
"notSupported": "デバイスはピクチャーインピクチャーモードをサポートしていません",
|
||||
"voSwitchFailed": "ピクチャーインピクチャーの映像出力切替に失敗しました",
|
||||
"failed": "ピクチャーインピクチャーの開始に失敗しました",
|
||||
"prepareFailed": "ピクチャーインピクチャーを準備できませんでした",
|
||||
"unknown": "エラーが発生しました: ${error}"
|
||||
},
|
||||
"chapters": "チャプター",
|
||||
@@ -878,6 +932,77 @@
|
||||
"editMediaBrowserTitle": "${product}接続を編集",
|
||||
"editMediaBrowserIntro": "${serverName}のURLを追加または削除します。Plezyは到達可能なURLのうち、レイテンシーが最も低いものを使用します。"
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "アカウント設定",
|
||||
"hubSubtitleSingle": "音声・字幕・ライブラリのオプションは${account}に保存されています",
|
||||
"hubSubtitleMultiple": "音声・字幕・ライブラリのオプションは${count}個のアカウントに保存されています",
|
||||
"pickAccount": "各アカウントに独自の設定が保存されています。編集するアカウントを選択してください。",
|
||||
"storedOnAccount": "これらのオプションはアカウント自体に保存されるため、サインインしているすべてのアプリ(他のデバイスのPlezyを含む)で使用されます。",
|
||||
"noAccounts": "設定できるアカウントがありません",
|
||||
"noAccountsHint": "Plexにサインインするか、JellyfinまたはEmbyサーバーに接続すると、そのアカウントに保存された設定がここに表示されます。",
|
||||
"unavailable": "このアカウントにアクセスできません",
|
||||
"loadFailed": "この設定を読み込めませんでした",
|
||||
"noPreference": "指定なし",
|
||||
"notSet": "未設定",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "音声と字幕",
|
||||
"libraryDisplay": "ライブラリ",
|
||||
"personalMedia": "個人メディア"
|
||||
},
|
||||
"preferredAudioLanguage": "優先音声言語",
|
||||
"autoSelectAudio": "言語で音声を選択",
|
||||
"autoSelectAudioDescription": "オフにすると、ファイルでデフォルトとマークされた音声トラックがそのまま使われます。",
|
||||
"preferredSubtitleLanguage": "優先字幕言語",
|
||||
"subtitleMode": "字幕の自動表示",
|
||||
"subtitleModes": {
|
||||
"none": "手動選択",
|
||||
"noneDescription": "自動では字幕をオンにしません。",
|
||||
"defaultMode": "トラックのフラグに従う",
|
||||
"defaultModeDescription": "各字幕トラックに保存されたデフォルトと強制のフラグを使用します。",
|
||||
"always": "常に有効",
|
||||
"alwaysDescription": "優先言語の字幕トラックがあれば、常にオンにします。",
|
||||
"onlyForced": "強制字幕のみ",
|
||||
"onlyForcedDescription": "強制とマークされたトラックのみ読み込みます。",
|
||||
"smart": "外国語音声時に表示",
|
||||
"smartDescription": "音声が別の言語の場合にのみ字幕をオンにします。"
|
||||
},
|
||||
"subtitleAccessibility": "SDH字幕",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "非SDH字幕を優先",
|
||||
"preferSdh": "SDH字幕を優先",
|
||||
"onlySdh": "SDH字幕のみ",
|
||||
"onlyNonSdh": "非SDH字幕のみ"
|
||||
},
|
||||
"forcedSubtitles": "強制字幕",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "非強制字幕を優先",
|
||||
"preferForced": "強制字幕を優先",
|
||||
"onlyForced": "強制字幕のみ",
|
||||
"onlyNonForced": "非強制字幕のみ"
|
||||
},
|
||||
"displayMissingEpisodes": "欠落エピソードを表示",
|
||||
"displayMissingEpisodesDescription": "サーバーが把握しているがファイルがないエピソードを一覧表示します。",
|
||||
"hidePlayedInLatest": "「最新」の視聴済みアイテムを非表示",
|
||||
"hidePlayedInLatestDescription": "すでに視聴したアイテムは、サーバーの「最新」に表示されません。",
|
||||
"displayCollectionsView": "コレクションビューを表示",
|
||||
"displayCollectionsViewDescription": "ライブラリに加えて、サーバーのコレクションビューを表示します。",
|
||||
"rewatchingInNextUp": "再視聴した番組を「次のエピソード」に残す",
|
||||
"rewatchingInNextUpDescription": "番組を見終えた後に再び見始めると、「次のエピソード」は番組を外さずに再視聴に追従します。",
|
||||
"watchedIndicator": "視聴済みマーク",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "しない",
|
||||
"moviesAndShows": "映画とテレビ番組",
|
||||
"movies": "映画のみ",
|
||||
"shows": "テレビ番組のみ"
|
||||
},
|
||||
"mediaReviewsVisibility": "評価とレビュー",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "ユーザーと批評家",
|
||||
"usersOnly": "ユーザーのみ",
|
||||
"criticsOnly": "批評家のみ",
|
||||
"nobody": "非表示"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "探す",
|
||||
"noContentAvailable": "コンテンツがありません",
|
||||
@@ -1022,7 +1147,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "${username} (${email})のサーバーが見つかりません",
|
||||
"failedToLoadServers": "サーバーの読み込みに失敗しました: ${error}"
|
||||
"failedToLoadServers": "サーバーの読み込みに失敗しました: ${error}",
|
||||
"noValidServers": "このアカウントで利用可能なサーバーが見つかりませんでした"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "タイトル",
|
||||
@@ -1129,7 +1255,6 @@
|
||||
"rankPopular": "人気 #${n}",
|
||||
"rankAiring": "放送中 #${n}",
|
||||
"rankRated": "評価 #${n}",
|
||||
"rankFavorited": "お気に入り #${n}",
|
||||
"rankTrending": "急上昇 #${n}",
|
||||
"rankSeasonal": "#${n}(${season})",
|
||||
"watchingNow": "${n}人視聴中",
|
||||
@@ -1269,6 +1394,11 @@
|
||||
"unknownChannel": "不明なチャンネル",
|
||||
"live": "ライブ",
|
||||
"reloadGuide": "番組表を再読み込み",
|
||||
"searchGuide": "番組表を検索",
|
||||
"searchHint": "チャンネル・番組を検索",
|
||||
"searchNoResults": "「${query}」に一致するものがありません",
|
||||
"channelsSection": "チャンネル",
|
||||
"programsSection": "番組",
|
||||
"now": "現在",
|
||||
"today": "今日",
|
||||
"tomorrow": "明日",
|
||||
@@ -1324,6 +1454,16 @@
|
||||
"guideReloadRequested": "番組表の更新をリクエストしました",
|
||||
"rulesProcessRequested": "録画ルールの再評価をリクエストしました",
|
||||
"recordShow": "番組を録画",
|
||||
"recordSettings": {
|
||||
"startEarly": "早めに開始(秒)",
|
||||
"endLate": "遅めに終了(秒)",
|
||||
"newOnly": "新しいエピソードのみ",
|
||||
"anyChannel": "すべてのチャンネルで録画",
|
||||
"anyTime": "すべての時間帯で録画",
|
||||
"skipInLibrary": "ライブラリにあるエピソードをスキップ",
|
||||
"keepUpTo": "保持するエピソード数",
|
||||
"keepUpToHint": "0 ですべてのエピソードを保持"
|
||||
},
|
||||
"startingInMinutes": "${minutes}分後に開始",
|
||||
"dayAtTime": "${day} ${time}",
|
||||
"invalidPlaybackData": "${product}から無効なライブTV再生データが返されました",
|
||||
@@ -1406,7 +1546,14 @@
|
||||
"repeatAll": "全曲リピート",
|
||||
"repeatOne": "1曲リピート",
|
||||
"instantMixNoServer": "インスタントミックスに利用できるサーバーがありません",
|
||||
"noAudioUrl": "${track}で利用可能な音声URLがありません"
|
||||
"instantMixFailed": "インスタントミックスを読み込めませんでした",
|
||||
"instantMixEmpty": "インスタントミックスにトラックがありませんでした",
|
||||
"noAudioUrl": "${track}で利用可能な音声URLがありません",
|
||||
"discography": {
|
||||
"singlesAndEps": "シングル・EP",
|
||||
"live": "ライブ",
|
||||
"compilations": "コンピレーション"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "一緒に見る",
|
||||
@@ -1432,6 +1579,13 @@
|
||||
"host": "ホスト",
|
||||
"hostBadge": "ホスト",
|
||||
"youAreHost": "あなたはホストです",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "他の人と視聴中",
|
||||
"endSession": "セッションを終了",
|
||||
"leaveSession": "セッションを退出",
|
||||
@@ -1474,7 +1628,13 @@
|
||||
"removeRoom": "削除",
|
||||
"guestSwitchUnavailable": "切り替えられません — 同期に必要なサーバーを利用できません",
|
||||
"guestSwitchFailed": "切り替えられません — このサーバーにコンテンツが見つかりません",
|
||||
"defaultDisplayName": "ユーザー"
|
||||
"defaultDisplayName": "ユーザー",
|
||||
"errors": {
|
||||
"timedOut": "リレーサーバーが時間内に応答しませんでした",
|
||||
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
|
||||
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
|
||||
"sessionEnded": "ホストがセッションを終了しました"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "ダウンロード",
|
||||
@@ -1622,7 +1782,8 @@
|
||||
"usePhoneToControl": "モバイルデバイスでこのアプリを操作できます",
|
||||
"startServer": "サーバーを起動",
|
||||
"stopServer": "サーバーを停止",
|
||||
"minimize": "最小化"
|
||||
"minimize": "最小化",
|
||||
"manualAddressHint": "手動接続アドレス:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "同じPlexアカウントを使用しているPlezyデバイスがここに表示されます",
|
||||
@@ -1716,6 +1877,7 @@
|
||||
"decoder": "デコーダー",
|
||||
"rawDecoder": "Raw デコーダー",
|
||||
"tunneling": "トンネリング",
|
||||
"passthrough": "パススルー",
|
||||
"aspect": "アスペクト",
|
||||
"rotation": "回転",
|
||||
"dvSource": "DV ソース",
|
||||
@@ -1847,9 +2009,7 @@
|
||||
"producer": "プロデューサー",
|
||||
"country": "国",
|
||||
"collection": "コレクション",
|
||||
"label": "ラベル",
|
||||
"style": "スタイル",
|
||||
"mood": "ムード"
|
||||
"label": "ラベル"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "照合…",
|
||||
@@ -1912,8 +2072,11 @@
|
||||
"statusPartiallyAvailable": "一部利用可能",
|
||||
"statusRequested": "リクエスト済み",
|
||||
"statusProcessing": "処理中",
|
||||
"statusBlocklisted": "ブロックリスト登録済み",
|
||||
"couldNotReach": "${url}に接続できませんでした: ${error}",
|
||||
"noInstanceAtUrl": "${url}にSeerrインスタンスがありません(HTTP ${status})",
|
||||
"invalidUrl": "https://seerr.example.comのようなサーバーアドレスを入力してください",
|
||||
"quickConnectUnsupported": "このSeerrインスタンスはQuick Connectに対応していません。Seerr 3.4以降が必要です。",
|
||||
"notInitialized": "このSeerrインスタンスでは初回セットアップが完了していません",
|
||||
"noPlexTokenForReauth": "再サインインに使用できるPlexトークンがありません",
|
||||
"noStoredCredentials": "再サインインに使用できる保存済みの認証情報がありません",
|
||||
@@ -1926,6 +2089,7 @@
|
||||
"services": {
|
||||
"title": "サービス",
|
||||
"hubSubtitle": "視聴の進捗を同期して、新しいタイトルをリクエストします。",
|
||||
"integrations": "インテグレーション",
|
||||
"notConnected": "未接続",
|
||||
"connectedAs": "@${username} として接続済み",
|
||||
"scrobble": "進捗を自動で記録",
|
||||
@@ -2003,6 +2167,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。",
|
||||
"invalidCredentials": "ユーザー名またはパスワードが正しくありません",
|
||||
"authResponseNotJson": "認証レスポンスが有効なJSONではありません",
|
||||
"authResponseIncomplete": "サーバーからのサインイン応答が不完全です",
|
||||
"quickConnectRejected": "Quick Connectがサーバーに拒否されました",
|
||||
"quickConnectNotJson": "Quick Connectのレスポンスが有効なJSONではありません",
|
||||
"quickConnectMissingFields": "Quick Connectのレスポンスにコードまたはシークレットがありません",
|
||||
|
||||
+191
-26
@@ -12,6 +12,7 @@
|
||||
"useBrowser": "Браузерді пайдалану",
|
||||
"or": "немесе",
|
||||
"connectToMediaBrowser": "${product} қосылу",
|
||||
"quickConnect": "Жылдам қосылу",
|
||||
"useQuickConnect": "Жылдам қосылуды пайдалану",
|
||||
"quickConnectInstructions": "Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.",
|
||||
"quickConnectWaiting": "Растау күтілуде…",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mute": "Дыбысты өшіру",
|
||||
"ok": "Түсінікті",
|
||||
"off": "Өшірулі",
|
||||
"options": "Параметрлер",
|
||||
"seasonNumber": "${number}-маусым",
|
||||
"episodeNumberTitle": "${number}-бөлім - ${title}",
|
||||
"chapterNumber": "${number}-бөлім",
|
||||
@@ -81,7 +83,21 @@
|
||||
},
|
||||
"notAvailable": "N/A",
|
||||
"url": "URL",
|
||||
"letterKeys": "ABC"
|
||||
"letterKeys": "ABC",
|
||||
"mediaKind": {
|
||||
"movie": "Фильм",
|
||||
"show": "Сериал",
|
||||
"season": "Маусым",
|
||||
"episode": "Эпизод",
|
||||
"artist": "Орындаушы",
|
||||
"album": "Альбом",
|
||||
"track": "Трек",
|
||||
"collection": "Жинақ",
|
||||
"playlist": "Ойнату тізімі",
|
||||
"clip": "Клип",
|
||||
"photo": "Фото",
|
||||
"folder": "Қалта"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Лицензиялар",
|
||||
@@ -127,6 +143,10 @@
|
||||
"displayScale": "Дисплей масштабы",
|
||||
"compact": "Тығыз",
|
||||
"comfortable": "Ыңғайлы",
|
||||
"gridSpacing": "Тор аралығы",
|
||||
"gridSpacingTight": "Тығыз",
|
||||
"gridSpacingNormal": "Қалыпты",
|
||||
"gridSpacingSpacious": "Кең",
|
||||
"tvCornerSpotlightBackdrop": "Бұрыштық жарық фоны",
|
||||
"tvCornerSpotlightBackdropDescription": "Фонды толық экран орнына жоғарғы оң жақ бұрышта көрсету",
|
||||
"viewMode": "Көрініс режимі",
|
||||
@@ -170,16 +190,16 @@
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Аппараттық декодтау",
|
||||
"hardwareDecodingDescription": "Мүмкіндігінше аппараттық үдетуді пайдалану",
|
||||
"bufferSize": "Буфер көлемі",
|
||||
"bufferSizeMB": "${size} МБ",
|
||||
"bufferSizeAuto": "Автоматты (Ұсынылатын)",
|
||||
"bufferSizeWarning": "${heap} МБ ЖҰД қолжетімді. ${size} МБ буфер ойнатуға әсер етуі мүмкін.",
|
||||
"playbackBuffer": "Ойнату буфері",
|
||||
"playbackBufferAuto": "Авто (ұсынылады)",
|
||||
"playbackBufferLarge": "Үлкен",
|
||||
"playbackBufferExtraLarge": "Өте үлкен",
|
||||
"playbackBufferDescription": "Тұрақсыз қосылыстарға қарсы көбірек буферлейді. Буфер өлшемімен де шектеледі.",
|
||||
"defaultQualityTitle": "Әдепкі сапа",
|
||||
"cellularQualityTitle": "Мобильді желідегі әдепкі сапа",
|
||||
"cellularQualitySameAsDefault": "Әдепкі сапамен бірдей",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Музыка сапасы",
|
||||
"subtitleStyling": "Субтитр баптаулары",
|
||||
"subtitleStylingDescription": "Субтитрлердің сыртқы келбетін теңшеу",
|
||||
@@ -193,13 +213,15 @@
|
||||
"rememberTrackSelectionsDescription": "Әр медиа үшін дыбыс пен субтитр таңдауын сақтау",
|
||||
"followServerTrackSelections": "Әр бөлім үшін сервердегі жолдар таңдауын қолдану",
|
||||
"followServerTrackSelectionsDescription": "Бөлім ауысқанда ағымдағы таңдауды көшірудің орнына серверде таңдалған дыбыс пен субтитрлерді қолдану",
|
||||
"resumeMusicOnLaunch": "Музыка сессиясын есте сақтау",
|
||||
"resumeMusicOnLaunchDescription": "Қолданба ашылғанда соңғы әнді тоқтаған жерінен кідіртілген күйде ашу",
|
||||
"showChapterMarkersOnTimeline": "Уақыт шкаласында бөлім белгілерін көрсету",
|
||||
"showChapterMarkersOnTimelineDescription": "Уақыт шкаласын бөлімдерге бөлу",
|
||||
"specialsOrdering": "",
|
||||
"specialsOrderingDescription": "",
|
||||
"specialsOrderingServer": "",
|
||||
"specialsOrderingAirDate": "",
|
||||
"specialsOrderingLast": "",
|
||||
"specialsOrdering": "Арнайы бөлімдер эпизодтар ретімен",
|
||||
"specialsOrderingDescription": "Арнайы бөлімдер сериалдың көру ретінің қай жерінде ойнатылады",
|
||||
"specialsOrderingServer": "Сервер ретін сақтау",
|
||||
"specialsOrderingAirDate": "Эфир күні бойынша араластыру",
|
||||
"specialsOrderingLast": "Қалыпты маусымдардан кейін",
|
||||
"clickVideoTogglesPlayback": "Ойнату/кідірту үшін видеоны басу",
|
||||
"clickVideoTogglesPlaybackDescription": "Басқару элементтерін көрсету орнына ойнату немесе кідірту",
|
||||
"videoPlayerControls": "Видео ойнатқыш басқару элементтері",
|
||||
@@ -246,7 +268,11 @@
|
||||
"shortcutAlreadyAssigned": "Бұл тіркес ${action} әрекетіне тағайындалған",
|
||||
"shortcutUpdated": "${action} үшін пернелер тіркесі жаңартылды",
|
||||
"saveFailed": "Өзгерістер сақталмады. Қайтадан байқап көріңіз.",
|
||||
"autoSkip": "Автоматты өткізу",
|
||||
"autoPlayAndSkip": "Автоойнату және өткізу",
|
||||
"autoPlayNextEpisode": "Келесі бөлімді автоматты ойнату",
|
||||
"autoPlayNextEpisodeDescription": "Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау",
|
||||
"playNextCountdown": "Келесіні ойнату санағы",
|
||||
"playNextCountdownImmediate": "Дереу ойнату",
|
||||
"autoSkipIntro": "Киріс бөлімді (Intro) автоматты өткізу",
|
||||
"autoSkipIntroDescription": "Бірнеше секундтан кейін киріс белгілерін автоматты өткізу",
|
||||
"autoSkipCredits": "Титрлерді автоматты өткізу",
|
||||
@@ -291,6 +317,8 @@
|
||||
"autoPipDescription": "Видео ойнап жатқанда қолданбадан шыққанда авто-PiP режиміне өту",
|
||||
"matchContentFrameRate": "Кадр жиілігін сәйкестендіру",
|
||||
"matchContentFrameRateDescription": "Экран жиілігін видео мазмұнына сәйкестендіру",
|
||||
"matchContentResolution": "Мазмұнның ажыратымдылығына бейімдеу",
|
||||
"matchContentResolutionDescription": "Масштабтауды теледидар орындауы үшін экранды бейненің өз ажыратымдылығына ауыстырады. Ойнату кезінде мәзірлер мен субтитрлер де масштабталады",
|
||||
"matchRefreshRate": "Жаңарту жиілігін сәйкестендіру",
|
||||
"matchRefreshRateDescription": "Толық экранда экран жаңарту жиілігін сәйкестендіру",
|
||||
"matchDynamicRange": "Динамикалық диапазонды сәйкестендіру",
|
||||
@@ -319,6 +347,8 @@
|
||||
"dvConversionNativeDescription": "Ішкі DV7 режимін мәжбүрлеу",
|
||||
"dvConversionDv81Description": "Dolby Vision profile 8.1 форматына түрлендіру",
|
||||
"dvConversionHevcStripDescription": "Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету",
|
||||
"deinterlace": "Деинтерлейсинг",
|
||||
"deinterlaceDescription": "Жоларалық видеодағы тарақ тәрізді артефактілерді жою (тек mpv ойнатқышында)",
|
||||
"requireProfileSelectionOnOpen": "Ашқанда профильді сұрау",
|
||||
"requireProfileSelectionOnOpenDescription": "Қолданба ашылған сайын профильді таңдауды көрсету",
|
||||
"forceTvMode": "TV режимін мәжбүрлеу",
|
||||
@@ -336,15 +366,43 @@
|
||||
"showExploreTabDescription": "Plex шолуынан және қосылған трекерлерден алынған контенті бар «Шолу» қойындысын көрсету",
|
||||
"liveTvDefaultFavorites": "Тікелей TV-де таңдаулы арналарды әдепкі ету",
|
||||
"liveTvDefaultFavoritesDescription": "Тікелей TV ашылғанда тек таңдаулы арналарды көрсету",
|
||||
"general": "Жалпы",
|
||||
"generalDescription": "Тіл, іске қосу және терезе мінез-құлқы",
|
||||
"languageAndRegion": "Тіл және аймақ",
|
||||
"startup": "Іске қосу",
|
||||
"display": "Дисплей",
|
||||
"libraryAndCards": "Кітапхана және карточкалар",
|
||||
"homeScreen": "Басты экран",
|
||||
"navigation": "Навигация",
|
||||
"window": "Терезе",
|
||||
"content": "Мазмұн",
|
||||
"liveTv": "Тікелей TV",
|
||||
"player": "Ойнатқыш",
|
||||
"subtitlesAndConfig": "Субтитрлер мен конфигурация",
|
||||
"videoAndDisplay": "Видео және дисплей",
|
||||
"audio": "Аудио",
|
||||
"quality": "Сапа",
|
||||
"subtitles": "Субтитрлер",
|
||||
"seekAndTiming": "Айналдыру және уақытты реттеу",
|
||||
"behavior": "Мінез-құлық",
|
||||
"gestures": "Ым-ишаралар",
|
||||
"gestureBrightnessSwipe": "Жарықтық сырғытуы",
|
||||
"gestureBrightnessSwipeDescription": "Сол жақ шетінде жоғары немесе төмен сырғытып, жарықтықты реттеу",
|
||||
"gestureVolumeSwipe": "Дыбыс сырғытуы",
|
||||
"gestureVolumeSwipeDescription": "Оң жақ шетінде жоғары немесе төмен сырғытып, дыбыс деңгейін реттеу",
|
||||
"gesturePinchToZoom": "Шымшу арқылы масштабтау",
|
||||
"gesturePinchToZoomDescription": "Видеода шымшып, жақындату немесе алыстату",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Басқару элементтері",
|
||||
"rememberPlayerChanges": "Ойнатқыш өзгерістерін есте сақтау",
|
||||
"rememberPlayerChangesDescription": "Ойнату кезінде жасалған өзгеріс сақталатын және қайта қолданылатын орын",
|
||||
"scopePlaybackSpeed": "Ойнату жылдамдығы",
|
||||
"scopeShaderPreset": "Шейдер алдын ала орнатымы",
|
||||
"scopeAspectRatio": "Кадр пішімі",
|
||||
"scopeSyncOffsets": "Аудио және субтитр синхрондауы",
|
||||
"playerScopeOff": "Сақтамау",
|
||||
"playerScopeGlobal": "Барлық жерде",
|
||||
"playerScopeLibrary": "Кітапхана бойынша",
|
||||
"playerScopeTitle": "Сериал немесе фильм бойынша",
|
||||
"exportDialogTitle": "Plezy параметрлерін экспорттау"
|
||||
},
|
||||
"search": {
|
||||
@@ -599,12 +657,7 @@
|
||||
"videoControls": {
|
||||
"audioLabel": "Дыбыс",
|
||||
"subtitlesLabel": "Субтитр",
|
||||
"resetToZero": "0мс-қа қалпына келтіру",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} кейін ойнатылады",
|
||||
"playsEarlier": "${label} бұрын ойнатылады",
|
||||
"noOffset": "Ығысусыз",
|
||||
"letterbox": "Кең экран (Letterbox)",
|
||||
"fillScreen": "Экранды толтыру",
|
||||
"stretch": "Созу",
|
||||
@@ -670,6 +723,7 @@
|
||||
"notSupported": "Құрылғы PiP режимін қолдамайды",
|
||||
"voSwitchFailed": "PiP үшін видео шығысын ауыстыру мүмкін болмады",
|
||||
"failed": "PiP режимін іске қосу қатесі",
|
||||
"prepareFailed": "PiP режимін дайындау мүмкін болмады",
|
||||
"unknown": "Қате орын алды: ${error}"
|
||||
},
|
||||
"chapters": "Бөлімдер",
|
||||
@@ -882,6 +936,77 @@
|
||||
"editMediaBrowserTitle": "${product} қосылымын өңдеу",
|
||||
"editMediaBrowserIntro": "${serverName} үшін URL мекенжайларды қосыңыз немесе өшіріңіз. Plezy ең төмен кідірісі бар қолжетімді URL мекенжайды пайдаланады."
|
||||
},
|
||||
"accountPreferences": {
|
||||
"sectionTitle": "Тіркелгі баптаулары",
|
||||
"hubSubtitleSingle": "${account} тіркелгісінде сақталған аудио, субтитр және кітапхана баптаулары",
|
||||
"hubSubtitleMultiple": "${count} тіркелгіде сақталған аудио, субтитр және кітапхана баптаулары",
|
||||
"pickAccount": "Әр тіркелгі өз баптауларын сақтайды. Өңдеу үшін біреуін таңдаңыз.",
|
||||
"storedOnAccount": "Бұл баптаулар тіркелгінің өзінде сақталады, сондықтан оған кірген әрбір қолданба оларды пайдаланады — басқа құрылғыларыңыздағы Plezy қоса.",
|
||||
"noAccounts": "Баптауға тіркелгілер жоқ",
|
||||
"noAccountsHint": "Plex-ке кіріңіз немесе Jellyfin не Emby серверін қосыңыз, сонда сол тіркелгіде сақталған баптаулар осында көрсетіледі.",
|
||||
"unavailable": "Бұл тіркелгіге қол жеткізу мүмкін емес",
|
||||
"loadFailed": "Бұл баптауларды жүктеу мүмкін болмады",
|
||||
"noPreference": "Таңдау жоқ",
|
||||
"notSet": "Орнатылмаған",
|
||||
"groups": {
|
||||
"audioAndSubtitles": "Аудио және субтитрлер",
|
||||
"libraryDisplay": "Кітапхана",
|
||||
"personalMedia": "Жеке медиа"
|
||||
},
|
||||
"preferredAudioLanguage": "Қалаулы аудио тілі",
|
||||
"autoSelectAudio": "Аудионы тілі бойынша таңдау",
|
||||
"autoSelectAudioDescription": "Өшірулі күйде файл әдепкі деп белгілеген аудио трек қолданылады.",
|
||||
"preferredSubtitleLanguage": "Қалаулы субтитр тілі",
|
||||
"subtitleMode": "Субтитрлерді қосу",
|
||||
"subtitleModes": {
|
||||
"none": "Қолмен таңдалған",
|
||||
"noneDescription": "Субтитрлерді өзінен-өзі ешқашан қоспау.",
|
||||
"defaultMode": "Трек белгілерін қолдану",
|
||||
"defaultModeDescription": "Әр субтитр трегінде сақталған әдепкі және мәжбүрлі белгілерді пайдалану.",
|
||||
"always": "Әрдайым қосылған",
|
||||
"alwaysDescription": "Қалаулы тілдегі субтитр трегі бар болса, оны қосу.",
|
||||
"onlyForced": "Тек мәжбүрлі субтитрлер",
|
||||
"onlyForcedDescription": "Тек мәжбүрлі деп белгіленген тректерді жүктеу.",
|
||||
"smart": "Шет тіліндегі аудио кезінде көрсету",
|
||||
"smartDescription": "Субтитрлерді тек аудио басқа тілде болғанда қосу."
|
||||
},
|
||||
"subtitleAccessibility": "SDH субтитрлері",
|
||||
"subtitleAccessibilityOptions": {
|
||||
"preferNonSdh": "SDH емес субтитрлерді артық көру",
|
||||
"preferSdh": "SDH субтитрлерін артық көру",
|
||||
"onlySdh": "Тек SDH субтитрлері",
|
||||
"onlyNonSdh": "Тек SDH емес субтитрлер"
|
||||
},
|
||||
"forcedSubtitles": "Мәжбүрлі субтитрлер",
|
||||
"forcedSubtitleOptions": {
|
||||
"preferNonForced": "Мәжбүрлі емес субтитрлерді артық көру",
|
||||
"preferForced": "Мәжбүрлі субтитрлерді артық көру",
|
||||
"onlyForced": "Тек мәжбүрлі субтитрлер",
|
||||
"onlyNonForced": "Тек мәжбүрлі емес субтитрлер"
|
||||
},
|
||||
"displayMissingEpisodes": "Жоқ бөлімдерді көрсету",
|
||||
"displayMissingEpisodesDescription": "Сервер білетін, бірақ файлы жоқ бөлімдерді тізімдеу.",
|
||||
"hidePlayedInLatest": "«Latest» айдарында көрілген элементтерді жасыру",
|
||||
"hidePlayedInLatestDescription": "Сервердің «Latest» қатарларында сіз көрген элементтерді көрсетпеу.",
|
||||
"displayCollectionsView": "Топтамалар көрінісін көрсету",
|
||||
"displayCollectionsViewDescription": "Кітапханаларыңызбен қатар сервердің Топтамалар көрінісін ұсыну.",
|
||||
"rewatchingInNextUp": "Қайта көрілген сериалдарды «Next Up» айдарында қалдыру",
|
||||
"rewatchingInNextUpDescription": "Сериалды аяқтаған соң оны қайта бастасаңыз, «Next Up» сериалды тастамай, қайта көруді жалғастырады.",
|
||||
"watchedIndicator": "Көрілген белгілері",
|
||||
"watchedIndicatorOptions": {
|
||||
"none": "Ешқашан",
|
||||
"moviesAndShows": "Фильмдер және сериалдар",
|
||||
"movies": "Тек фильмдер",
|
||||
"shows": "Тек сериалдар"
|
||||
},
|
||||
"mediaReviewsVisibility": "Бағалар мен пікірлер",
|
||||
"mediaReviewsOptions": {
|
||||
"usersAndCritics": "Пайдаланушылар және сыншылар",
|
||||
"usersOnly": "Тек пайдаланушылар",
|
||||
"criticsOnly": "Тек сыншылар",
|
||||
"nobody": "Жасырылған"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"title": "Шолу",
|
||||
"noContentAvailable": "Мазмұн қолжетімсіз",
|
||||
@@ -1027,7 +1152,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "${username} (${email}) үшін серверлер табылмады",
|
||||
"failedToLoadServers": "Серверлерді жүктеу қатесі: ${error}"
|
||||
"failedToLoadServers": "Серверлерді жүктеу қатесі: ${error}",
|
||||
"noValidServers": "Бұл тіркелгіде жарамды сервер табылмады"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Атауы",
|
||||
@@ -1135,7 +1261,6 @@
|
||||
"rankPopular": "#${n} танымал",
|
||||
"rankAiring": "#${n} эфирде",
|
||||
"rankRated": "#${n} бағаланған",
|
||||
"rankFavorited": "#${n} таңдаулы",
|
||||
"rankTrending": "#${n} трендте",
|
||||
"rankSeasonal": "#${n} — ${season}",
|
||||
"watchingNow": "${n} қарауда",
|
||||
@@ -1278,6 +1403,11 @@
|
||||
"unknownChannel": "Белгісіз арна",
|
||||
"live": "ТІКЕЛЕЙ ЭФИР",
|
||||
"reloadGuide": "Бағдарламаны жаңарту",
|
||||
"searchGuide": "Телебағдарламадан іздеу",
|
||||
"searchHint": "Арналар мен бағдарламаларды іздеу",
|
||||
"searchNoResults": "\"${query}\" бойынша сәйкестік табылмады",
|
||||
"channelsSection": "Арналар",
|
||||
"programsSection": "Бағдарламалар",
|
||||
"now": "Қазір",
|
||||
"today": "Бүгін",
|
||||
"tomorrow": "Ертең",
|
||||
@@ -1333,6 +1463,16 @@
|
||||
"guideReloadRequested": "Бағдарламаны жаңарту сұралды",
|
||||
"rulesProcessRequested": "Ережелерді қайта бағалау сұралды",
|
||||
"recordShow": "Шоуды жазу",
|
||||
"recordSettings": {
|
||||
"startEarly": "Ертерек бастау (секунд)",
|
||||
"endLate": "Кешірек аяқтау (секунд)",
|
||||
"newOnly": "Тек жаңа эпизодтар",
|
||||
"anyChannel": "Кез келген арнадан жазу",
|
||||
"anyTime": "Кез келген уақытта жазу",
|
||||
"skipInLibrary": "Кітапханада бар эпизодтарды өткізіп жіберу",
|
||||
"keepUpTo": "Сақталатын эпизодтар",
|
||||
"keepUpToHint": "0 барлық эпизодты сақтайды"
|
||||
},
|
||||
"startingInMinutes": "${minutes} мин кейін басталады",
|
||||
"dayAtTime": "${day}, ${time}",
|
||||
"invalidPlaybackData": "${product} жарамсыз тікелей TV ойнату деректерін қайтарды",
|
||||
@@ -1416,7 +1556,14 @@
|
||||
"repeatAll": "Барлығын қайталау",
|
||||
"repeatOne": "Біреуін қайталау",
|
||||
"instantMixNoServer": "Жедел микс үшін қолжетімді сервер жоқ",
|
||||
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес"
|
||||
"instantMixFailed": "Сәттік микс жүктелмеді",
|
||||
"instantMixEmpty": "Сәттік микс ешбір тректі қамтымады",
|
||||
"noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес",
|
||||
"discography": {
|
||||
"singlesAndEps": "Синглдер және EP",
|
||||
"live": "Жанды",
|
||||
"compilations": "Жинақтар"
|
||||
}
|
||||
},
|
||||
"watchTogether": {
|
||||
"title": "Бірге көру",
|
||||
@@ -1442,6 +1589,13 @@
|
||||
"host": "Ұйымдастырушы",
|
||||
"hostBadge": "ҰЙЫМДАСТЫРУШЫ",
|
||||
"youAreHost": "Сіз ұйымдастырушысыз",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Басқалармен бірге көрілуде",
|
||||
"endSession": "Сеансты аяқтау",
|
||||
"leaveSession": "Сеанстан шығу",
|
||||
@@ -1484,7 +1638,13 @@
|
||||
"removeRoom": "Өшіру",
|
||||
"guestSwitchUnavailable": "Ауысу мүмкін болмады",
|
||||
"guestSwitchFailed": "Ауысу мүмкін болмады — мазмұн табылмады",
|
||||
"defaultDisplayName": "Пайдаланушы"
|
||||
"defaultDisplayName": "Пайдаланушы",
|
||||
"errors": {
|
||||
"timedOut": "Реле сервері уақытында жауап бермеді",
|
||||
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
|
||||
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Жүктеулер",
|
||||
@@ -1632,7 +1792,8 @@
|
||||
"usePhoneToControl": "Осы қолданбаны басқару үшін телефоныңызды пайдаланыңыз",
|
||||
"startServer": "Серверді іске қосу",
|
||||
"stopServer": "Серверді тоқтату",
|
||||
"minimize": "Жию"
|
||||
"minimize": "Жию",
|
||||
"manualAddressHint": "Қолмен қосылу мекенжайы:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Бірдей Plex тіркелгісіндегі Plezy құрылғылары мұнда көрінеді",
|
||||
@@ -1726,6 +1887,7 @@
|
||||
"decoder": "Декодер",
|
||||
"rawDecoder": "Өңделмеген декодер",
|
||||
"tunneling": "Туннельдеу",
|
||||
"passthrough": "Тікелей өткізу",
|
||||
"aspect": "Қатынас",
|
||||
"rotation": "Бұрылыс",
|
||||
"dvSource": "DV дереккөзі",
|
||||
@@ -1857,9 +2019,7 @@
|
||||
"producer": "Продюсер",
|
||||
"country": "Ел",
|
||||
"collection": "Топтама",
|
||||
"label": "Белгі",
|
||||
"style": "Стиль",
|
||||
"mood": "Көңіл-күй"
|
||||
"label": "Белгі"
|
||||
},
|
||||
"matchScreen": {
|
||||
"match": "Сәйкестендіру...",
|
||||
@@ -1922,8 +2082,11 @@
|
||||
"statusPartiallyAvailable": "Жартылай қолжетімді",
|
||||
"statusRequested": "Сұралды",
|
||||
"statusProcessing": "Өңделуде",
|
||||
"statusBlocklisted": "Бұғаттау тізімінде",
|
||||
"couldNotReach": "${url} мекенжайына қосылу мүмкін болмады: ${error}",
|
||||
"noInstanceAtUrl": "${url} мекенжайында Seerr данасы жоқ (HTTP ${status})",
|
||||
"invalidUrl": "Сервер мекенжайын енгізіңіз, мысалы: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Бұл Seerr данасы Жылдам қосылуды қолдамайды. Оған Seerr 3.4 немесе одан жаңарақ нұсқа қажет.",
|
||||
"notInitialized": "Бұл Seerr данасының бастапқы баптауы аяқталмаған",
|
||||
"noPlexTokenForReauth": "Қайта кіру үшін Plex токені қолжетімді емес",
|
||||
"noStoredCredentials": "Қайта кіру үшін сақталған тіркелгі деректері жоқ",
|
||||
@@ -1936,6 +2099,7 @@
|
||||
"services": {
|
||||
"title": "Қызметтер",
|
||||
"hubSubtitle": "Көру барысын синхрондаңыз және жаңа мазмұн сұраңыз.",
|
||||
"integrations": "Интеграциялар",
|
||||
"notConnected": "Қосылмаған",
|
||||
"connectedAs": "@${username} ретінде қосылды",
|
||||
"scrobble": "Барысты автоматты бақылау",
|
||||
@@ -2013,6 +2177,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Басқа профильдің қосылымын қайта пайдалану.",
|
||||
"invalidCredentials": "Пайдаланушы аты немесе құпия сөз қате",
|
||||
"authResponseNotJson": "Аутентификация жауабы жарамды JSON болмады",
|
||||
"authResponseIncomplete": "Сервердің кіру жауабы толық емес",
|
||||
"quickConnectRejected": "Сервер Quick Connect сұрауын қабылдамады",
|
||||
"quickConnectNotJson": "Quick Connect жауабы жарамды JSON болмады",
|
||||
"quickConnectMissingFields": "Quick Connect жауабында код немесе құпия кілт жоқ",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user