Compare commits
248
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6ee0861e | ||
|
|
986e769ac0 | ||
|
|
b61fc9e661 | ||
|
|
fa8da33ec8 | ||
|
|
44599549e1 | ||
|
|
e46b3dab7c | ||
|
|
e67bf996c5 | ||
|
|
66cc6fc192 | ||
|
|
691d10b3c6 | ||
|
|
42b9bf7fb8 | ||
|
|
2efd300a5f | ||
|
|
8b1ac156f3 | ||
|
|
be8fa15733 | ||
|
|
fa054153ae | ||
|
|
a230d51598 | ||
|
|
e4f3888694 | ||
|
|
2087dff32d | ||
|
|
d112ade601 | ||
|
|
6301d5124e | ||
|
|
13acd41b09 | ||
|
|
0003d95c0a | ||
|
|
4d2aa8f9a2 | ||
|
|
deadf712a8 | ||
|
|
ef91156827 | ||
|
|
42b6710f04 | ||
|
|
7e583ef05a | ||
|
|
2132c23fab | ||
|
|
c4be378219 | ||
|
|
f967b9b2f2 | ||
|
|
eafacff3be | ||
|
|
33d845dcdd | ||
|
|
0134d9dbc0 | ||
|
|
4fd9034e8e | ||
|
|
51beb5ac4b | ||
|
|
b43cd81102 | ||
|
|
5112d71820 | ||
|
|
ab477c971d | ||
|
|
59f44d9ae1 | ||
|
|
de39e15e36 | ||
|
|
7b1b0d766b | ||
|
|
960adf42f2 | ||
|
|
d181bd34ae | ||
|
|
b27cab4510 | ||
|
|
17c877c973 | ||
|
|
1373c299df | ||
|
|
7e84df6385 | ||
|
|
e8e67df379 | ||
|
|
c70f88cee0 | ||
|
|
54efd4fe73 | ||
|
|
deaa06826c | ||
|
|
d371bfed3a | ||
|
|
aeb06c2c9e | ||
|
|
9a9b89082b | ||
|
|
c79ff68a7b | ||
|
|
7e1f18f93f | ||
|
|
5bd742c8b8 | ||
|
|
5c37c72a08 | ||
|
|
22c1ad56d3 | ||
|
|
3b3a816de0 | ||
|
|
3fc229cd6c | ||
|
|
b5186624b8 | ||
|
|
0c809491c7 | ||
|
|
5860079b43 | ||
|
|
ce03857588 | ||
|
|
200d896bea | ||
|
|
8fe7e4bcfd | ||
|
|
d2b5039bb3 | ||
|
|
12b8c7e7ef | ||
|
|
e9988165df | ||
|
|
582d419618 | ||
|
|
72d15b96a2 | ||
|
|
d9e31fd1a4 | ||
|
|
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 |
@@ -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
|
||||
|
||||
+25
-20
@@ -31,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
|
||||
@@ -414,9 +414,7 @@ jobs:
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
flutter_setup: git
|
||||
native_cache_path: |
|
||||
build/windows/arm64/_deps
|
||||
build/windows/arm64/mpv-dev-arm64
|
||||
native_cache_path: build/windows/arm64/_deps
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -427,12 +425,7 @@ jobs:
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ${{ matrix.native_cache_path }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: choco install 7zip -y
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt', 'mpv-build.lock.json') }}
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
@@ -585,10 +578,10 @@ jobs:
|
||||
|
||||
- name: Read version from pubspec
|
||||
id: version
|
||||
shell: pwsh
|
||||
shell: bash
|
||||
run: |
|
||||
$v = (Select-String -Path pubspec.yaml -Pattern '^version:\s*(\S+)').Matches[0].Groups[1].Value -replace '\+.*'
|
||||
echo "version=$v" >> $env:GITHUB_OUTPUT
|
||||
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
|
||||
echo "version=${VERSION%%+*}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build installer and portables
|
||||
run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
|
||||
@@ -725,17 +718,26 @@ jobs:
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Cache libmpv build
|
||||
- name: Cache libmpv prefix
|
||||
id: libmpv-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
# The prebuilt prefix from our unified mpv-build repo: the script reads
|
||||
# the asset and expected SHA-256 from the lock and verifies the bytes
|
||||
# before a single one is extracted. The tarball is a self-relocating
|
||||
# prefix tree (lib/ or lib/<triplet>/ with the libmpv.so* chain,
|
||||
# lib/libshaderc_shared.so*, include/, and pkgconfig/ dirs with
|
||||
# ${pcfiledir}-relative roots), so the downstream PKG_CONFIG_PATH,
|
||||
# bundle-copy, and find steps are unchanged.
|
||||
- name: Fetch libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: bash linux/packaging/build-libmpv.sh
|
||||
run: |
|
||||
command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y --no-install-recommends zstd; }
|
||||
python3 scripts/fetch_linux_libmpv.py --dest libmpv-prefix
|
||||
|
||||
- name: Install fpm
|
||||
shell: bash
|
||||
@@ -872,15 +874,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
sparse-checkout: pubspec.yaml
|
||||
sparse-checkout: |
|
||||
pubspec.yaml
|
||||
scripts/pubspec_version.py
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read version from pubspec.yaml
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
|
||||
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
|
||||
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
|
||||
BUILD_NUMBER=${VERSION#*+}
|
||||
VERSION=${VERSION%%+*}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
+26
-22
@@ -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:
|
||||
@@ -252,9 +252,6 @@ jobs:
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition and build plan
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
name: Linux native reliability (${{ matrix.sanitizer }})
|
||||
runs-on: ubuntu-latest
|
||||
@@ -317,7 +314,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: |
|
||||
@@ -403,7 +401,9 @@ jobs:
|
||||
|
||||
- name: Verify tvOS project wiring
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: ruby tvos/scripts/test_wire_mpv.rb
|
||||
run: |
|
||||
ruby tvos/scripts/test_wire_mpv.rb
|
||||
ruby tvos/scripts/test_wire_top_shelf.rb
|
||||
|
||||
- name: Select Apple test destination
|
||||
env:
|
||||
@@ -644,10 +644,10 @@ jobs:
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
# The packaging deps, minus libmpv: this job builds it from source below,
|
||||
# because the distro's is a different version with different windowing
|
||||
# backends, and a package smoke-built against it cannot show that
|
||||
# build-libmpv.sh still works or that the bundle it produces is coherent.
|
||||
# The packaging deps, minus libmpv: that arrives prebuilt from mpv-build
|
||||
# below. The dev packages still matter — they install the runtime
|
||||
# libraries the pinned libmpv links (ass, pulse, pipewire, ...), which
|
||||
# bundle-libs.sh resolves off the host into the bundle.
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -661,25 +661,29 @@ jobs:
|
||||
liblua5.2-dev rpm libarchive-tools imagemagick ruby-dev build-essential
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
|
||||
# Keyed the same way build.yml keys it, so editing the script or its pinned
|
||||
# inputs is what invalidates the cache - and this branch's whole point is
|
||||
# that those edits get exercised somewhere.
|
||||
- name: Cache libmpv build
|
||||
# Keyed the same way build.yml keys it: the lock names the asset and its
|
||||
# checksum, so editing the lock is what invalidates the cache.
|
||||
- name: Cache libmpv prefix
|
||||
id: libmpv-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
# Same contract as build.yml: the script reads mpv-build.lock.json,
|
||||
# downloads the linux asset for this arch, verifies its SHA-256, and
|
||||
# extracts the prefix tree.
|
||||
- name: Fetch libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
run: bash linux/packaging/build-libmpv.sh
|
||||
run: |
|
||||
command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y --no-install-recommends zstd; }
|
||||
python3 scripts/fetch_linux_libmpv.py --dest libmpv-prefix
|
||||
|
||||
# The windowing backends the runner depends on, read off the library the
|
||||
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
|
||||
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
|
||||
# in software; X11 and VDPAU are gone with the texture path.
|
||||
- name: Check the built libmpv's backends
|
||||
# The windowing backends the runner depends on, read off the pinned
|
||||
# library just extracted. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY,
|
||||
# so a libmpv without Wayland cannot find the VAAPI device and quietly
|
||||
# decodes in software; X11 and VDPAU are gone with the texture path.
|
||||
- name: Check the pinned libmpv's backends
|
||||
run: |
|
||||
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
|
||||
echo "== $LIB =="
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.15.0"
|
||||
sha256 "ad1ada9278d42d706f9e17f0a1fc7a3d8b02ef25536c71674848699955bce9d6"
|
||||
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
|
||||
@@ -205,4 +205,4 @@ Plezy is licensed under [GPL-3.0](LICENSE).
|
||||
|
||||
- Built with [Flutter](https://flutter.dev)
|
||||
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
|
||||
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
|
||||
- Playback powered by [mpv](https://mpv.io) via our [mpv-build](https://github.com/edde746/mpv-build) pipeline (started as a fork of [MPVKit](https://github.com/mpvkit/MPVKit); the Android Kotlin/JNI glue descends from [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), and [libass-android](https://github.com/peerless2012/libass-android)
|
||||
|
||||
@@ -4,6 +4,13 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -58,82 +58,31 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
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"
|
||||
// The in-project :libmpv module owns the mpv-build pin (repo-root
|
||||
// mpv-build.lock.json assets + checksums, plus the plezy.localMpvDir/
|
||||
// PLEZY_LOCAL_MPV_DIR escape hatch) and extracts the per-ABI tarballs'
|
||||
// prebuilt native libraries. This file reads two of its output trees
|
||||
// back: FFmpeg .so files for the Media3 adapter link step, and the libc++
|
||||
// runtime packaged at PROJECT scope below.
|
||||
val libmpvBuildDir = project(":libmpv").layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val libmpvNativeJniDir = File(libmpvBuildDir, "native/jni")
|
||||
val libmpvLibcxxJniDir = File(libmpvBuildDir, "libcxx/jni")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvDir, ".manifest")
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||
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)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
||||
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
||||
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val outDir = File(mpvDir, "libcxx")
|
||||
inputs.file(aar)
|
||||
outputs.dir(outDir)
|
||||
doLast {
|
||||
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
||||
outDir.mkdirs()
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"unzip",
|
||||
"-q",
|
||||
"-o",
|
||||
aar.absolutePath,
|
||||
"jni/*/libc++_shared.so",
|
||||
"-d",
|
||||
outDir.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
val mpvFfmpegDevelopmentDir = layout.buildDirectory.dir("libmpv-ffmpeg-development").get().asFile
|
||||
|
||||
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
|
||||
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
|
||||
// build-only; the APK contains one FFmpeg implementation for both players.
|
||||
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
dependsOn(":libmpv:extractLibmpvNative")
|
||||
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
|
||||
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val libraries = listOf("avcodec", "avutil", "swresample")
|
||||
inputs.file(aar)
|
||||
inputs.dir(libmpvNativeJniDir)
|
||||
inputs.property("ffmpegVersion", mpvFfmpegVersion)
|
||||
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
|
||||
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
|
||||
@@ -203,15 +152,12 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
)
|
||||
|
||||
project.copy {
|
||||
from(zipTree(aar)) {
|
||||
from(libmpvNativeJniDir) {
|
||||
include(
|
||||
"jni/*/libavcodec.so",
|
||||
"jni/*/libavutil.so",
|
||||
"jni/*/libswresample.so"
|
||||
"*/libavcodec.so",
|
||||
"*/libavutil.so",
|
||||
"*/libswresample.so"
|
||||
)
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
into(nativeDir)
|
||||
@@ -222,11 +168,11 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
}.filterNot(File::isFile)
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
"the :libmpv prebuilt tree is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
)
|
||||
}
|
||||
File(staging, ".manifest").writeText(
|
||||
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
"ffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
)
|
||||
sourceArchive.delete()
|
||||
extractedSource.deleteRecursively()
|
||||
@@ -336,7 +282,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
minSdk = 25 // Fire OS 6.x (API 25); :libmpv shares the same floor
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@@ -435,8 +381,9 @@ android {
|
||||
packaging {
|
||||
jniLibs {
|
||||
// pickFirst only suppresses the duplicate libc++ merge error; the
|
||||
// sourceSets rule below makes libmpv's newer runtime win for
|
||||
// std::from_chars<float>, while older native consumers remain ABI-compatible.
|
||||
// sourceSets rule below makes the runtime :libmpv extracts from the
|
||||
// mpv-build tarballs win for std::from_chars<float>, while older
|
||||
// native consumers remain ABI-compatible.
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
}
|
||||
@@ -444,8 +391,10 @@ android {
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
|
||||
// order cannot accidentally select the older libc++ copy.
|
||||
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
|
||||
// order cannot accidentally select an older libc++ copy. The directory
|
||||
// is :libmpv's extractLibmpvNative output (the tarballs' 16 KB-capable
|
||||
// libc++), wired below via the JniLibFolders dependency.
|
||||
jniLibs.srcDir(libmpvLibcxxJniDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,16 +450,18 @@ tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative")
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
|
||||
dependsOn(prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted libmpv libc++ directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractMpvLibcxx)
|
||||
dependsOn(":libmpv:extractLibmpvNative")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
// mpv Kotlin API + JNI glue live in-project; the prebuilt libmpv/FFmpeg .so
|
||||
// set rides along from the module's extracted mpv-build tarballs.
|
||||
implementation(project(":libmpv"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
private const val API_VLC_RESULT_POSITION = "extra_position"
|
||||
private const val API_VLC_RESULT_DURATION = "extra_duration"
|
||||
|
||||
// Honored by VLC and the native Zidoo player (com.android.gallery3d /
|
||||
// com.zidoo.player). Without it, a launch with no resume point lets the
|
||||
// player consult its own bookmark store, which on Zidoo collides across
|
||||
// Plex items because every part URL ends in the same `file.<ext>` (#2223).
|
||||
private const val API_VLC_FROM_START = "from_start"
|
||||
|
||||
private const val API_VIMU_TITLE = "forcename"
|
||||
private const val API_VIMU_SEEK_POSITION = "startfrom"
|
||||
private const val API_VIMU_RESUME = "forceresume"
|
||||
@@ -150,7 +156,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
internal data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
|
||||
private fun resolveSource(filePath: String): Source {
|
||||
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||
@@ -168,7 +174,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
return Source(uri, grantRead = true, fileName = file.name)
|
||||
}
|
||||
|
||||
private fun buildIntent(
|
||||
internal fun buildIntent(
|
||||
source: Source,
|
||||
packageName: String?,
|
||||
startPositionMs: Long,
|
||||
@@ -181,6 +187,9 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
if (startPosition > 0) {
|
||||
putExtra(API_MX_RESULT_POSITION, startPosition)
|
||||
putExtra(API_VIMU_SEEK_POSITION, startPosition)
|
||||
putExtra(API_VLC_FROM_START, false)
|
||||
} else {
|
||||
putExtra(API_VLC_FROM_START, true)
|
||||
}
|
||||
putExtra(API_MX_RETURN_RESULT, true)
|
||||
putExtra(API_MX_SECURE_URI, true)
|
||||
|
||||
@@ -31,7 +31,9 @@ import com.edde746.plezy.car.CarRestrictionsMonitor
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.shared.AssistiveTechnologyMonitor
|
||||
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
|
||||
@@ -95,9 +97,12 @@ class MainActivity : FlutterActivity() {
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
||||
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
|
||||
private val ASSISTIVE_TECHNOLOGY_CHANNEL = "com.plezy/assistive_technology"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var carRestrictions: CarRestrictionsMonitor? = null
|
||||
private var carRestrictionsChannel: MethodChannel? = null
|
||||
private var assistiveTechnology: AssistiveTechnologyMonitor? = null
|
||||
private var assistiveTechnologyChannel: MethodChannel? = null
|
||||
private var nativeTextInputFocused = false
|
||||
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
|
||||
private var imeShowAttempts = 0
|
||||
@@ -592,6 +597,9 @@ class MainActivity : FlutterActivity() {
|
||||
carRestrictions?.release()
|
||||
carRestrictions = null
|
||||
carRestrictionsChannel = null
|
||||
assistiveTechnology?.release()
|
||||
assistiveTechnology = null
|
||||
assistiveTechnologyChannel = null
|
||||
activityStarted = false
|
||||
flutterSurfaceReconnectPending = false
|
||||
flutterTextureView = null
|
||||
@@ -735,6 +743,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))
|
||||
)
|
||||
@@ -779,6 +788,19 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val assistiveChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, ASSISTIVE_TECHNOLOGY_CHANNEL)
|
||||
assistiveTechnologyChannel = assistiveChannel
|
||||
val assistiveMonitor = assistiveTechnology ?: AssistiveTechnologyMonitor(applicationContext).also {
|
||||
assistiveTechnology = it
|
||||
}
|
||||
assistiveMonitor.start { runOnUiThread { assistiveTechnologyChannel?.invokeMethod("onChanged", null) } }
|
||||
assistiveChannel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getSignals" -> result.success(assistiveMonitor.signals())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
handleDeviceAdjustmentCall(call.method, call.arguments, result)
|
||||
}
|
||||
@@ -882,7 +904,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" -> {
|
||||
|
||||
@@ -76,58 +76,93 @@ 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.
|
||||
*
|
||||
* Only the codecs mpv's audiotrack AO can physically carry are listed. That AO opens every
|
||||
* spdif format as a stereo `ENCODING_IEC61937` track and clamps the track rate to
|
||||
* `getNativeOutputSampleRate` (the 48kHz mixer rate), so E-AC3 (a 192kHz burst), TrueHD
|
||||
* (192kHz/8ch MAT) and DTS-HD MA (192kHz/8ch) can never leave that AO intact. Naming one
|
||||
* anyway force-passes the codec into a track that cannot carry it (#1991); those streams
|
||||
* decode to PCM instead, like every other unnamed codec.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
"ac3" to C.ENCODING_AC3,
|
||||
"dts" to C.ENCODING_DTS
|
||||
/** 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 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 an IEC
|
||||
* 61937 track carries.
|
||||
* 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.
|
||||
*
|
||||
* Two conditions, both required:
|
||||
* - The route must accept the exact track shape mpv opens for spdif output — stereo
|
||||
* IEC 61937 at the 48kHz mixer rate ([supportsMpvIecShape]). Advertising the raw AC3/DTS
|
||||
* encodings only says the receiver decodes them, not that the HAL takes an IEC 61937
|
||||
* AudioTrack: #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv
|
||||
* spdif attempt strands playback on a dead audio output, leaving nothing but AAC playable.
|
||||
* 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 IEC 61937 is transport, not transcoding.
|
||||
* [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")
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
if (!supportsMpvIecShape(context)) {
|
||||
Log.i(TAG, "Route takes no stereo IEC 61937 track; mpv will decode instead of bitstreaming")
|
||||
return ""
|
||||
}
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
@@ -138,12 +173,32 @@ 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
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */
|
||||
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,
|
||||
@@ -161,6 +216,47 @@ internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -226,8 +322,10 @@ internal fun iecRouteSupported(
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0
|
||||
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
|
||||
}
|
||||
@@ -272,8 +370,10 @@ private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCou
|
||||
}
|
||||
|
||||
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
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()
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
@@ -565,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`
|
||||
@@ -735,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 {
|
||||
@@ -781,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)
|
||||
@@ -818,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"
|
||||
@@ -2699,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
|
||||
@@ -2726,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"}")
|
||||
@@ -4111,6 +4122,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
@@ -4118,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 {
|
||||
@@ -4213,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,17 +1343,13 @@ class ExoPlayerPlugin :
|
||||
private fun prepareMpvFallback(core: MpvPlayerCore) {
|
||||
val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList()
|
||||
val observedProps = observedProperties.toList()
|
||||
val bufferSize = configuredBufferSizeBytes
|
||||
|
||||
// vo is owned by MpvPlayerCore's init: the fallback core hardware-decodes
|
||||
// (hwdec below), so it gets the legacy gpu VO — gpu-next under mediacodec
|
||||
// fails every frame on Tegra (#2010) and reshapes no DV anyway.
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
|
||||
// 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,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,9 +1,9 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import dev.jdtech.mpv.EndFileReason
|
||||
import dev.jdtech.mpv.LogLevel
|
||||
import dev.jdtech.mpv.LogMessage
|
||||
import dev.jdtech.mpv.MpvEvent
|
||||
import com.edde746.plezy.libmpv.EndFileReason
|
||||
import com.edde746.plezy.libmpv.LogLevel
|
||||
import com.edde746.plezy.libmpv.LogMessage
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
|
||||
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
|
||||
internal class MpvEndFileDiagnostics {
|
||||
|
||||
@@ -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,12 +16,13 @@ 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.libmpv.*
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import dev.jdtech.mpv.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -63,24 +65,41 @@ class MpvPlayerCore private constructor(
|
||||
* The initial `vo` chain, decided by whether this session will hardware-
|
||||
* decode.
|
||||
*
|
||||
* gpu-next (libplacebo) is the only Android path that applies Dolby Vision
|
||||
* RPU reshaping (#1902), but reshaping only ever happens under software
|
||||
* decode: FFmpeg's mediacodec wrapper exports no DOVI side data, so a
|
||||
* hardware-decoded stream renders the untouched base layer on any VO.
|
||||
* Hardware decode is also where gpu-next breaks: it samples the decoder
|
||||
* output as a samplerExternalOES that libplacebo declares in both shader
|
||||
* stages, and the Tegra GLES linker rejects that pair ("struct type
|
||||
* mismatch between shaders for uniform"), failing every frame — a solid
|
||||
* blue screen with audio on the Shield (#2010). The in-chain gpu fallback
|
||||
* cannot catch it because gpu-next initializes fine and only fails
|
||||
* per-frame renders.
|
||||
* 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.
|
||||
*
|
||||
* So gpu-next is offered exactly where it can help — sessions that will
|
||||
* software-decode — and hardware sessions keep the legacy gpu VO. A
|
||||
* mid-session hwdec fallback to software stays on vo=gpu, which renders
|
||||
* software frames correctly (the pre-2.15.0 behavior for every session).
|
||||
* 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) "gpu" else "gpu-next,gpu"
|
||||
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
|
||||
@@ -89,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
|
||||
@@ -260,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
|
||||
@@ -287,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)
|
||||
@@ -295,6 +384,7 @@ class MpvPlayerCore private constructor(
|
||||
ensureFlutterOverlayOnTop()
|
||||
val sv = surfaceView
|
||||
if (sv != null) applySurfaceSize(sv.width, sv.height)
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
|
||||
|
||||
@@ -308,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
|
||||
@@ -322,15 +417,25 @@ class MpvPlayerCore private constructor(
|
||||
setOption("gapless-audio", "weak")
|
||||
} else {
|
||||
// vo choice is decode-path-dependent; rationale on
|
||||
// initialVideoOutput. Film grain is left on its `auto` default:
|
||||
// applied by the VO under gpu-next, by the decoder under gpu.
|
||||
// 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).
|
||||
@@ -342,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")
|
||||
}
|
||||
@@ -361,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)
|
||||
@@ -397,11 +515,28 @@ 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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,6 +568,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)
|
||||
}
|
||||
}
|
||||
@@ -484,6 +631,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
|
||||
@@ -559,10 +1000,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
|
||||
@@ -880,12 +1326,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) {
|
||||
@@ -1205,6 +1775,7 @@ class MpvPlayerCore private constructor(
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
@@ -1212,7 +1783,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)
|
||||
@@ -1222,7 +1793,7 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrDisplayActive)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
@@ -1273,11 +1844,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 ->
|
||||
@@ -1329,6 +1904,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)
|
||||
}
|
||||
@@ -1339,6 +1915,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,6 +1,7 @@
|
||||
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
|
||||
@@ -14,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,
|
||||
@@ -62,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)
|
||||
|
||||
@@ -95,6 +108,7 @@ open class MpvPlayerPlugin(
|
||||
++sessionGeneration
|
||||
val core = playerCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
cancelPendingInits()
|
||||
return core
|
||||
}
|
||||
@@ -149,7 +163,7 @@ open class MpvPlayerPlugin(
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
"getStats" -> handleGetStats(result)
|
||||
@@ -164,6 +178,13 @@ open class MpvPlayerPlugin(
|
||||
"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()
|
||||
@@ -247,6 +268,7 @@ open class MpvPlayerPlugin(
|
||||
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)
|
||||
@@ -258,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)")
|
||||
@@ -315,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") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,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,86 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
|
||||
/**
|
||||
* Reports whether an enabled accessibility service can consume the app's semantics tree.
|
||||
*
|
||||
* Flutter compiles a semantics tree for every frame while [AccessibilityManager.isEnabled], which
|
||||
* Android sets for any bound service. On TV that is routinely a utility that never reads app
|
||||
* content (a launcher's foreground-app hook, a key remapper), so Dart gates the tree on this
|
||||
* verdict. The verdict errs towards keeping the tree:
|
||||
*
|
||||
* - touch exploration is a screen reader, full stop;
|
||||
* - an empty enabled list while accessibility is on means a [android.app.UiAutomation] client
|
||||
* (instrumentation, Maestro), which is not listed but reads the tree;
|
||||
* - on API 33+ a service flagged `isAccessibilityTool`, or one giving spoken, braille, audible or
|
||||
* visual feedback, consumes; a non-tool with only generic/haptic feedback does not;
|
||||
* - below 33 only a haptic-only service is treated as not consuming, since `feedbackGeneric` is
|
||||
* what Switch Access-style tools declare.
|
||||
*/
|
||||
class AssistiveTechnologyMonitor(context: Context) {
|
||||
private val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
private var onChanged: (() -> Unit)? = null
|
||||
private val stateListener = AccessibilityManager.AccessibilityStateChangeListener { onChanged?.invoke() }
|
||||
private val touchExplorationListener =
|
||||
AccessibilityManager.TouchExplorationStateChangeListener { onChanged?.invoke() }
|
||||
private var servicesListener: AccessibilityManager.AccessibilityServicesStateChangeListener? = null
|
||||
|
||||
fun start(onChanged: () -> Unit) {
|
||||
if (this.onChanged != null) return
|
||||
this.onChanged = onChanged
|
||||
manager.addAccessibilityStateChangeListener(stateListener)
|
||||
manager.addTouchExplorationStateChangeListener(touchExplorationListener)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val listener = AccessibilityManager.AccessibilityServicesStateChangeListener { onChanged() }
|
||||
servicesListener = listener
|
||||
manager.addAccessibilityServicesStateChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (onChanged == null) return
|
||||
onChanged = null
|
||||
manager.removeAccessibilityStateChangeListener(stateListener)
|
||||
manager.removeTouchExplorationStateChangeListener(touchExplorationListener)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
servicesListener?.let { manager.removeAccessibilityServicesStateChangeListener(it) }
|
||||
servicesListener = null
|
||||
}
|
||||
}
|
||||
|
||||
fun signals(): Map<String, Any> {
|
||||
val services = manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||
return mapOf(
|
||||
"accessibilityEnabled" to manager.isEnabled,
|
||||
"touchExplorationEnabled" to manager.isTouchExplorationEnabled,
|
||||
"enabledServiceCount" to services.size,
|
||||
"consumesSemantics" to consumesSemantics(services)
|
||||
)
|
||||
}
|
||||
|
||||
private fun consumesSemantics(services: List<AccessibilityServiceInfo>): Boolean {
|
||||
if (manager.isTouchExplorationEnabled) return true
|
||||
if (services.isEmpty()) return true
|
||||
return services.any { info -> consumesSemantics(info) }
|
||||
}
|
||||
|
||||
private fun consumesSemantics(info: AccessibilityServiceInfo): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (info.isAccessibilityTool) return true
|
||||
return info.feedbackType and READER_FEEDBACK != 0
|
||||
}
|
||||
return info.feedbackType and AccessibilityServiceInfo.FEEDBACK_HAPTIC.inv() != 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READER_FEEDBACK =
|
||||
AccessibilityServiceInfo.FEEDBACK_SPOKEN or
|
||||
AccessibilityServiceInfo.FEEDBACK_BRAILLE or
|
||||
AccessibilityServiceInfo.FEEDBACK_AUDIBLE or
|
||||
AccessibilityServiceInfo.FEEDBACK_VISUAL
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,3 +13,8 @@ add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
|
||||
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
|
||||
|
||||
add_executable(mpv_utf8_convert_test mpv_utf8_convert_test.cpp)
|
||||
target_compile_features(mpv_utf8_convert_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME mpv_utf8_convert_test COMMAND mpv_utf8_convert_test)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "../../../../libmpv/src/main/cpp/utf8_convert.h"
|
||||
|
||||
namespace {
|
||||
|
||||
using plezy::utf8::FromUtf16;
|
||||
using plezy::utf8::ToUtf16;
|
||||
|
||||
bool check(bool condition, const char* message) {
|
||||
if (!condition) std::fprintf(stderr, "%s\n", message);
|
||||
return condition;
|
||||
}
|
||||
|
||||
bool roundTripsAsciiBmpAndSupplementary() {
|
||||
// "a" U+00E9 U+4E2D U+1F3AC (clapper board) — 1/2/3/4-byte sequences.
|
||||
const std::string utf8 = "a\xC3\xA9\xE4\xB8\xAD\xF0\x9F\x8E\xAC";
|
||||
const std::u16string utf16 = ToUtf16(utf8.c_str());
|
||||
return check(utf16 == u"a\u00E9\u4E2D\U0001F3AC", "UTF-8 -> UTF-16 mismatch") &&
|
||||
check(FromUtf16(utf16.data(), utf16.size()) == utf8, "UTF-16 -> UTF-8 mismatch");
|
||||
}
|
||||
|
||||
bool doesNotEmitModifiedUtf8() {
|
||||
// JNI's modified UTF-8 would encode U+1F3AC as a 6-byte CESU-8 surrogate
|
||||
// pair; mpv/open() need the real 4-byte form.
|
||||
const std::u16string clapper = u"\U0001F3AC";
|
||||
return check(FromUtf16(clapper.data(), clapper.size()) == "\xF0\x9F\x8E\xAC", "supplementary char not 4 bytes") &&
|
||||
check(
|
||||
ToUtf16("\xED\xA0\xBC\xED\xBE\xAC") == u"\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD",
|
||||
"CESU-8 surrogate bytes accepted as UTF-8");
|
||||
}
|
||||
|
||||
bool replacesMalformedBytesOneAtATime() {
|
||||
return check(ToUtf16("ok\xFF\xFEz") == u"ok\uFFFD\uFFFDz", "stray bytes not replaced individually") &&
|
||||
check(ToUtf16("\xC0\x80") == u"\uFFFD\uFFFD", "overlong NUL accepted") &&
|
||||
check(ToUtf16("\xE2\x82") == u"\uFFFD\uFFFD", "truncated sequence not replaced") &&
|
||||
check(ToUtf16("\xF4\x90\x80\x80") == u"\uFFFD\uFFFD\uFFFD\uFFFD", "code point above U+10FFFF accepted") &&
|
||||
check(ToUtf16("\xE0\x80\xAF") == u"\uFFFD\uFFFD\uFFFD", "overlong 3-byte form accepted");
|
||||
}
|
||||
|
||||
bool replacesLoneSurrogates() {
|
||||
const std::u16string lone = u"x\xD83Cy\xDFACz";
|
||||
return check(FromUtf16(lone.data(), lone.size()) == "x\xEF\xBF\xBDy\xEF\xBF\xBDz", "lone surrogates not replaced");
|
||||
}
|
||||
|
||||
bool handlesNullAndEmpty() {
|
||||
return check(ToUtf16(nullptr).empty(), "NULL input not empty") &&
|
||||
check(ToUtf16("").empty(), "empty input not empty") &&
|
||||
check(FromUtf16(nullptr, 0).empty(), "NULL UTF-16 not empty");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const bool ok = roundTripsAsciiBmpAndSupplementary() && doesNotEmitModifiedUtf8() &&
|
||||
replacesMalformedBytesOneAtATime() && replacesLoneSurrogates() && handlesNullAndEmpty();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.edde746.plezy
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -30,6 +32,40 @@ class ExternalPlayerChannelTest {
|
||||
assertEquals(false, result["playbackError"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshLaunchTellsPlayerToStartFromBeginning() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
val channel = ExternalPlayerChannel(activity)
|
||||
val source = ExternalPlayerChannel.Source(
|
||||
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
|
||||
grantRead = false,
|
||||
fileName = "file.mkv"
|
||||
)
|
||||
|
||||
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 0L, title = "Episode")
|
||||
|
||||
assertTrue(intent.getBooleanExtra("from_start", false))
|
||||
assertFalse(intent.hasExtra("position"))
|
||||
assertFalse(intent.hasExtra("startfrom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeLaunchPassesPositionAndDisablesFromStart() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
val channel = ExternalPlayerChannel(activity)
|
||||
val source = ExternalPlayerChannel.Source(
|
||||
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
|
||||
grantRead = false,
|
||||
fileName = "file.mkv"
|
||||
)
|
||||
|
||||
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 90_000L, title = null)
|
||||
|
||||
assertFalse(intent.getBooleanExtra("from_start", true))
|
||||
assertEquals(90_000, intent.getIntExtra("position", -1))
|
||||
assertEquals(90_000, intent.getIntExtra("startfrom", -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activityDestroyCompletesPendingChannelCall() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
|
||||
@@ -94,24 +94,111 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListNamesOnlyCodecsMpvsStereoIecTrackCanCarry() {
|
||||
// eac3 (a 192kHz burst), truehd and dts-hd (192kHz/8ch) never survive mpv's audiotrack
|
||||
// AO, which opens every spdif format as a stereo IEC 61937 track at the mixer rate;
|
||||
// naming them strands playback on a dead audio output (#1991).
|
||||
assertEquals("ac3,dts", mpvSpdifCodecs { true })
|
||||
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 bitstreams, DTS does not (#1703).
|
||||
// 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", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
|
||||
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
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
|
||||
@@ -204,4 +291,17 @@ class AudioOutputPolicyTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import android.os.Looper
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.FrameLayout
|
||||
import com.edde746.plezy.libmpv.EndFileReason
|
||||
import com.edde746.plezy.libmpv.LogLevel
|
||||
import com.edde746.plezy.libmpv.LogMessage
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import dev.jdtech.mpv.EndFileReason
|
||||
import dev.jdtech.mpv.LogLevel
|
||||
import dev.jdtech.mpv.LogMessage
|
||||
import dev.jdtech.mpv.MpvEvent
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -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
|
||||
@@ -62,14 +63,24 @@ class MpvPlayerPluginTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardwareDecodeSessionsKeepTheLegacyGpuVo() {
|
||||
// gpu-next under hwdec=mediacodec fails every frame on Tegra (the
|
||||
// cross-stage samplerExternalOES linker bug): solid blue screen with
|
||||
// audio on the Shield (#2010). DV reshaping — the reason gpu-next exists
|
||||
// on Android (#1902) — only happens under software decode anyway, so
|
||||
// gpu-next is offered exactly there and nowhere else.
|
||||
assertEquals("gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
|
||||
assertEquals("gpu-next,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
|
||||
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
|
||||
@@ -622,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()
|
||||
@@ -891,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,94 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.time.Duration
|
||||
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
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ android {
|
||||
compileSdk = 36
|
||||
// Matches the app's latest stable NDK so every project-owned native library
|
||||
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
|
||||
// is NOT what ships: the app packages the libmpv AAR's newer copy with top
|
||||
// is NOT what ships: the app packages the mpv-build tarball's newer copy with top
|
||||
// merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
fun verifySha256(file: File, expected: String, identity: String) {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") {
|
||||
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
if (actual != expected) {
|
||||
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteDirectory(staging: File, destination: File) {
|
||||
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
||||
val hadDestination = destination.exists()
|
||||
try {
|
||||
if (hadDestination) {
|
||||
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
try {
|
||||
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (promotionFailure: Exception) {
|
||||
if (hadDestination && backup.exists()) {
|
||||
try {
|
||||
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (restoreFailure: Exception) {
|
||||
promotionFailure.addSuppressed(restoreFailure)
|
||||
}
|
||||
}
|
||||
throw promotionFailure
|
||||
}
|
||||
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
||||
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
||||
}
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// mpv Kotlin API + JNI glue built in-project (imported from the libmpv-android
|
||||
// fork, commit e60c3ba); the external dependency is reduced to prebuilt native
|
||||
// trees carried by the mpv-build per-ABI tarballs pinned below.
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
// Single source for the pinned native artifacts: the repo-root
|
||||
// mpv-build.lock.json (github.com/edde746/mpv-build release assets + sha256
|
||||
// checksums). This module downloads and extracts them; app/build.gradle.kts
|
||||
// reads FFmpeg .so files and the libc++ runtime back out of the extracted
|
||||
// trees.
|
||||
val mpvAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val mpvLockFile = rootProject.file("../mpv-build.lock.json")
|
||||
val mpvLockAndroid = run {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lock = groovy.json.JsonSlurper().parse(mpvLockFile) as Map<String, Any?>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
((lock["artifacts"] as? Map<String, Any?>)?.get("android") as? Map<String, Any?>)
|
||||
?: throw GradleException("$mpvLockFile has no artifacts.android section")
|
||||
}
|
||||
val mpvKey = mpvLockAndroid["key"] as? String
|
||||
?: throw GradleException("$mpvLockFile artifacts.android.key is missing")
|
||||
val mpvAssetBase = mpvLockAndroid["assetBase"] as? String
|
||||
?: throw GradleException("$mpvLockFile artifacts.android.assetBase is missing")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val mpvAssets = (mpvLockAndroid["assets"] as? Map<String, Map<String, String>>).let { assets ->
|
||||
val missing = mpvAbis.filter { assets?.get(it)?.get("asset").isNullOrEmpty() || assets?.get(it)?.get("checksum").isNullOrEmpty() }
|
||||
if (assets == null || missing.isNotEmpty()) {
|
||||
throw GradleException("$mpvLockFile artifacts.android.assets lacks asset+checksum for: ${missing.joinToString()}")
|
||||
}
|
||||
assets
|
||||
}
|
||||
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvArchivesDir = File(mpvDir, "archives")
|
||||
val mpvNativeDir = File(mpvDir, "native")
|
||||
val mpvLibcxxDir = File(mpvDir, "libcxx")
|
||||
|
||||
// Downloaded archives are renamed to a key-independent name so a lock bump
|
||||
// (new key) changes task inputs, not the output file set.
|
||||
fun stagedArchiveName(abi: String) = "libmpv-android-$abi.tar.gz"
|
||||
|
||||
// Dev-only escape hatch: point the build at a directory of locally built
|
||||
// mpv-build android tarballs (platforms/android output, one
|
||||
// libmpv-android-<key>-<abi>.tar.gz per ABI) to test changes before a lock
|
||||
// bump. Checksums are skipped for local archives; the lock stays
|
||||
// authoritative otherwise.
|
||||
val localMpvDir: String? = (project.findProperty("plezy.localMpvDir") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_DIR")
|
||||
|
||||
fun localArchive(abi: String): File {
|
||||
val dir = File(localMpvDir!!)
|
||||
val exact = File(dir, mpvAssets.getValue(abi).getValue("asset"))
|
||||
if (exact.isFile) return exact
|
||||
val pattern = Regex("libmpv-android-.+-${Regex.escape(abi)}\\.tar\\.gz")
|
||||
val matches = dir.listFiles()?.filter { it.isFile && pattern.matches(it.name) }.orEmpty()
|
||||
return matches.singleOrNull() ?: throw GradleException(
|
||||
"PLEZY_LOCAL_MPV_DIR=$localMpvDir must contain exactly one libmpv-android-*-$abi.tar.gz, found ${matches.size}"
|
||||
)
|
||||
}
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val manifest = File(mpvArchivesDir, ".manifest")
|
||||
inputs.file(mpvLockFile)
|
||||
inputs.property("localOverride", localMpvDir ?: "")
|
||||
if (localMpvDir != null) mpvAbis.forEach { abi -> inputs.file(localArchive(abi)) }
|
||||
outputs.files(mpvAbis.map { File(mpvArchivesDir, stagedArchiveName(it)) } + manifest)
|
||||
doLast {
|
||||
mpvDir.mkdirs()
|
||||
val staging = File(mpvDir, "archives.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val manifestText = StringBuilder()
|
||||
mpvAbis.forEach { abi ->
|
||||
val staged = File(staging, stagedArchiveName(abi))
|
||||
if (localMpvDir != null) {
|
||||
val source = localArchive(abi)
|
||||
source.copyTo(staged, overwrite = true)
|
||||
manifestText.append("$abi=local:${source.absolutePath}\n")
|
||||
} else {
|
||||
val asset = mpvAssets.getValue(abi).getValue("asset")
|
||||
val checksum = mpvAssets.getValue(abi).getValue("checksum")
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", "$mpvAssetBase/$asset", "-o", staged.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $asset (mpv-build $mpvKey)", error)
|
||||
}
|
||||
verifySha256(staged, checksum, "$asset (mpv-build $mpvKey)")
|
||||
manifestText.append("$abi=$asset sha256=$checksum\n")
|
||||
}
|
||||
}
|
||||
File(staging, ".manifest").writeText(manifestText.toString())
|
||||
promoteDirectory(staging, mpvArchivesDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each tarball is a native tree: lib/*.so (libmpv, seven FFmpeg libraries,
|
||||
// libc++_shared) + include/mpv/*.h. The .so files land in the per-ABI jniLibs
|
||||
// layout under native/jni, the headers under native/include for the CMake
|
||||
// glue build, and libc++ in a separate tree that the app packages at PROJECT
|
||||
// scope so the tarball's 16 KB-capable runtime deterministically wins the
|
||||
// merge (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
val extractLibmpvNative = tasks.register("extractLibmpvNative") {
|
||||
dependsOn(downloadLibmpv)
|
||||
inputs.files(mpvAbis.map { File(mpvArchivesDir, stagedArchiveName(it)) })
|
||||
outputs.dir(mpvNativeDir)
|
||||
outputs.dir(mpvLibcxxDir)
|
||||
doLast {
|
||||
val nativeStaging = File(mpvDir, "native.staging-${UUID.randomUUID()}")
|
||||
val libcxxStaging = File(mpvDir, "libcxx.staging-${UUID.randomUUID()}")
|
||||
val unpackRoot = File(mpvDir, "unpack-${UUID.randomUUID()}")
|
||||
try {
|
||||
mpvAbis.forEach { abi ->
|
||||
val unpack = File(unpackRoot, abi).apply { mkdirs() }
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"tar",
|
||||
"-xzf",
|
||||
File(mpvArchivesDir, stagedArchiveName(abi)).absolutePath,
|
||||
"-C",
|
||||
unpack.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
val jniDir = File(nativeStaging, "jni/$abi")
|
||||
File(unpack, "lib").listFiles()?.filter { it.isFile && it.name.endsWith(".so") }?.forEach { so ->
|
||||
val target = if (so.name == "libc++_shared.so") {
|
||||
File(libcxxStaging, "jni/$abi/${so.name}")
|
||||
} else {
|
||||
File(jniDir, so.name)
|
||||
}
|
||||
target.parentFile.mkdirs()
|
||||
Files.move(so.toPath(), target.toPath())
|
||||
}
|
||||
// Headers are identical across ABIs; keep the first archive's copy.
|
||||
val include = File(unpack, "include")
|
||||
val includeTarget = File(nativeStaging, "include")
|
||||
if (!includeTarget.exists() && include.isDirectory) {
|
||||
include.copyRecursively(includeTarget)
|
||||
}
|
||||
}
|
||||
val missing = buildList {
|
||||
mpvAbis.forEach { abi ->
|
||||
if (!File(nativeStaging, "jni/$abi/libmpv.so").isFile) add("native/jni/$abi/libmpv.so")
|
||||
if (!File(nativeStaging, "jni/$abi/libavcodec.so").isFile) add("native/jni/$abi/libavcodec.so")
|
||||
if (!File(libcxxStaging, "jni/$abi/libc++_shared.so").isFile) add("libcxx/jni/$abi/libc++_shared.so")
|
||||
}
|
||||
if (!File(nativeStaging, "include/mpv/client.h").isFile) add("native/include/mpv/client.h")
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"mpv-build $mpvKey android archives are missing expected entries: ${missing.joinToString()}"
|
||||
)
|
||||
}
|
||||
promoteDirectory(nativeStaging, mpvNativeDir)
|
||||
promoteDirectory(libcxxStaging, mpvLibcxxDir)
|
||||
} finally {
|
||||
nativeStaging.deleteRecursively()
|
||||
libcxxStaging.deleteRecursively()
|
||||
unpackRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy.libmpv"
|
||||
compileSdk = 36
|
||||
// Matches the app's latest stable NDK so every project-owned native library
|
||||
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
|
||||
// is NOT what ships: the app packages the mpv-build tarball's newer copy with
|
||||
// top merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
defaultConfig {
|
||||
// Fire OS 6.x (API 25), same floor as the app. The fork declared 26, but
|
||||
// nothing in this API or glue uses anything above 25.
|
||||
minSdk = 25
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DANDROID_STL=c++_shared",
|
||||
"-DMPV_PREBUILT_ROOT=${mpvNativeDir.absolutePath}"
|
||||
)
|
||||
cFlags += "-Werror"
|
||||
cppFlags += "-std=c++11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// Prebuilt libmpv + FFmpeg .so files extracted from the mpv-build tarballs;
|
||||
// the glue libplayer.so comes from the CMake build above.
|
||||
jniLibs.srcDir(File(mpvNativeDir, "jni"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
// The imported libmpv.so/libavcodec.so must exist before CMake links the glue.
|
||||
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
||||
dependsOn(extractLibmpvNative)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted prebuilt directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractLibmpvNative)
|
||||
}
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(extractLibmpvNative)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Same version the app pins; MpvPlayer's public flows compile against it.
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# JNI exports bind by name (Java_com_edde746_plezy_libmpv_MpvPlayer_native*); keep the names stable.
|
||||
-keepclasseswithmembernames class com.edde746.plezy.libmpv.* {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# jni_utils.cpp caches MpvPlayer with FindClass and resolves these static callbacks
|
||||
# with GetStaticMethodID on the native event thread. R8 sees no reference to the
|
||||
# class name or the member names, so both must stay alive and un-renamed.
|
||||
-keep class com.edde746.plezy.libmpv.MpvPlayer {
|
||||
public static void onPropertyChanged(...);
|
||||
public static void onEvent(int);
|
||||
public static void onEndFile(int);
|
||||
public static void onLogMessage(java.lang.String, int, java.lang.String);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project("libmpv")
|
||||
|
||||
# The JNI glue this module compiles; MpvPlayer loads it with
|
||||
# System.loadLibrary("player") after libmpv.so itself.
|
||||
add_library(
|
||||
player
|
||||
SHARED
|
||||
main.cpp render.cpp log.cpp jni_utils.cpp property.cpp event.cpp
|
||||
)
|
||||
|
||||
# Prebuilt libraries extracted from the pinned mpv-build per-ABI tarballs by the
|
||||
# extractLibmpvNative Gradle task; MPV_PREBUILT_ROOT points at its output.
|
||||
add_library(
|
||||
mpv
|
||||
SHARED
|
||||
IMPORTED
|
||||
)
|
||||
|
||||
set_target_properties(
|
||||
mpv
|
||||
PROPERTIES IMPORTED_LOCATION
|
||||
${MPV_PREBUILT_ROOT}/jni/${ANDROID_ABI}/libmpv.so
|
||||
)
|
||||
|
||||
# av_jni_set_java_vm / av_jni_set_android_app_ctx (main.cpp) live in FFmpeg's
|
||||
# libavcodec, which the same tarballs package.
|
||||
add_library(
|
||||
avcodec
|
||||
SHARED
|
||||
IMPORTED
|
||||
)
|
||||
|
||||
set_target_properties(
|
||||
avcodec
|
||||
PROPERTIES IMPORTED_LOCATION
|
||||
${MPV_PREBUILT_ROOT}/jni/${ANDROID_ABI}/libavcodec.so
|
||||
)
|
||||
|
||||
# mpv public headers ride in the extracted tarballs next to libmpv.so; the
|
||||
# vendored tree only carries FFmpeg's libavcodec/jni.h (see include/README.md).
|
||||
include_directories( ${MPV_PREBUILT_ROOT}/include ${CMAKE_CURRENT_SOURCE_DIR}/include )
|
||||
|
||||
target_link_libraries( player mpv avcodec log )
|
||||
@@ -0,0 +1,99 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop) {
|
||||
jstring jprop = new_java_string(env, prop->name);
|
||||
jstring jvalue = NULL;
|
||||
switch (prop->format) {
|
||||
case MPV_FORMAT_NONE:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_S, jprop);
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sb, jprop, *(int*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_INT64:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sl, jprop, *(int64_t*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_DOUBLE:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sd, jprop, *(double*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_STRING:
|
||||
jvalue = new_java_string(env, *(const char**)prop->data);
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SS, jprop, jvalue);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (jprop) env->DeleteLocalRef(jprop);
|
||||
if (jvalue) env->DeleteLocalRef(jvalue);
|
||||
}
|
||||
|
||||
static void sendEventToJava(JNIEnv* env, int event) {
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEvent, event);
|
||||
}
|
||||
|
||||
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
|
||||
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
|
||||
int reason = end_file ? end_file->reason : -1;
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason);
|
||||
}
|
||||
|
||||
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
|
||||
jstring jprefix = new_java_string(env, msg->prefix);
|
||||
jstring jtext = new_java_string(env, msg->text);
|
||||
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, jprefix, (jint)msg->log_level, jtext);
|
||||
|
||||
if (jprefix) env->DeleteLocalRef(jprefix);
|
||||
if (jtext) env->DeleteLocalRef(jtext);
|
||||
}
|
||||
|
||||
void* event_thread(void* arg) {
|
||||
JNIEnv* env = NULL;
|
||||
acquire_jni_env(g_vm, &env);
|
||||
if (!env) die("failed to acquire java env");
|
||||
|
||||
while (true) {
|
||||
mpv_event* mp_event;
|
||||
mpv_event_property* mp_property;
|
||||
mpv_event_log_message* msg;
|
||||
|
||||
mp_event = mpv_wait_event(g_mpv, -1.0);
|
||||
|
||||
if (g_event_thread_request_exit) break;
|
||||
|
||||
if (mp_event->event_id == MPV_EVENT_NONE) continue;
|
||||
|
||||
switch (mp_event->event_id) {
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
msg = (mpv_event_log_message*)mp_event->data;
|
||||
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
|
||||
sendLogMessageToJava(env, msg);
|
||||
break;
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
mp_property = (mpv_event_property*)mp_event->data;
|
||||
sendPropertyUpdateToJava(env, mp_property);
|
||||
break;
|
||||
case MPV_EVENT_END_FILE:
|
||||
sendEndFileToJava(env, mp_event);
|
||||
break;
|
||||
case MPV_EVENT_START_FILE:
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id);
|
||||
break;
|
||||
default:
|
||||
// Nothing on the Kotlin side consumes the remaining ids (MpvEvent.fromId).
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
g_vm->DetachCurrentThread();
|
||||
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void* event_thread(void* arg);
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
extern JavaVM* g_vm;
|
||||
extern mpv_handle* g_mpv;
|
||||
extern std::atomic<bool> g_event_thread_request_exit;
|
||||
@@ -0,0 +1,15 @@
|
||||
# Vendored native headers
|
||||
|
||||
Build-time headers for the JNI glue in this module; nothing here ships in the APK.
|
||||
Each file carries its own upstream license text — none is modified.
|
||||
|
||||
- `libavcodec/jni.h` — FFmpeg n8.0.1 (https://github.com/FFmpeg/FFmpeg, tag `n8.0.1`,
|
||||
commit `894da5ca7d742e4429ffb2af534fcda0103ef593`), copied unmodified. Declares
|
||||
`av_jni_set_java_vm` / `av_jni_set_android_app_ctx`, which `main.cpp` calls into the
|
||||
`libavcodec.so` packaged by the pinned mpv-build tarballs (FFmpeg 8.0.1 — the
|
||||
version `app/build.gradle.kts` also pins for the Media3 adapter headers).
|
||||
|
||||
The mpv public headers (`mpv/client.h`, `mpv/render.h`, `mpv/render_gl.h`,
|
||||
`mpv/stream_cb.h`) are no longer vendored: each mpv-build per-ABI tarball carries
|
||||
`include/mpv/*.h` matching its `libmpv.so`, and `extractLibmpvNative` places them
|
||||
under `native/include`, which CMake reads via `MPV_PREBUILT_ROOT`.
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* JNI public API functions
|
||||
*
|
||||
* Copyright (c) 2015-2016 Matthieu Bouron <matthieu.bouron stupeflix.com>
|
||||
*
|
||||
* This file is part of FFmpeg.
|
||||
*
|
||||
* FFmpeg is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*
|
||||
* FFmpeg is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public
|
||||
* License along with FFmpeg; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef AVCODEC_JNI_H
|
||||
#define AVCODEC_JNI_H
|
||||
|
||||
/*
|
||||
* Manually set a Java virtual machine which will be used to retrieve the JNI
|
||||
* environment. Once a Java VM is set it cannot be changed afterwards, meaning
|
||||
* you can call multiple times av_jni_set_java_vm with the same Java VM pointer
|
||||
* however it will error out if you try to set a different Java VM.
|
||||
*
|
||||
* @param vm Java virtual machine
|
||||
* @param log_ctx context used for logging, can be NULL
|
||||
* @return 0 on success, < 0 otherwise
|
||||
*/
|
||||
int av_jni_set_java_vm(void *vm, void *log_ctx);
|
||||
|
||||
/*
|
||||
* Get the Java virtual machine which has been set with av_jni_set_java_vm.
|
||||
*
|
||||
* @param vm Java virtual machine
|
||||
* @return a pointer to the Java virtual machine
|
||||
*/
|
||||
void *av_jni_get_java_vm(void *log_ctx);
|
||||
|
||||
/*
|
||||
* Set the Android application context which will be used to retrieve the Android
|
||||
* content resolver to handle content uris.
|
||||
*
|
||||
* This function is only available on Android.
|
||||
*
|
||||
* @param app_ctx global JNI reference to the Android application context
|
||||
* @return 0 on success, < 0 otherwise
|
||||
*/
|
||||
int av_jni_set_android_app_ctx(void *app_ctx, void *log_ctx);
|
||||
|
||||
/*
|
||||
* Get the Android application context that has been set with
|
||||
* av_jni_set_android_app_ctx.
|
||||
*
|
||||
* This function is only available on Android.
|
||||
*
|
||||
* @return a pointer the the Android application context
|
||||
*/
|
||||
void *av_jni_get_android_app_ctx(void);
|
||||
|
||||
#endif /* AVCODEC_JNI_H */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
||||
/* Copyright (C) 2018 the mpv developers
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef MPV_CLIENT_API_RENDER_H_
|
||||
#define MPV_CLIENT_API_RENDER_H_
|
||||
|
||||
#include "client.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Overview
|
||||
* --------
|
||||
*
|
||||
* This API can be used to make mpv render using supported graphic APIs (such
|
||||
* as OpenGL). It can be used to handle video display.
|
||||
*
|
||||
* The renderer needs to be created with mpv_render_context_create() before
|
||||
* you start playback (or otherwise cause a VO to be created). Then (with most
|
||||
* backends) mpv_render_context_render() can be used to explicitly render the
|
||||
* current video frame. Use mpv_render_context_set_update_callback() to get
|
||||
* notified when there is a new frame to draw.
|
||||
*
|
||||
* Preferably rendering should be done in a separate thread. If you call
|
||||
* normal libmpv API functions on the renderer thread, deadlocks can result
|
||||
* (these are made non-fatal with timeouts, but user experience will obviously
|
||||
* suffer). See "Threading" section below.
|
||||
*
|
||||
* You can output and embed video without this API by setting the mpv "wid"
|
||||
* option to a native window handle (see "Embedding the video window" section
|
||||
* in the client.h header). In general, using the render API is recommended,
|
||||
* because window embedding can cause various issues, especially with GUI
|
||||
* toolkits and certain platforms.
|
||||
*
|
||||
* Supported backends
|
||||
* ------------------
|
||||
*
|
||||
* OpenGL: via MPV_RENDER_API_TYPE_OPENGL, see render_gl.h header.
|
||||
* Software: via MPV_RENDER_API_TYPE_SW, see section "Software renderer"
|
||||
*
|
||||
* Threading
|
||||
* ---------
|
||||
*
|
||||
* You are recommended to do rendering on a separate thread than normal libmpv
|
||||
* use.
|
||||
*
|
||||
* The mpv_render_* functions can be called from any thread, under the
|
||||
* following conditions:
|
||||
* - only one of the mpv_render_* functions can be called at the same time
|
||||
* (unless they belong to different mpv cores created by mpv_create())
|
||||
* - never can be called from within the callbacks set with
|
||||
* mpv_set_wakeup_callback() or mpv_render_context_set_update_callback()
|
||||
* - if the OpenGL backend is used, for all functions the OpenGL context
|
||||
* must be "current" in the calling thread, and it must be the same OpenGL
|
||||
* context as the mpv_render_context was created with. Otherwise, undefined
|
||||
* behavior will occur.
|
||||
* - the thread does not call libmpv API functions other than the mpv_render_*
|
||||
* functions, except APIs which are declared as safe (see below). Likewise,
|
||||
* there must be no lock or wait dependency from the render thread to a
|
||||
* thread using other libmpv functions. Basically, the situation that your
|
||||
* render thread waits for a "not safe" libmpv API function to return must
|
||||
* not happen. If you ignore this requirement, deadlocks can happen, which
|
||||
* are made non-fatal with timeouts; then playback quality will be degraded,
|
||||
* and the message
|
||||
* mpv_render_context_render() not being called or stuck.
|
||||
* is logged. If you set MPV_RENDER_PARAM_ADVANCED_CONTROL, you promise that
|
||||
* this won't happen, and must absolutely guarantee it, or a real deadlock
|
||||
* will freeze the mpv core thread forever.
|
||||
*
|
||||
* libmpv functions which are safe to call from a render thread are:
|
||||
* - functions marked with "Safe to be called from mpv render API threads."
|
||||
* - client.h functions which don't have an explicit or implicit mpv_handle
|
||||
* parameter
|
||||
* - mpv_render_* functions; but only for the same mpv_render_context pointer.
|
||||
* If the pointer is different, mpv_render_context_free() is not safe. (The
|
||||
* reason is that if MPV_RENDER_PARAM_ADVANCED_CONTROL is set, it may have
|
||||
* to process still queued requests from the core, which it can do only for
|
||||
* the current context, while requests for other contexts would deadlock.
|
||||
* Also, it may have to wait and block for the core to terminate the video
|
||||
* chain to make sure no resources are used after context destruction.)
|
||||
* - if the mpv_handle parameter refers to a different mpv core than the one
|
||||
* you're rendering for (very obscure, but allowed)
|
||||
*
|
||||
* Note about old libmpv version:
|
||||
*
|
||||
* Before API version 1.105 (basically in mpv 0.29.x), simply enabling
|
||||
* MPV_RENDER_PARAM_ADVANCED_CONTROL could cause deadlock issues. This can
|
||||
* be worked around by setting the "vd-lavc-dr" option to "no".
|
||||
* In addition, you were required to call all mpv_render*() API functions
|
||||
* from the same thread on which mpv_render_context_create() was originally
|
||||
* run (for the same the mpv_render_context). Not honoring it led to UB
|
||||
* (deadlocks, use of invalid mp_thread handles), even if you moved your GL
|
||||
* context to a different thread correctly.
|
||||
* These problems were addressed in API version 1.105 (mpv 0.30.0).
|
||||
*
|
||||
* Context and handle lifecycle
|
||||
* ----------------------------
|
||||
*
|
||||
* Video initialization will fail if the render context was not initialized yet
|
||||
* (with mpv_render_context_create()), or it will revert to a VO that creates
|
||||
* its own window.
|
||||
*
|
||||
* Currently, there can be only 1 mpv_render_context at a time per mpv core.
|
||||
*
|
||||
* Calling mpv_render_context_free() while a VO is using the render context is
|
||||
* active will disable video.
|
||||
*
|
||||
* You must free the context with mpv_render_context_free() before the mpv core
|
||||
* is destroyed. If this doesn't happen, undefined behavior will result.
|
||||
*
|
||||
* Software renderer
|
||||
* -----------------
|
||||
*
|
||||
* MPV_RENDER_API_TYPE_SW provides an extremely simple (but slow) renderer to
|
||||
* memory surfaces. You probably don't want to use this. Use other render API
|
||||
* types, or other methods of video embedding.
|
||||
*
|
||||
* Use mpv_render_context_create() with MPV_RENDER_PARAM_API_TYPE set to
|
||||
* MPV_RENDER_API_TYPE_SW.
|
||||
*
|
||||
* Call mpv_render_context_render() with various MPV_RENDER_PARAM_SW_* fields
|
||||
* to render the video frame to an in-memory surface. The following fields are
|
||||
* required: MPV_RENDER_PARAM_SW_SIZE, MPV_RENDER_PARAM_SW_FORMAT,
|
||||
* MPV_RENDER_PARAM_SW_STRIDE, MPV_RENDER_PARAM_SW_POINTER.
|
||||
*
|
||||
* This method of rendering is very slow, because everything, including color
|
||||
* conversion, scaling, and OSD rendering, is done on the CPU, single-threaded.
|
||||
* In particular, large video or display sizes, as well as presence of OSD or
|
||||
* subtitles can make it too slow for realtime. As with other software rendering
|
||||
* VOs, setting "sw-fast" may help. Enabling or disabling zimg may help,
|
||||
* depending on the platform.
|
||||
*
|
||||
* In addition, certain multimedia job creation measures like HDR may not work
|
||||
* properly, and will have to be manually handled by for example inserting
|
||||
* filters.
|
||||
*
|
||||
* This API is not really suitable to extract individual frames from video etc.
|
||||
* (basically non-playback uses) - there are better libraries for this. It can
|
||||
* be used this way, but it may be clunky and tricky.
|
||||
*
|
||||
* Further notes:
|
||||
* - MPV_RENDER_PARAM_FLIP_Y is currently ignored (unsupported)
|
||||
* - MPV_RENDER_PARAM_DEPTH is ignored (meaningless)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Opaque context, returned by mpv_render_context_create().
|
||||
*/
|
||||
typedef struct mpv_render_context mpv_render_context;
|
||||
|
||||
/**
|
||||
* Parameters for mpv_render_param (which is used in a few places such as
|
||||
* mpv_render_context_create().
|
||||
*
|
||||
* Also see mpv_render_param for conventions and how to use it.
|
||||
*/
|
||||
typedef enum mpv_render_param_type {
|
||||
/**
|
||||
* Not a valid value, but also used to terminate a params array. Its value
|
||||
* is always guaranteed to be 0 (even if the ABI changes in the future).
|
||||
*/
|
||||
MPV_RENDER_PARAM_INVALID = 0,
|
||||
/**
|
||||
* The render API to use. Valid for mpv_render_context_create().
|
||||
*
|
||||
* Type: char*
|
||||
*
|
||||
* Defined APIs:
|
||||
*
|
||||
* MPV_RENDER_API_TYPE_OPENGL:
|
||||
* OpenGL desktop 2.1 or later (preferably core profile compatible to
|
||||
* OpenGL 3.2), or OpenGLES 2.0 or later.
|
||||
* Providing MPV_RENDER_PARAM_OPENGL_INIT_PARAMS is required.
|
||||
* It is expected that an OpenGL context is valid and "current" when
|
||||
* calling mpv_render_* functions (unless specified otherwise). It
|
||||
* must be the same context for the same mpv_render_context.
|
||||
*/
|
||||
MPV_RENDER_PARAM_API_TYPE = 1,
|
||||
/**
|
||||
* Required parameters for initializing the OpenGL renderer. Valid for
|
||||
* mpv_render_context_create().
|
||||
* Type: mpv_opengl_init_params*
|
||||
*/
|
||||
MPV_RENDER_PARAM_OPENGL_INIT_PARAMS = 2,
|
||||
/**
|
||||
* Describes a GL render target. Valid for mpv_render_context_render().
|
||||
* Type: mpv_opengl_fbo*
|
||||
*/
|
||||
MPV_RENDER_PARAM_OPENGL_FBO = 3,
|
||||
/**
|
||||
* Control flipped rendering. Valid for mpv_render_context_render().
|
||||
* Type: int*
|
||||
* If the value is set to 0, render normally. Otherwise, render it flipped,
|
||||
* which is needed e.g. when rendering to an OpenGL default framebuffer
|
||||
* (which has a flipped coordinate system).
|
||||
*/
|
||||
MPV_RENDER_PARAM_FLIP_Y = 4,
|
||||
/**
|
||||
* Control surface depth. Valid for mpv_render_context_render().
|
||||
* Type: int*
|
||||
* This implies the depth of the surface passed to the render function in
|
||||
* bits per channel. If omitted or set to 0, the renderer will assume 8.
|
||||
* Typically used to control dithering.
|
||||
*/
|
||||
MPV_RENDER_PARAM_DEPTH = 5,
|
||||
/**
|
||||
* ICC profile blob. Valid for mpv_render_context_set_parameter().
|
||||
* Type: mpv_byte_array*
|
||||
* Set an ICC profile for use with the "icc-profile-auto" option. (If the
|
||||
* option is not enabled, the ICC data will not be used.)
|
||||
*/
|
||||
MPV_RENDER_PARAM_ICC_PROFILE = 6,
|
||||
/**
|
||||
* Deprecated
|
||||
* Ambient light in lux. Valid for mpv_render_context_set_parameter().
|
||||
* Type: int*
|
||||
* This can be used for automatic gamma correction.
|
||||
*/
|
||||
MPV_RENDER_PARAM_AMBIENT_LIGHT = 7,
|
||||
/**
|
||||
* X11 Display, sometimes used for hwdec. Valid for
|
||||
* mpv_render_context_create(). The Display must stay valid for the lifetime
|
||||
* of the mpv_render_context.
|
||||
* Type: Display*
|
||||
*/
|
||||
MPV_RENDER_PARAM_X11_DISPLAY = 8,
|
||||
/**
|
||||
* Wayland display, sometimes used for hwdec. Valid for
|
||||
* mpv_render_context_create(). The wl_display must stay valid for the
|
||||
* lifetime of the mpv_render_context.
|
||||
* Type: struct wl_display*
|
||||
*/
|
||||
MPV_RENDER_PARAM_WL_DISPLAY = 9,
|
||||
/**
|
||||
* Better control about rendering and enabling some advanced features. Valid
|
||||
* for mpv_render_context_create().
|
||||
*
|
||||
* This conflates multiple requirements the API user promises to abide if
|
||||
* this option is enabled:
|
||||
*
|
||||
* - The API user's render thread, which is calling the mpv_render_*()
|
||||
* functions, never waits for the core. Otherwise deadlocks can happen.
|
||||
* See "Threading" section.
|
||||
* - The callback set with mpv_render_context_set_update_callback() can now
|
||||
* be called even if there is no new frame. The API user should call the
|
||||
* mpv_render_context_update() function, and interpret the return value
|
||||
* for whether a new frame should be rendered.
|
||||
* - Correct functionality is impossible if the update callback is not set,
|
||||
* or not set soon enough after mpv_render_context_create() (the core can
|
||||
* block while waiting for you to call mpv_render_context_update(), and
|
||||
* if the update callback is not correctly set, it will deadlock, or
|
||||
* block for too long).
|
||||
*
|
||||
* In general, setting this option will enable the following features (and
|
||||
* possibly more):
|
||||
*
|
||||
* - "Direct rendering", which means the player decodes directly to a
|
||||
* texture, which saves a copy per video frame ("vd-lavc-dr" option
|
||||
* needs to be enabled, and the rendering backend as well as the
|
||||
* underlying GPU API/driver needs to have support for it).
|
||||
* - Rendering screenshots with the GPU API if supported by the backend
|
||||
* (instead of using a suboptimal software fallback via libswscale).
|
||||
*
|
||||
* Warning: do not just add this without reading the "Threading" section
|
||||
* above, and then wondering that deadlocks happen. The
|
||||
* requirements are tricky. But also note that even if advanced
|
||||
* control is disabled, not adhering to the rules will lead to
|
||||
* playback problems. Enabling advanced controls simply makes
|
||||
* violating these rules fatal.
|
||||
*
|
||||
* Type: int*: 0 for disable (default), 1 for enable
|
||||
*/
|
||||
MPV_RENDER_PARAM_ADVANCED_CONTROL = 10,
|
||||
/**
|
||||
* Return information about the next frame to render. Valid for
|
||||
* mpv_render_context_get_info().
|
||||
*
|
||||
* Type: mpv_render_frame_info*
|
||||
*
|
||||
* It strictly returns information about the _next_ frame. The implication
|
||||
* is that e.g. mpv_render_context_update()'s return value will have
|
||||
* MPV_RENDER_UPDATE_FRAME set, and the user is supposed to call
|
||||
* mpv_render_context_render(). If there is no next frame, then the
|
||||
* return value will have is_valid set to 0.
|
||||
*/
|
||||
MPV_RENDER_PARAM_NEXT_FRAME_INFO = 11,
|
||||
/**
|
||||
* Enable or disable video timing. Valid for mpv_render_context_render().
|
||||
*
|
||||
* Type: int*: 0 for disable, 1 for enable (default)
|
||||
*
|
||||
* When video is timed to audio, the player attempts to render video a bit
|
||||
* ahead, and then do a blocking wait until the target display time is
|
||||
* reached. This blocks mpv_render_context_render() for up to the amount
|
||||
* specified with the "video-timing-offset" global option. You can set
|
||||
* this parameter to 0 to disable this kind of waiting. If you do, it's
|
||||
* recommended to use the target time value in mpv_render_frame_info to
|
||||
* wait yourself, or to set the "video-timing-offset" to 0 instead.
|
||||
*
|
||||
* Disabling this without doing anything in addition will result in A/V sync
|
||||
* being slightly off.
|
||||
*/
|
||||
MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 12,
|
||||
/**
|
||||
* Use to skip rendering in mpv_render_context_render().
|
||||
*
|
||||
* Type: int*: 0 for rendering (default), 1 for skipping
|
||||
*
|
||||
* If this is set, you don't need to pass a target surface to the render
|
||||
* function (and if you do, it's completely ignored). This can still call
|
||||
* into the lower level APIs (i.e. if you use OpenGL, the OpenGL context
|
||||
* must be set).
|
||||
*
|
||||
* Be aware that the render API will consider this frame as having been
|
||||
* rendered. All other normal rules also apply, for example about whether
|
||||
* you have to call mpv_render_context_report_swap(). It also does timing
|
||||
* in the same way.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SKIP_RENDERING = 13,
|
||||
/**
|
||||
* Deprecated. Not supported. Use MPV_RENDER_PARAM_DRM_DISPLAY_V2 instead.
|
||||
* Type : struct mpv_opengl_drm_params*
|
||||
*/
|
||||
MPV_RENDER_PARAM_DRM_DISPLAY = 14,
|
||||
/**
|
||||
* DRM draw surface size, contains draw surface dimensions.
|
||||
* Valid for mpv_render_context_create().
|
||||
* Type : struct mpv_opengl_drm_draw_surface_size*
|
||||
*/
|
||||
MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE = 15,
|
||||
/**
|
||||
* DRM display, contains drm display handles.
|
||||
* Valid for mpv_render_context_create().
|
||||
* Type : struct mpv_opengl_drm_params_v2*
|
||||
*/
|
||||
MPV_RENDER_PARAM_DRM_DISPLAY_V2 = 16,
|
||||
/**
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface size, mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: int[2] (e.g.: int s[2] = {w, h}; param.data = &s[0];)
|
||||
*
|
||||
* The video frame is transformed as with other VOs. Typically, this means
|
||||
* the video gets scaled and black bars are added if the video size or
|
||||
* aspect ratio mismatches with the target size.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_SIZE = 17,
|
||||
/**
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface pixel format,
|
||||
* mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: char* (e.g.: char *f = "rgb0"; param.data = f;)
|
||||
*
|
||||
* Valid values are:
|
||||
* "rgb0", "bgr0", "0bgr", "0rgb"
|
||||
* 4 bytes per pixel RGB, 1 byte (8 bit) per component, component bytes
|
||||
* with increasing address from left to right (e.g. "rgb0" has r at
|
||||
* address 0), the "0" component contains uninitialized garbage (often
|
||||
* the value 0, but not necessarily; the bad naming is inherited from
|
||||
* FFmpeg)
|
||||
* Pixel alignment size: 4 bytes
|
||||
* "rgb24"
|
||||
* 3 bytes per pixel RGB. This is strongly discouraged because it is
|
||||
* very slow.
|
||||
* Pixel alignment size: 1 bytes
|
||||
* other
|
||||
* The API may accept other pixel formats, using mpv internal format
|
||||
* names, as long as it's internally marked as RGB, has exactly 1
|
||||
* plane, and is supported as conversion output. It is not a good idea
|
||||
* to rely on any of these. Their semantics and handling could change.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_FORMAT = 18,
|
||||
/**
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface bytes per line,
|
||||
* mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: size_t*
|
||||
*
|
||||
* This is the number of bytes between a pixel (x, y) and (x, y + 1) on the
|
||||
* target surface. It must be a multiple of the pixel size, and have space
|
||||
* for the surface width as specified by MPV_RENDER_PARAM_SW_SIZE.
|
||||
*
|
||||
* Both stride and pointer value should be a multiple of 64 to facilitate
|
||||
* fast SIMD operation. Lower alignment might trigger slower code paths,
|
||||
* and in the worst case, will copy the entire target frame. If mpv is built
|
||||
* with zimg (and zimg is not disabled), the performance impact might be
|
||||
* less.
|
||||
* In either cases, the pointer and stride must be aligned at least to the
|
||||
* pixel alignment size. Otherwise, crashes and undefined behavior is
|
||||
* possible on platforms which do not support unaligned accesses (either
|
||||
* through normal memory access or aligned SIMD memory access instructions).
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_STRIDE = 19,
|
||||
/*
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface pixel data pointer,
|
||||
* mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: void*
|
||||
*
|
||||
* This points to the first pixel at the left/top corner (0, 0). In
|
||||
* particular, each line y starts at (pointer + stride * y). Upon rendering,
|
||||
* all data between pointer and (pointer + stride * h) is overwritten.
|
||||
* Whether the padding between (w, y) and (0, y + 1) is overwritten is left
|
||||
* unspecified (it should not be, but unfortunately some scaler backends
|
||||
* will do it anyway). It is assumed that even the padding after the last
|
||||
* line (starting at bytepos(w, h) until (pointer + stride * h)) is
|
||||
* writable.
|
||||
*
|
||||
* See MPV_RENDER_PARAM_SW_STRIDE for alignment requirements.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_POINTER = 20,
|
||||
} mpv_render_param_type;
|
||||
|
||||
/**
|
||||
* For backwards compatibility with the old naming of
|
||||
* MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE
|
||||
*/
|
||||
#define MPV_RENDER_PARAM_DRM_OSD_SIZE MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE
|
||||
|
||||
/**
|
||||
* Used to pass arbitrary parameters to some mpv_render_* functions. The
|
||||
* meaning of the data parameter is determined by the type, and each
|
||||
* MPV_RENDER_PARAM_* documents what type the value must point to.
|
||||
*
|
||||
* Each value documents the required data type as the pointer you cast to
|
||||
* void* and set on mpv_render_param.data. For example, if MPV_RENDER_PARAM_FOO
|
||||
* documents the type as Something* , then the code should look like this:
|
||||
*
|
||||
* Something foo = {...};
|
||||
* mpv_render_param param;
|
||||
* param.type = MPV_RENDER_PARAM_FOO;
|
||||
* param.data = & foo;
|
||||
*
|
||||
* Normally, the data field points to exactly 1 object. If the type is char*,
|
||||
* it points to a 0-terminated string.
|
||||
*
|
||||
* In all cases (unless documented otherwise) the pointers need to remain
|
||||
* valid during the call only. Unless otherwise documented, the API functions
|
||||
* will not write to the params array or any data pointed to it.
|
||||
*
|
||||
* As a convention, parameter arrays are always terminated by type==0. There
|
||||
* is no specific order of the parameters required. The order of the 2 fields in
|
||||
* this struct is guaranteed (even after ABI changes).
|
||||
*/
|
||||
typedef struct mpv_render_param {
|
||||
enum mpv_render_param_type type;
|
||||
void *data;
|
||||
} mpv_render_param;
|
||||
|
||||
|
||||
/**
|
||||
* Predefined values for MPV_RENDER_PARAM_API_TYPE.
|
||||
*/
|
||||
// See render_gl.h
|
||||
#define MPV_RENDER_API_TYPE_OPENGL "opengl"
|
||||
// See section "Software renderer"
|
||||
#define MPV_RENDER_API_TYPE_SW "sw"
|
||||
|
||||
/**
|
||||
* Flags used in mpv_render_frame_info.flags. Each value represents a bit in it.
|
||||
*/
|
||||
typedef enum mpv_render_frame_info_flag {
|
||||
/**
|
||||
* Set if there is actually a next frame. If unset, there is no next frame
|
||||
* yet, and other flags and fields that require a frame to be queued will
|
||||
* be unset.
|
||||
*
|
||||
* This is set for _any_ kind of frame, even for redraw requests.
|
||||
*
|
||||
* Note that when this is unset, it simply means no new frame was
|
||||
* decoded/queued yet, not necessarily that the end of the video was
|
||||
* reached. A new frame can be queued after some time.
|
||||
*
|
||||
* If the return value of mpv_render_context_render() had the
|
||||
* MPV_RENDER_UPDATE_FRAME flag set, this flag will usually be set as well,
|
||||
* unless the frame is rendered, or discarded by other asynchronous events.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_PRESENT = 1 << 0,
|
||||
/**
|
||||
* If set, the frame is not an actual new video frame, but a redraw request.
|
||||
* For example if the video is paused, and an option that affects video
|
||||
* rendering was changed (or any other reason), an update request can be
|
||||
* issued and this flag will be set.
|
||||
*
|
||||
* Typically, redraw frames will not be subject to video timing.
|
||||
*
|
||||
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_REDRAW = 1 << 1,
|
||||
/**
|
||||
* If set, this is supposed to reproduce the previous frame perfectly. This
|
||||
* is usually used for certain "video-sync" options ("display-..." modes).
|
||||
* Typically the renderer will blit the video from a FBO. Unset otherwise.
|
||||
*
|
||||
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_REPEAT = 1 << 2,
|
||||
/**
|
||||
* If set, the player timing code expects that the user thread blocks on
|
||||
* vsync (by either delaying the render call, or by making a call to
|
||||
* mpv_render_context_report_swap() at vsync time).
|
||||
*
|
||||
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_BLOCK_VSYNC = 1 << 3,
|
||||
} mpv_render_frame_info_flag;
|
||||
|
||||
/**
|
||||
* Information about the next video frame that will be rendered. Can be
|
||||
* retrieved with MPV_RENDER_PARAM_NEXT_FRAME_INFO.
|
||||
*/
|
||||
typedef struct mpv_render_frame_info {
|
||||
/**
|
||||
* A bitset of mpv_render_frame_info_flag values (i.e. multiple flags are
|
||||
* combined with bitwise or).
|
||||
*/
|
||||
uint64_t flags;
|
||||
/**
|
||||
* Absolute time at which the frame is supposed to be displayed. This is in
|
||||
* the same unit and base as the time returned by mpv_get_time_us(). For
|
||||
* frames that are redrawn, or if vsync locked video timing is used (see
|
||||
* "video-sync" option), then this can be 0. The "video-timing-offset"
|
||||
* option determines how much "headroom" the render thread gets (but a high
|
||||
* enough frame rate can reduce it anyway). mpv_render_context_render() will
|
||||
* normally block until the time is elapsed, unless you pass it
|
||||
* MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 0.
|
||||
*/
|
||||
int64_t target_time;
|
||||
} mpv_render_frame_info;
|
||||
|
||||
/**
|
||||
* Initialize the renderer state. Depending on the backend used, this will
|
||||
* access the underlying GPU API and initialize its own objects.
|
||||
*
|
||||
* You must free the context with mpv_render_context_free(). Not doing so before
|
||||
* the mpv core is destroyed may result in memory leaks or crashes.
|
||||
*
|
||||
* Currently, only at most 1 context can exists per mpv core (it represents the
|
||||
* main video output).
|
||||
*
|
||||
* You should pass the following parameters:
|
||||
* - MPV_RENDER_PARAM_API_TYPE to select the underlying backend/GPU API.
|
||||
* - Backend-specific init parameter, like MPV_RENDER_PARAM_OPENGL_INIT_PARAMS.
|
||||
* - Setting MPV_RENDER_PARAM_ADVANCED_CONTROL and following its rules is
|
||||
* strongly recommended.
|
||||
* - If you want to use hwdec, possibly hwdec interop resources.
|
||||
*
|
||||
* @param res set to the context (on success) or NULL (on failure). The value
|
||||
* is never read and always overwritten.
|
||||
* @param mpv handle used to get the core (the mpv_render_context won't depend
|
||||
* on this specific handle, only the core referenced by it)
|
||||
* @param params an array of parameters, terminated by type==0. It's left
|
||||
* unspecified what happens with unknown parameters. At least
|
||||
* MPV_RENDER_PARAM_API_TYPE is required, and most backends will
|
||||
* require another backend-specific parameter.
|
||||
* @return error code, including but not limited to:
|
||||
* MPV_ERROR_UNSUPPORTED: the OpenGL version is not supported
|
||||
* (or required extensions are missing)
|
||||
* MPV_ERROR_NOT_IMPLEMENTED: an unknown API type was provided, or
|
||||
* support for the requested API was not
|
||||
* built in the used libmpv binary.
|
||||
* MPV_ERROR_INVALID_PARAMETER: at least one of the provided parameters was
|
||||
* not valid.
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_create(mpv_render_context **res, mpv_handle *mpv,
|
||||
mpv_render_param *params);
|
||||
|
||||
/**
|
||||
* Attempt to change a single parameter. Not all backends and parameter types
|
||||
* support all kinds of changes.
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
* @param param the parameter type and data that should be set
|
||||
* @return error code. If a parameter could actually be changed, this returns
|
||||
* success, otherwise an error code depending on the parameter type
|
||||
* and situation.
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_set_parameter(mpv_render_context *ctx,
|
||||
mpv_render_param param);
|
||||
|
||||
/**
|
||||
* Retrieve information from the render context. This is NOT a counterpart to
|
||||
* mpv_render_context_set_parameter(), because you generally can't read
|
||||
* parameters set with it, and this function is not meant for this purpose.
|
||||
* Instead, this is for communicating information from the renderer back to the
|
||||
* user. See mpv_render_param_type; entries which support this function
|
||||
* explicitly mention it, and for other entries you can assume it will fail.
|
||||
*
|
||||
* You pass param with param.type set and param.data pointing to a variable
|
||||
* of the required data type. The function will then overwrite that variable
|
||||
* with the returned value (at least on success).
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
* @param param the parameter type and data that should be retrieved
|
||||
* @return error code. If a parameter could actually be retrieved, this returns
|
||||
* success, otherwise an error code depending on the parameter type
|
||||
* and situation. MPV_ERROR_NOT_IMPLEMENTED is used for unknown
|
||||
* param.type, or if retrieving it is not supported.
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_get_info(mpv_render_context *ctx,
|
||||
mpv_render_param param);
|
||||
|
||||
typedef void (*mpv_render_update_fn)(void *cb_ctx);
|
||||
|
||||
/**
|
||||
* Set the callback that notifies you when a new video frame is available, or
|
||||
* if the video display configuration somehow changed and requires a redraw.
|
||||
* Similar to mpv_set_wakeup_callback(), you must not call any mpv API from
|
||||
* the callback, and all the other listed restrictions apply (such as not
|
||||
* exiting the callback by throwing exceptions).
|
||||
*
|
||||
* This can be called from any thread, except from an update callback. In case
|
||||
* of the OpenGL backend, no OpenGL state or API is accessed.
|
||||
*
|
||||
* Calling this will raise an update callback immediately.
|
||||
*
|
||||
* @param callback callback(callback_ctx) is called if the frame should be
|
||||
* redrawn
|
||||
* @param callback_ctx opaque argument to the callback
|
||||
*/
|
||||
MPV_EXPORT void mpv_render_context_set_update_callback(mpv_render_context *ctx,
|
||||
mpv_render_update_fn callback,
|
||||
void *callback_ctx);
|
||||
|
||||
/**
|
||||
* The API user is supposed to call this when the update callback was invoked
|
||||
* (like all mpv_render_* functions, this has to happen on the render thread,
|
||||
* and _not_ from the update callback itself).
|
||||
*
|
||||
* This is optional if MPV_RENDER_PARAM_ADVANCED_CONTROL was not set (default).
|
||||
* Otherwise, it's a hard requirement that this is called after each update
|
||||
* callback. If multiple update callback happened, and the function could not
|
||||
* be called sooner, it's OK to call it once after the last callback.
|
||||
*
|
||||
* If an update callback happens during or after this function, the function
|
||||
* must be called again at the soonest possible time.
|
||||
*
|
||||
* If MPV_RENDER_PARAM_ADVANCED_CONTROL was set, this will do additional work
|
||||
* such as allocating textures for the video decoder.
|
||||
*
|
||||
* @return a bitset of mpv_render_update_flag values (i.e. multiple flags are
|
||||
* combined with bitwise or). Typically, this will tell the API user
|
||||
* what should happen next. E.g. if the MPV_RENDER_UPDATE_FRAME flag is
|
||||
* set, mpv_render_context_render() should be called. If flags unknown
|
||||
* to the API user are set, or if the return value is 0, nothing needs
|
||||
* to be done.
|
||||
*/
|
||||
MPV_EXPORT uint64_t mpv_render_context_update(mpv_render_context *ctx);
|
||||
|
||||
/**
|
||||
* Flags returned by mpv_render_context_update(). Each value represents a bit
|
||||
* in the function's return value.
|
||||
*/
|
||||
typedef enum mpv_render_update_flag {
|
||||
/**
|
||||
* A new video frame must be rendered. mpv_render_context_render() must be
|
||||
* called.
|
||||
*/
|
||||
MPV_RENDER_UPDATE_FRAME = 1 << 0,
|
||||
} mpv_render_context_flag;
|
||||
|
||||
/**
|
||||
* Render video.
|
||||
*
|
||||
* Typically renders the video to a target surface provided via mpv_render_param
|
||||
* (the details depend on the backend in use). Options like "panscan" are
|
||||
* applied to determine which part of the video should be visible and how the
|
||||
* video should be scaled. You can change these options at runtime by using the
|
||||
* mpv property API.
|
||||
*
|
||||
* The renderer will reconfigure itself every time the target surface
|
||||
* configuration (such as size) is changed.
|
||||
*
|
||||
* This function implicitly pulls a video frame from the internal queue and
|
||||
* renders it. If no new frame is available, the previous frame is redrawn.
|
||||
* The update callback set with mpv_render_context_set_update_callback()
|
||||
* notifies you when a new frame was added. The details potentially depend on
|
||||
* the backends and the provided parameters.
|
||||
*
|
||||
* Generally, libmpv will invoke your update callback some time before the video
|
||||
* frame should be shown, and then lets this function block until the supposed
|
||||
* display time. This will limit your rendering to video FPS. You can prevent
|
||||
* this by setting the "video-timing-offset" global option to 0. (This applies
|
||||
* only to "audio" video sync mode.)
|
||||
*
|
||||
* You should pass the following parameters:
|
||||
* - Backend-specific target object, such as MPV_RENDER_PARAM_OPENGL_FBO.
|
||||
* - Possibly transformations, such as MPV_RENDER_PARAM_FLIP_Y.
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
* @param params an array of parameters, terminated by type==0. Which parameters
|
||||
* are required depends on the backend. It's left unspecified what
|
||||
* happens with unknown parameters.
|
||||
* @return error code
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_render(mpv_render_context *ctx, mpv_render_param *params);
|
||||
|
||||
/**
|
||||
* Tell the renderer that a frame was flipped at the given time. This is
|
||||
* optional, but can help the player to achieve better timing.
|
||||
*
|
||||
* Note that calling this at least once informs libmpv that you will use this
|
||||
* function. If you use it inconsistently, expect bad video playback.
|
||||
*
|
||||
* If this is called while no video is initialized, it is ignored.
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
*/
|
||||
MPV_EXPORT void mpv_render_context_report_swap(mpv_render_context *ctx);
|
||||
|
||||
/**
|
||||
* Destroy the mpv renderer state.
|
||||
*
|
||||
* If video is still active (e.g. a file playing), video will be disabled
|
||||
* forcefully.
|
||||
*
|
||||
* @param ctx a valid render context. After this function returns, this is not
|
||||
* a valid pointer anymore. NULL is also allowed and does nothing.
|
||||
*/
|
||||
MPV_EXPORT void mpv_render_context_free(mpv_render_context *ctx);
|
||||
|
||||
#ifdef MPV_CPLUGIN_DYNAMIC_SYM
|
||||
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_create)
|
||||
#define mpv_render_context_create pfn_mpv_render_context_create
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_set_parameter)
|
||||
#define mpv_render_context_set_parameter pfn_mpv_render_context_set_parameter
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_get_info)
|
||||
#define mpv_render_context_get_info pfn_mpv_render_context_get_info
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_set_update_callback)
|
||||
#define mpv_render_context_set_update_callback pfn_mpv_render_context_set_update_callback
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_update)
|
||||
#define mpv_render_context_update pfn_mpv_render_context_update
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_render)
|
||||
#define mpv_render_context_render pfn_mpv_render_context_render
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_report_swap)
|
||||
#define mpv_render_context_report_swap pfn_mpv_render_context_report_swap
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_free)
|
||||
#define mpv_render_context_free pfn_mpv_render_context_free
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,211 @@
|
||||
/* Copyright (C) 2018 the mpv developers
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef MPV_CLIENT_API_RENDER_GL_H_
|
||||
#define MPV_CLIENT_API_RENDER_GL_H_
|
||||
|
||||
#include "render.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* OpenGL backend
|
||||
* --------------
|
||||
*
|
||||
* This header contains definitions for using OpenGL with the render.h API.
|
||||
*
|
||||
* OpenGL interop
|
||||
* --------------
|
||||
*
|
||||
* The OpenGL backend has some special rules, because OpenGL itself uses
|
||||
* implicit per-thread contexts, which causes additional API problems.
|
||||
*
|
||||
* This assumes the OpenGL context lives on a certain thread controlled by the
|
||||
* API user. All mpv_render_* APIs have to be assumed to implicitly use the
|
||||
* OpenGL context if you pass a mpv_render_context using the OpenGL backend,
|
||||
* unless specified otherwise.
|
||||
*
|
||||
* The OpenGL context is indirectly accessed through the OpenGL function
|
||||
* pointers returned by the get_proc_address callback in mpv_opengl_init_params.
|
||||
* Generally, mpv will not load the system OpenGL library when using this API.
|
||||
*
|
||||
* OpenGL state
|
||||
* ------------
|
||||
*
|
||||
* OpenGL has a large amount of implicit state. All the mpv functions mentioned
|
||||
* above expect that the OpenGL state is reasonably set to OpenGL standard
|
||||
* defaults. Likewise, mpv will attempt to leave the OpenGL context with
|
||||
* standard defaults. The following state is excluded from this:
|
||||
*
|
||||
* - the glViewport state
|
||||
* - the glScissor state (but GL_SCISSOR_TEST is in its default value)
|
||||
* - glBlendFuncSeparate() state (but GL_BLEND is in its default value)
|
||||
* - glClearColor() state
|
||||
* - mpv may overwrite the callback set with glDebugMessageCallback()
|
||||
* - mpv always disables GL_DITHER at init
|
||||
*
|
||||
* Messing with the state could be avoided by creating shared OpenGL contexts,
|
||||
* but this is avoided for the sake of compatibility and interoperability.
|
||||
*
|
||||
* On OpenGL 2.1, mpv will strictly call functions like glGenTextures() to
|
||||
* create OpenGL objects. You will have to do the same. This ensures that
|
||||
* objects created by mpv and the API users don't clash. Also, legacy state
|
||||
* must be either in its defaults, or not interfere with core state.
|
||||
*
|
||||
* API use
|
||||
* -------
|
||||
*
|
||||
* The mpv_render_* API is used. That API supports multiple backends, and this
|
||||
* section documents specifics for the OpenGL backend.
|
||||
*
|
||||
* Use mpv_render_context_create() with MPV_RENDER_PARAM_API_TYPE set to
|
||||
* MPV_RENDER_API_TYPE_OPENGL, and MPV_RENDER_PARAM_OPENGL_INIT_PARAMS provided.
|
||||
*
|
||||
* Call mpv_render_context_render() with MPV_RENDER_PARAM_OPENGL_FBO to render
|
||||
* the video frame to an FBO.
|
||||
*
|
||||
* Hardware decoding
|
||||
* -----------------
|
||||
*
|
||||
* Hardware decoding via this API is fully supported, but requires some
|
||||
* additional setup. (At least if direct hardware decoding modes are wanted,
|
||||
* instead of copying back surface data from GPU to CPU RAM.)
|
||||
*
|
||||
* There may be certain requirements on the OpenGL implementation:
|
||||
*
|
||||
* - Windows: ANGLE is required (although in theory GL/DX interop could be used)
|
||||
* - Intel/Linux: EGL is required, and also the native display resource needs
|
||||
* to be provided (e.g. MPV_RENDER_PARAM_X11_DISPLAY for X11 and
|
||||
* MPV_RENDER_PARAM_WL_DISPLAY for Wayland)
|
||||
* - nVidia/Linux: Both GLX and EGL should work (GLX is required if vdpau is
|
||||
* used, e.g. due to old drivers.)
|
||||
* - macOS: CGL is required (CGLGetCurrentContext() returning non-NULL)
|
||||
* - iOS: EAGL is required (EAGLContext.currentContext returning non-nil)
|
||||
*
|
||||
* Once these things are setup, hardware decoding can be enabled/disabled at
|
||||
* any time by setting the "hwdec" property.
|
||||
*/
|
||||
|
||||
/**
|
||||
* For initializing the mpv OpenGL state via MPV_RENDER_PARAM_OPENGL_INIT_PARAMS.
|
||||
*/
|
||||
typedef struct mpv_opengl_init_params {
|
||||
/**
|
||||
* This retrieves OpenGL function pointers, and will use them in subsequent
|
||||
* operation.
|
||||
* Usually, you can simply call the GL context APIs from this callback (e.g.
|
||||
* glXGetProcAddressARB or wglGetProcAddress), but some APIs do not always
|
||||
* return pointers for all standard functions (even if present); in this
|
||||
* case you have to compensate by looking up these functions yourself when
|
||||
* libmpv wants to resolve them through this callback.
|
||||
* libmpv will not normally attempt to resolve GL functions on its own, nor
|
||||
* does it link to GL libraries directly.
|
||||
*/
|
||||
void *(*get_proc_address)(void *ctx, const char *name);
|
||||
/**
|
||||
* Value passed as ctx parameter to get_proc_address().
|
||||
*/
|
||||
void *get_proc_address_ctx;
|
||||
} mpv_opengl_init_params;
|
||||
|
||||
/**
|
||||
* For MPV_RENDER_PARAM_OPENGL_FBO.
|
||||
*/
|
||||
typedef struct mpv_opengl_fbo {
|
||||
/**
|
||||
* Framebuffer object name. This must be either a valid FBO generated by
|
||||
* glGenFramebuffers() that is complete and color-renderable, or 0. If the
|
||||
* value is 0, this refers to the OpenGL default framebuffer.
|
||||
*/
|
||||
int fbo;
|
||||
/**
|
||||
* Valid dimensions. This must refer to the size of the framebuffer. This
|
||||
* must always be set.
|
||||
*/
|
||||
int w, h;
|
||||
/**
|
||||
* Underlying texture internal format (e.g. GL_RGBA8), or 0 if unknown. If
|
||||
* this is the default framebuffer, this can be an equivalent.
|
||||
*/
|
||||
int internal_format;
|
||||
} mpv_opengl_fbo;
|
||||
|
||||
/**
|
||||
* Deprecated. For MPV_RENDER_PARAM_DRM_DISPLAY.
|
||||
*/
|
||||
typedef struct mpv_opengl_drm_params {
|
||||
int fd;
|
||||
int crtc_id;
|
||||
int connector_id;
|
||||
struct _drmModeAtomicReq **atomic_request_ptr;
|
||||
int render_fd;
|
||||
} mpv_opengl_drm_params;
|
||||
|
||||
/**
|
||||
* For MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE.
|
||||
*/
|
||||
typedef struct mpv_opengl_drm_draw_surface_size {
|
||||
/**
|
||||
* size of the draw plane surface in pixels.
|
||||
*/
|
||||
int width, height;
|
||||
} mpv_opengl_drm_draw_surface_size;
|
||||
|
||||
/**
|
||||
* For MPV_RENDER_PARAM_DRM_DISPLAY_V2.
|
||||
*/
|
||||
typedef struct mpv_opengl_drm_params_v2 {
|
||||
/**
|
||||
* DRM fd (int). Set to -1 if invalid.
|
||||
*/
|
||||
int fd;
|
||||
|
||||
/**
|
||||
* Currently used crtc id
|
||||
*/
|
||||
int crtc_id;
|
||||
|
||||
/**
|
||||
* Currently used connector id
|
||||
*/
|
||||
int connector_id;
|
||||
|
||||
/**
|
||||
* Pointer to a drmModeAtomicReq pointer that is being used for the renderloop.
|
||||
* This pointer should hold a pointer to the atomic request pointer
|
||||
* The atomic request pointer is usually changed at every renderloop.
|
||||
*/
|
||||
struct _drmModeAtomicReq **atomic_request_ptr;
|
||||
|
||||
/**
|
||||
* DRM render node. Used for VAAPI interop.
|
||||
* Set to -1 if invalid.
|
||||
*/
|
||||
int render_fd;
|
||||
} mpv_opengl_drm_params_v2;
|
||||
|
||||
|
||||
/**
|
||||
* For backwards compatibility with the old naming of mpv_opengl_drm_draw_surface_size
|
||||
*/
|
||||
#define mpv_opengl_drm_osd_size mpv_opengl_drm_draw_surface_size
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,247 @@
|
||||
/* Copyright (C) 2017 the mpv developers
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef MPV_CLIENT_API_STREAM_CB_H_
|
||||
#define MPV_CLIENT_API_STREAM_CB_H_
|
||||
|
||||
#include "client.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Warning: this API is not stable yet.
|
||||
*
|
||||
* Overview
|
||||
* --------
|
||||
*
|
||||
* This API can be used to make mpv read from a stream with a custom
|
||||
* implementation. This interface is inspired by funopen on BSD and
|
||||
* fopencookie on linux. The stream is backed by user-defined callbacks
|
||||
* which can implement customized open, read, seek, size and close behaviors.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
*
|
||||
* Register your stream callbacks with the mpv_stream_cb_add_ro() function. You
|
||||
* have to provide a mpv_stream_cb_open_ro_fn callback to it (open_fn argument).
|
||||
*
|
||||
* Once registered, you can `loadfile myprotocol://myfile`. Your open_fn will be
|
||||
* invoked with the URI and you must fill out the provided mpv_stream_cb_info
|
||||
* struct. This includes your stream callbacks (like read_fn), and an opaque
|
||||
* cookie, which will be passed as the first argument to all the remaining
|
||||
* stream callbacks.
|
||||
*
|
||||
* Note that your custom callbacks must not invoke libmpv APIs as that would
|
||||
* cause a deadlock. (Unless you call a different mpv_handle than the one the
|
||||
* callback was registered for, and the mpv_handles refer to different mpv
|
||||
* instances.)
|
||||
*
|
||||
* Stream lifetime
|
||||
* ---------------
|
||||
*
|
||||
* A stream remains valid until its close callback has been called. It's up to
|
||||
* libmpv to call the close callback, and the libmpv user cannot close it
|
||||
* directly with the stream_cb API.
|
||||
*
|
||||
* For example, if you consider your custom stream to become suddenly invalid
|
||||
* (maybe because the underlying stream died), libmpv will continue using your
|
||||
* stream. All you can do is returning errors from each callback, until libmpv
|
||||
* gives up and closes it.
|
||||
*
|
||||
* Protocol registration and lifetime
|
||||
* ----------------------------------
|
||||
*
|
||||
* Protocols remain registered until the mpv instance is terminated. This means
|
||||
* in particular that it can outlive the mpv_handle that was used to register
|
||||
* it, but once mpv_terminate_destroy() is called, your registered callbacks
|
||||
* will not be called again.
|
||||
*
|
||||
* Protocol unregistration is finished after the mpv core has been destroyed
|
||||
* (e.g. after mpv_terminate_destroy() has returned).
|
||||
*
|
||||
* If you do not call mpv_terminate_destroy() yourself (e.g. plugin-style code),
|
||||
* you will have to deal with the registration or even streams outliving your
|
||||
* code. Here are some possible ways to do this:
|
||||
* - call mpv_terminate_destroy(), which destroys the core, and will make sure
|
||||
* all streams are closed once this function returns
|
||||
* - you refcount all resources your stream "cookies" reference, so that it
|
||||
* doesn't matter if streams live longer than expected
|
||||
* - create "cancellation" semantics: after your protocol has been unregistered,
|
||||
* notify all your streams that are still opened, and make them drop all
|
||||
* referenced resources - then return errors from the stream callbacks as
|
||||
* long as the stream is still opened
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read callback used to implement a custom stream. The semantics of the
|
||||
* callback match read(2) in blocking mode. Short reads are allowed (you can
|
||||
* return less bytes than requested, and libmpv will retry reading the rest
|
||||
* with another call). If no data can be immediately read, the callback must
|
||||
* block until there is new data. A return of 0 will be interpreted as final
|
||||
* EOF, although libmpv might retry the read, or seek to a different position.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
* @param buf buffer to read data into
|
||||
* @param size of the buffer
|
||||
* @return number of bytes read into the buffer
|
||||
* @return 0 on EOF
|
||||
* @return -1 on error
|
||||
*/
|
||||
typedef int64_t (*mpv_stream_cb_read_fn)(void *cookie, char *buf, uint64_t nbytes);
|
||||
|
||||
/**
|
||||
* Seek callback used to implement a custom stream.
|
||||
*
|
||||
* Note that mpv will issue a seek to position 0 immediately after opening. This
|
||||
* is used to test whether the stream is seekable (since seekability might
|
||||
* depend on the URI contents, not just the protocol). Return
|
||||
* MPV_ERROR_UNSUPPORTED if seeking is not implemented for this stream. This
|
||||
* seek also serves to establish the fact that streams start at position 0.
|
||||
*
|
||||
* This callback can be NULL, in which it behaves as if always returning
|
||||
* MPV_ERROR_UNSUPPORTED.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
* @param offset target absolute stream position
|
||||
* @return the resulting offset of the stream
|
||||
* MPV_ERROR_UNSUPPORTED or MPV_ERROR_GENERIC if the seek failed
|
||||
*/
|
||||
typedef int64_t (*mpv_stream_cb_seek_fn)(void *cookie, int64_t offset);
|
||||
|
||||
/**
|
||||
* Size callback used to implement a custom stream.
|
||||
*
|
||||
* Return MPV_ERROR_UNSUPPORTED if no size is known.
|
||||
*
|
||||
* This callback can be NULL, in which it behaves as if always returning
|
||||
* MPV_ERROR_UNSUPPORTED.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
* @return the total size in bytes of the stream
|
||||
*/
|
||||
typedef int64_t (*mpv_stream_cb_size_fn)(void *cookie);
|
||||
|
||||
/**
|
||||
* Close callback used to implement a custom stream.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
*/
|
||||
typedef void (*mpv_stream_cb_close_fn)(void *cookie);
|
||||
|
||||
/**
|
||||
* Cancel callback used to implement a custom stream.
|
||||
*
|
||||
* This callback is used to interrupt any current or future read and seek
|
||||
* operations. It will be called from a separate thread than the demux
|
||||
* thread, and should not block.
|
||||
*
|
||||
* This callback can be NULL.
|
||||
*
|
||||
* Available since API 1.106.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
*/
|
||||
typedef void (*mpv_stream_cb_cancel_fn)(void *cookie);
|
||||
|
||||
/**
|
||||
* See mpv_stream_cb_open_ro_fn callback.
|
||||
*/
|
||||
typedef struct mpv_stream_cb_info {
|
||||
/**
|
||||
* Opaque user-provided value, which will be passed to the other callbacks.
|
||||
* The close callback will be called to release the cookie. It is not
|
||||
* interpreted by mpv. It doesn't even need to be a valid pointer.
|
||||
*
|
||||
* The user sets this in the mpv_stream_cb_open_ro_fn callback.
|
||||
*/
|
||||
void *cookie;
|
||||
|
||||
/**
|
||||
* Callbacks set by the user in the mpv_stream_cb_open_ro_fn callback. Some
|
||||
* of them are optional, and can be left unset.
|
||||
*
|
||||
* The following callbacks are mandatory: read_fn, close_fn
|
||||
*/
|
||||
mpv_stream_cb_read_fn read_fn;
|
||||
mpv_stream_cb_seek_fn seek_fn;
|
||||
mpv_stream_cb_size_fn size_fn;
|
||||
mpv_stream_cb_close_fn close_fn;
|
||||
mpv_stream_cb_cancel_fn cancel_fn; /* since API 1.106 */
|
||||
} mpv_stream_cb_info;
|
||||
|
||||
/**
|
||||
* Open callback used to implement a custom read-only (ro) stream. The user
|
||||
* must set the callback fields in the passed info struct. The cookie field
|
||||
* also can be set to store state associated to the stream instance.
|
||||
*
|
||||
* Note that the info struct is valid only for the duration of this callback.
|
||||
* You can't change the callbacks or the pointer to the cookie at a later point.
|
||||
*
|
||||
* Each stream instance created by the open callback can have different
|
||||
* callbacks.
|
||||
*
|
||||
* The close_fn callback will terminate the stream instance. The pointers to
|
||||
* your callbacks and cookie will be discarded, and the callbacks will not be
|
||||
* called again.
|
||||
*
|
||||
* @param user_data opaque user data provided via mpv_stream_cb_add()
|
||||
* @param uri name of the stream to be opened (with protocol prefix)
|
||||
* @param info fields which the user should fill
|
||||
* @return 0 on success, MPV_ERROR_LOADING_FAILED if the URI cannot be opened.
|
||||
*/
|
||||
typedef int (*mpv_stream_cb_open_ro_fn)(void *user_data, char *uri,
|
||||
mpv_stream_cb_info *info);
|
||||
|
||||
/**
|
||||
* Add a custom stream protocol. This will register a protocol handler under
|
||||
* the given protocol prefix, and invoke the given callbacks if an URI with the
|
||||
* matching protocol prefix is opened.
|
||||
*
|
||||
* The "ro" is for read-only - only read-only streams can be registered with
|
||||
* this function.
|
||||
*
|
||||
* The callback remains registered until the mpv core is registered.
|
||||
*
|
||||
* If a custom stream with the same name is already registered, then the
|
||||
* MPV_ERROR_INVALID_PARAMETER error is returned.
|
||||
*
|
||||
* @param protocol protocol prefix, for example "foo" for "foo://" URIs
|
||||
* @param user_data opaque pointer passed into the mpv_stream_cb_open_fn
|
||||
* callback.
|
||||
* @return error code
|
||||
*/
|
||||
MPV_EXPORT int mpv_stream_cb_add_ro(mpv_handle *ctx, const char *protocol, void *user_data,
|
||||
mpv_stream_cb_open_ro_fn open_fn);
|
||||
|
||||
#ifdef MPV_CPLUGIN_DYNAMIC_SYM
|
||||
|
||||
MPV_DEFINE_SYM_PTR(mpv_stream_cb_add_ro)
|
||||
#define mpv_stream_cb_add_ro pfn_mpv_stream_cb_add_ro
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
#define UTIL_EXTERN
|
||||
#include "jni_utils.h"
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "utf8_convert.h"
|
||||
|
||||
jstring new_java_string(JNIEnv* env, const char* utf8) {
|
||||
if (!utf8) return NULL;
|
||||
const std::u16string u16 = plezy::utf8::ToUtf16(utf8);
|
||||
return env->NewString(reinterpret_cast<const jchar*>(u16.data()), static_cast<jsize>(u16.size()));
|
||||
}
|
||||
|
||||
std::string java_string_to_utf8(JNIEnv* env, jstring jstr) {
|
||||
if (!jstr) return std::string();
|
||||
const jsize len = env->GetStringLength(jstr);
|
||||
const jchar* chars = env->GetStringChars(jstr, NULL);
|
||||
if (!chars) return std::string();
|
||||
std::string out = plezy::utf8::FromUtf16(reinterpret_cast<const char16_t*>(chars), static_cast<size_t>(len));
|
||||
env->ReleaseStringChars(jstr, chars);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool acquire_jni_env(JavaVM* vm, JNIEnv** env) {
|
||||
int ret = vm->GetEnv((void**)env, JNI_VERSION_1_6);
|
||||
if (ret == JNI_EDETACHED)
|
||||
return vm->AttachCurrentThread(env, NULL) == 0;
|
||||
else
|
||||
return ret == JNI_OK;
|
||||
}
|
||||
|
||||
void init_methods_cache(JNIEnv* env) {
|
||||
static bool methods_initialized = false;
|
||||
if (methods_initialized) return;
|
||||
|
||||
// Plain assignments straight from env->FindClass, promoted to global refs
|
||||
// on the following line, keep every Get*MethodID owner traceable for
|
||||
// scripts/checks/check_shrinker_rules.py.
|
||||
java_Integer = env->FindClass("java/lang/Integer");
|
||||
java_Integer = reinterpret_cast<jclass>(env->NewGlobalRef(java_Integer));
|
||||
java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V");
|
||||
java_Double = env->FindClass("java/lang/Double");
|
||||
java_Double = reinterpret_cast<jclass>(env->NewGlobalRef(java_Double));
|
||||
java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V");
|
||||
java_Boolean = env->FindClass("java/lang/Boolean");
|
||||
java_Boolean = reinterpret_cast<jclass>(env->NewGlobalRef(java_Boolean));
|
||||
java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V");
|
||||
|
||||
mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");
|
||||
mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));
|
||||
mpv_MpvPlayer_onPropertyChanged_S =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_Sb =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Z)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_Sl =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;J)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_Sd =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;D)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SS =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(I)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(I)V");
|
||||
mpv_MpvPlayer_onLogMessage =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V");
|
||||
|
||||
methods_initialized = true;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#define jni_func_name(name) Java_com_edde746_plezy_libmpv_MpvPlayer_##name
|
||||
#define jni_func(return_type, name, ...) \
|
||||
JNIEXPORT return_type JNICALL jni_func_name(name)(JNIEnv * env, jobject obj, ##__VA_ARGS__)
|
||||
|
||||
bool acquire_jni_env(JavaVM* vm, JNIEnv** env);
|
||||
void init_methods_cache(JNIEnv* env);
|
||||
|
||||
// Standard-UTF-8 string crossings; see utf8_convert.h for why NewStringUTF /
|
||||
// GetStringUTFChars are wrong for mpv data. `utf8` may be NULL (-> NULL).
|
||||
jstring new_java_string(JNIEnv* env, const char* utf8);
|
||||
// `jstr` may be NULL (-> empty).
|
||||
std::string java_string_to_utf8(JNIEnv* env, jstring jstr);
|
||||
|
||||
#ifndef UTIL_EXTERN
|
||||
#define UTIL_EXTERN extern
|
||||
#endif
|
||||
|
||||
UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean;
|
||||
UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
|
||||
|
||||
UTIL_EXTERN jclass mpv_MpvPlayer;
|
||||
UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_S, mpv_MpvPlayer_onPropertyChanged_Sb,
|
||||
mpv_MpvPlayer_onPropertyChanged_Sl, mpv_MpvPlayer_onPropertyChanged_Sd, mpv_MpvPlayer_onPropertyChanged_SS,
|
||||
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage;
|
||||
@@ -0,0 +1,13 @@
|
||||
#include "log.h"
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
|
||||
void die(const char* msg) {
|
||||
ALOGE("%s", msg);
|
||||
JNIEnv* env = nullptr;
|
||||
if (g_vm && acquire_jni_env(g_vm, &env) && env) {
|
||||
jclass cls = env->FindClass("java/lang/RuntimeException");
|
||||
if (cls) env->ThrowNew(cls, msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define DEBUG 1
|
||||
|
||||
#define LOG_TAG "mpv"
|
||||
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#if DEBUG
|
||||
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
|
||||
#else
|
||||
#define ALOGV(...) (void)0
|
||||
#endif
|
||||
|
||||
void die(const char* msg);
|
||||
|
||||
#define CHECK_MPV_INIT() \
|
||||
do { \
|
||||
if (__builtin_expect(!g_mpv, 0)) { \
|
||||
die("libmpv is not initialized"); \
|
||||
return; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_MPV_INIT_RET(val) \
|
||||
do { \
|
||||
if (__builtin_expect(!g_mpv, 0)) { \
|
||||
die("libmpv is not initialized"); \
|
||||
return val; \
|
||||
} \
|
||||
} while (0)
|
||||
@@ -0,0 +1,157 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <clocale>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/jni.h>
|
||||
}
|
||||
|
||||
#include "event.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
#define ARRAYLEN(a) (sizeof(a) / sizeof(a[0]))
|
||||
|
||||
void render_cleanup(JNIEnv* env);
|
||||
|
||||
static void* destroy_mpv_thread(void* arg) {
|
||||
mpv_handle* handle = (mpv_handle*)arg;
|
||||
mpv_terminate_destroy(handle);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Fire-and-forget mpv_terminate_destroy on a detached thread.
|
||||
// Safe because the event thread has been joined and g_mpv cleared —
|
||||
// no other code references this handle.
|
||||
static void async_destroy(mpv_handle* handle) {
|
||||
pthread_t tid;
|
||||
if (pthread_create(&tid, NULL, destroy_mpv_thread, handle) == 0) {
|
||||
pthread_detach(tid);
|
||||
} else {
|
||||
// Fallback: destroy synchronously if thread creation fails
|
||||
mpv_terminate_destroy(handle);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeCreate, jobject appctx);
|
||||
jni_func(void, nativeInit);
|
||||
jni_func(void, nativeDestroy);
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray);
|
||||
};
|
||||
|
||||
JavaVM* g_vm;
|
||||
mpv_handle* g_mpv;
|
||||
std::atomic<bool> g_event_thread_request_exit(false);
|
||||
|
||||
static pthread_t event_thread_id;
|
||||
static std::mutex g_lifecycle_mutex;
|
||||
|
||||
static void prepare_environment(JNIEnv* env, jobject appctx) {
|
||||
setlocale(LC_NUMERIC, "C");
|
||||
|
||||
if (!env->GetJavaVM(&g_vm) && g_vm) av_jni_set_java_vm(g_vm, NULL);
|
||||
|
||||
jobject global_appctx = env->NewGlobalRef(appctx);
|
||||
if (global_appctx) av_jni_set_android_app_ctx(global_appctx, NULL);
|
||||
|
||||
init_methods_cache(env);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCreate, jobject appctx) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
mpv_handle* leaked_mpv = NULL;
|
||||
if (g_mpv) {
|
||||
ALOGE("destroying leaked mpv instance");
|
||||
leaked_mpv = g_mpv;
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(leaked_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
g_mpv = NULL;
|
||||
render_cleanup(env);
|
||||
}
|
||||
|
||||
g_mpv = mpv_create();
|
||||
if (!g_mpv) {
|
||||
die("context init failed");
|
||||
if (leaked_mpv) mpv_terminate_destroy(leaked_mpv);
|
||||
return;
|
||||
}
|
||||
|
||||
mpv_request_log_messages(g_mpv, "v");
|
||||
|
||||
// Async teardown of leaked handle — doesn't block caller
|
||||
if (leaked_mpv) async_destroy(leaked_mpv);
|
||||
}
|
||||
|
||||
jni_func(void, nativeInit) {
|
||||
if (!g_mpv) {
|
||||
die("mpv is not created");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mpv_initialize(g_mpv) < 0) {
|
||||
die("mpv init failed");
|
||||
return;
|
||||
}
|
||||
|
||||
g_event_thread_request_exit = false;
|
||||
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
|
||||
die("thread create failed");
|
||||
return;
|
||||
}
|
||||
pthread_setname_np(event_thread_id, "event_thread");
|
||||
}
|
||||
|
||||
jni_func(void, nativeDestroy) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
if (!g_mpv) {
|
||||
ALOGV("mpv destroy called but it's already destroyed");
|
||||
return;
|
||||
}
|
||||
mpv_handle* local_mpv = g_mpv;
|
||||
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(local_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
|
||||
g_mpv = NULL;
|
||||
render_cleanup(env);
|
||||
|
||||
// Async teardown — nativeDestroy returns immediately
|
||||
async_destroy(local_mpv);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
const char* arguments[128] = {0};
|
||||
int len = env->GetArrayLength(jarray);
|
||||
if (len >= (int)ARRAYLEN(arguments)) {
|
||||
die("too many command arguments");
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<std::string> storage;
|
||||
storage.reserve(len);
|
||||
for (int i = 0; i < len; ++i) {
|
||||
jstring jarg = (jstring)env->GetObjectArrayElement(jarray, i);
|
||||
storage.push_back(java_string_to_utf8(env, jarg));
|
||||
arguments[i] = storage.back().c_str();
|
||||
env->DeleteLocalRef(jarg);
|
||||
}
|
||||
|
||||
mpv_command(g_mpv, arguments);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jint, nativeSetOptionString, jstring option, jstring value);
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue);
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
|
||||
CHECK_MPV_INIT_RET(0);
|
||||
|
||||
const char* option = env->GetStringUTFChars(joption, NULL);
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
|
||||
int result = mpv_set_option_string(g_mpv, option, value.c_str());
|
||||
|
||||
env->ReleaseStringUTFChars(joption, option);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_get_property(JNIEnv* env, jstring jproperty, mpv_format format, void* output) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_get_property(g_mpv, prop, format, output);
|
||||
if (result < 0) ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format, void* value) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_set_property(g_mpv, prop, format, value);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
|
||||
int64_t value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
|
||||
double value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
|
||||
int value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
|
||||
}
|
||||
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
|
||||
char* value;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
jstring jvalue = new_java_string(env, value);
|
||||
mpv_free(value);
|
||||
return jvalue;
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyInt, jstring jproperty, jint jvalue) {
|
||||
int64_t value = static_cast<int64_t>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
|
||||
double value = static_cast<double>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
|
||||
int value = jvalue == JNI_TRUE ? 1 : 0;
|
||||
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue) {
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
const char* value_ptr = value.c_str();
|
||||
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
}
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format) {
|
||||
CHECK_MPV_INIT();
|
||||
const char* prop = env->GetStringUTFChars(property, NULL);
|
||||
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
|
||||
if (result < 0) ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(property, prop);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeAttachSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachSurface);
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachOsdSurface);
|
||||
};
|
||||
|
||||
static jobject surface;
|
||||
|
||||
jni_func(void, nativeAttachSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
surface = env->NewGlobalRef(surface_);
|
||||
if (!surface) {
|
||||
die("invalid surface provided");
|
||||
return;
|
||||
}
|
||||
int64_t wid = reinterpret_cast<intptr_t>(surface);
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, nativeDetachSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(surface);
|
||||
surface = NULL;
|
||||
}
|
||||
|
||||
static jobject osd_surface;
|
||||
|
||||
// The OSD plane of vo=mediacodec. Same lifetime rules as the video surface:
|
||||
// the global ref must outlive the VO, so detach only after vo has been unset.
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
osd_surface = env->NewGlobalRef(surface_);
|
||||
if (!osd_surface) {
|
||||
die("invalid osd surface provided");
|
||||
return;
|
||||
}
|
||||
int64_t wid = reinterpret_cast<intptr_t>(osd_surface);
|
||||
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, nativeDetachOsdSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
if (!osd_surface) return;
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = NULL;
|
||||
}
|
||||
|
||||
void render_cleanup(JNIEnv* env) {
|
||||
if (surface) {
|
||||
env->DeleteGlobalRef(surface);
|
||||
surface = NULL;
|
||||
}
|
||||
if (osd_surface) {
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// UTF-8 <-> UTF-16 transcoding for the JNI boundary.
|
||||
//
|
||||
// mpv speaks standard UTF-8. JNI's NewStringUTF/GetStringUTFChars speak
|
||||
// *modified* UTF-8: supplementary-plane characters are CESU-8 surrogate pairs
|
||||
// and NUL is 0xC0 0x80. Feeding one encoding to the other corrupts emoji in
|
||||
// file names and titles, and malformed bytes from mpv (log lines, ID3 tags,
|
||||
// system-encoded paths) abort under CheckJNI. Both directions therefore go
|
||||
// through UTF-16 with NewString/GetStringChars; malformed input is replaced
|
||||
// with U+FFFD one unit at a time, matching shared/cpp/sanitize_utf8.h on
|
||||
// desktop. Header-only and JNI-free so the host test harness can exercise it.
|
||||
|
||||
namespace plezy {
|
||||
namespace utf8 {
|
||||
|
||||
// Decodes one scalar value at `s`. Returns the number of bytes consumed, or 0
|
||||
// when `s` does not start a well-formed sequence (Unicode Table 3-7).
|
||||
inline size_t DecodeOne(const unsigned char* s, size_t len, uint32_t* cp) {
|
||||
const unsigned char c = s[0];
|
||||
if (c < 0x80) {
|
||||
*cp = c;
|
||||
return 1;
|
||||
}
|
||||
size_t need;
|
||||
unsigned char lo = 0x80, hi = 0xBF;
|
||||
if (c >= 0xC2 && c <= 0xDF) {
|
||||
need = 2;
|
||||
*cp = c & 0x1F;
|
||||
} else if (c >= 0xE0 && c <= 0xEF) {
|
||||
need = 3;
|
||||
*cp = c & 0x0F;
|
||||
if (c == 0xE0) lo = 0xA0;
|
||||
if (c == 0xED) hi = 0x9F; // no surrogates
|
||||
} else if (c >= 0xF0 && c <= 0xF4) {
|
||||
need = 4;
|
||||
*cp = c & 0x07;
|
||||
if (c == 0xF0) lo = 0x90;
|
||||
if (c == 0xF4) hi = 0x8F; // <= U+10FFFF
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
if (len < need) return 0;
|
||||
for (size_t i = 1; i < need; ++i) {
|
||||
const unsigned char b = s[i];
|
||||
if (b < lo || b > hi) return 0;
|
||||
lo = 0x80;
|
||||
hi = 0xBF;
|
||||
*cp = (*cp << 6) | (b & 0x3F);
|
||||
}
|
||||
return need;
|
||||
}
|
||||
|
||||
// Standard UTF-8 -> UTF-16. Malformed bytes become U+FFFD.
|
||||
inline std::u16string ToUtf16(const char* input, size_t len) {
|
||||
std::u16string out;
|
||||
if (!input) return out;
|
||||
out.reserve(len);
|
||||
const unsigned char* s = reinterpret_cast<const unsigned char*>(input);
|
||||
size_t pos = 0;
|
||||
while (pos < len) {
|
||||
uint32_t cp;
|
||||
const size_t n = DecodeOne(s + pos, len - pos, &cp);
|
||||
if (n == 0) {
|
||||
out.push_back(u'\uFFFD');
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
pos += n;
|
||||
if (cp < 0x10000) {
|
||||
out.push_back(static_cast<char16_t>(cp));
|
||||
} else {
|
||||
cp -= 0x10000;
|
||||
out.push_back(static_cast<char16_t>(0xD800 | (cp >> 10)));
|
||||
out.push_back(static_cast<char16_t>(0xDC00 | (cp & 0x3FF)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline std::u16string ToUtf16(const char* input) {
|
||||
return input ? ToUtf16(input, std::char_traits<char>::length(input)) : std::u16string();
|
||||
}
|
||||
|
||||
// UTF-16 -> standard UTF-8. Lone surrogates become U+FFFD.
|
||||
inline std::string FromUtf16(const char16_t* input, size_t len) {
|
||||
std::string out;
|
||||
if (!input) return out;
|
||||
out.reserve(len * 3);
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
uint32_t cp = input[i];
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||
if (i + 1 < len && input[i + 1] >= 0xDC00 && input[i + 1] <= 0xDFFF) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (input[i + 1] - 0xDC00);
|
||||
++i;
|
||||
} else {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
if (cp < 0x80) {
|
||||
out.push_back(static_cast<char>(cp));
|
||||
} else if (cp < 0x800) {
|
||||
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
|
||||
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
|
||||
} else if (cp < 0x10000) {
|
||||
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
|
||||
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
|
||||
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
|
||||
} else {
|
||||
out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
|
||||
out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
|
||||
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
|
||||
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace utf8
|
||||
} // namespace plezy
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
enum class EndFileReason(val id: Int) {
|
||||
Eof(0),
|
||||
Stop(2),
|
||||
Quit(3),
|
||||
Error(4),
|
||||
Redirect(5);
|
||||
|
||||
companion object {
|
||||
fun fromId(id: Int): EndFileReason? = entries.find { it.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
enum class LogLevel(internal val nativeValue: Int) {
|
||||
Fatal(10),
|
||||
Error(20),
|
||||
Warn(30),
|
||||
Info(40),
|
||||
Verbose(50),
|
||||
Debug(60),
|
||||
Trace(70);
|
||||
|
||||
companion object {
|
||||
internal fun fromNative(value: Int): LogLevel? = entries.find { it.nativeValue == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
data class LogMessage(
|
||||
val prefix: String,
|
||||
val level: LogLevel,
|
||||
val text: String
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
sealed interface MpvEvent {
|
||||
data object StartFile : MpvEvent
|
||||
data class EndFile(val reason: EndFileReason?) : MpvEvent
|
||||
data object FileLoaded : MpvEvent
|
||||
data object PlaybackRestart : MpvEvent
|
||||
|
||||
companion object {
|
||||
// Mirrors the ids event.cpp forwards; END_FILE arrives via its own JNI path.
|
||||
internal fun fromId(id: Int): MpvEvent? = when (id) {
|
||||
6 -> StartFile
|
||||
8 -> FileLoaded
|
||||
21 -> PlaybackRestart
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvException(message: String) : RuntimeException(message)
|
||||
@@ -0,0 +1,309 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Surface
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("mpv")
|
||||
System.loadLibrary("player")
|
||||
}
|
||||
|
||||
private val instance = AtomicReference<MpvPlayer?>(null)
|
||||
|
||||
suspend fun create(
|
||||
context: Context,
|
||||
configure: MpvPlayerConfig.() -> Unit = {}
|
||||
): MpvPlayer = withContext(Dispatchers.IO) {
|
||||
val player = MpvPlayer()
|
||||
// Atomically replace; mark old as closed so its background close() skips nativeDestroy
|
||||
instance.getAndSet(player)?.also { it.closed = true }
|
||||
// nativeCreate's safety net handles any leaked native session
|
||||
try {
|
||||
nativeCreate(context.applicationContext)
|
||||
MpvPlayerConfig().apply(configure)
|
||||
nativeInit()
|
||||
ensureActive()
|
||||
player
|
||||
} catch (e: Throwable) {
|
||||
instance.compareAndSet(player, null)
|
||||
try {
|
||||
nativeDestroy()
|
||||
} catch (_: Throwable) {}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// JNI callbacks — called from native event thread
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.None(name))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Flag(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Long) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Int64(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Double) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Double(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: String) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Str(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEvent(eventId: Int) {
|
||||
val event = MpvEvent.fromId(eventId) ?: return
|
||||
instance.get()?.rawEvents?.trySend(event)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEndFile(reason: Int) {
|
||||
instance.get()?.rawEvents?.trySend(
|
||||
MpvEvent.EndFile(EndFileReason.fromId(reason))
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onLogMessage(prefix: String, level: Int, text: String) {
|
||||
val logLevel = LogLevel.fromNative(level) ?: return
|
||||
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
}
|
||||
|
||||
// JNI native declarations — private to avoid internal name mangling
|
||||
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context)
|
||||
|
||||
@JvmStatic private external fun nativeInit()
|
||||
|
||||
@JvmStatic private external fun nativeDestroy()
|
||||
|
||||
@JvmStatic private external fun nativeCommand(cmd: Array<out String>)
|
||||
|
||||
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
|
||||
|
||||
@JvmStatic private external fun nativeDetachSurface()
|
||||
|
||||
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
|
||||
|
||||
@JvmStatic private external fun nativeDetachOsdSurface()
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
|
||||
|
||||
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
|
||||
|
||||
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
|
||||
}
|
||||
|
||||
// The native event thread hands everything to unbounded channels: trySend
|
||||
// on them cannot fail (until close) and cannot block mpv's event loop. A
|
||||
// pump per stream re-emits into the SharedFlow, whose SUSPEND overflow
|
||||
// parks the pump - not the native thread - while a collector catches up.
|
||||
// The previous design tryEmit-ed straight into the 64-slot SharedFlow
|
||||
// buffer, which silently dropped whatever arrived during a burst; losing
|
||||
// e.g. the one cplayer log line that signals a failed video chain.
|
||||
private val rawEvents = Channel<MpvEvent>(Channel.UNLIMITED)
|
||||
private val rawPropertyChanges = Channel<PropertyChange>(Channel.UNLIMITED)
|
||||
private val rawLogMessages = Channel<LogMessage>(Channel.UNLIMITED)
|
||||
|
||||
private val events = MutableSharedFlow<MpvEvent>(extraBufferCapacity = 64)
|
||||
private val propertyChanges = MutableSharedFlow<PropertyChange>(extraBufferCapacity = 64)
|
||||
private val logMessages = MutableSharedFlow<LogMessage>(extraBufferCapacity = 64)
|
||||
|
||||
private val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
init {
|
||||
pumpScope.launch { for (e in rawEvents) events.emit(e) }
|
||||
pumpScope.launch { for (c in rawPropertyChanges) propertyChanges.emit(c) }
|
||||
pumpScope.launch { for (m in rawLogMessages) logMessages.emit(m) }
|
||||
}
|
||||
|
||||
val eventFlow: SharedFlow<MpvEvent> = events.asSharedFlow()
|
||||
val propertyFlow: SharedFlow<PropertyChange> = propertyChanges.asSharedFlow()
|
||||
val logFlow: SharedFlow<LogMessage> = logMessages.asSharedFlow()
|
||||
|
||||
// Commands
|
||||
|
||||
suspend fun command(vararg args: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeCommand(args) }
|
||||
}
|
||||
|
||||
// Surface — not suspend, called from SurfaceHolder.Callback
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachSurface(surface)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachSurface()
|
||||
}
|
||||
|
||||
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
|
||||
fun attachOsdSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachOsdSurface(surface)
|
||||
}
|
||||
|
||||
fun detachOsdSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachOsdSurface()
|
||||
}
|
||||
|
||||
// Property getters
|
||||
|
||||
suspend fun getInt(name: String): Int? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
|
||||
}
|
||||
|
||||
suspend fun getDouble(name: String): Double? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
|
||||
}
|
||||
|
||||
suspend fun getFlag(name: String): Boolean? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
|
||||
}
|
||||
|
||||
suspend fun getString(name: String): String? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
|
||||
}
|
||||
|
||||
// Property setters
|
||||
|
||||
suspend fun setProperty(name: String, value: Int) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Double) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Boolean) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
|
||||
}
|
||||
|
||||
// Property observation
|
||||
|
||||
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, format.nativeValue)
|
||||
return propertyFlow.filter { it.name == name }
|
||||
}
|
||||
|
||||
fun observeFlag(name: String): Flow<Boolean> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Flag>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeInt(name: String): Flow<Long> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Int64>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeDouble(name: String): Flow<Double> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Double>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeString(name: String): Flow<String> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Str>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
// Only destroy native if we're still the active player.
|
||||
// If create() already replaced us, nativeCreate's safety net handles native cleanup.
|
||||
if (instance.compareAndSet(this, null)) {
|
||||
nativeDestroy()
|
||||
}
|
||||
// After nativeDestroy no callback can produce: closing the channels
|
||||
// lets each pump drain what is already queued and then complete.
|
||||
rawEvents.close()
|
||||
rawPropertyChanges.close()
|
||||
rawLogMessages.close()
|
||||
}
|
||||
|
||||
private fun checkNotClosed() {
|
||||
check(!closed) { "MpvPlayer has been closed" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvPlayerConfig internal constructor() {
|
||||
fun setOption(name: String, value: String) {
|
||||
val result = MpvPlayer.setOptionString(name, value)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set option '$name' to '$value': error $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
sealed interface PropertyChange {
|
||||
val name: String
|
||||
|
||||
data class None(override val name: String) : PropertyChange
|
||||
data class Flag(override val name: String, val value: Boolean) : PropertyChange
|
||||
data class Int64(override val name: String, val value: Long) : PropertyChange
|
||||
data class Double(override val name: String, val value: kotlin.Double) : PropertyChange
|
||||
data class Str(override val name: String, val value: String) : PropertyChange
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
enum class PropertyFormat(internal val nativeValue: Int) {
|
||||
None(0),
|
||||
String(1),
|
||||
Flag(3),
|
||||
Int64(4),
|
||||
Double(5)
|
||||
}
|
||||
@@ -25,3 +25,4 @@ plugins {
|
||||
|
||||
include(":app")
|
||||
include(":libass")
|
||||
include(":libmpv")
|
||||
|
||||
+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;
|
||||
@@ -280,7 +283,7 @@
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -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;
|
||||
@@ -793,12 +797,12 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */ = {
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/edde746/MPVKit";
|
||||
repositoryURL = "https://github.com/edde746/mpv-build";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 1.0.23;
|
||||
kind = revision;
|
||||
revision = dafa7762af20052031a7b512c9761a5d8bde327d;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -806,7 +810,7 @@
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
6A8A46202EDB320D0057B88C /* MPVKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */;
|
||||
productName = MPVKit;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
|
||||
@@ -28,12 +28,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"identity" : "mpv-build",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "ee423a7e8727eeee4748f08cb7d3c00e935ed45e",
|
||||
"version" : "1.0.23"
|
||||
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,12 +28,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"identity" : "mpv-build",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "ee423a7e8727eeee4748f08cb7d3c00e935ed45e",
|
||||
"version" : "1.0.23"
|
||||
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import 'dart:convert';
|
||||
|
||||
import '../models/plex/plex_home.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../profiles/plex_home_cache_codec.dart';
|
||||
import '../profiles/profile_registry.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/server_registry.dart';
|
||||
@@ -20,24 +20,23 @@ import 'plex_account_setup.dart';
|
||||
///
|
||||
/// Plex Home users are NOT persisted here — the bootstrap copies the
|
||||
/// legacy `homeUsersCache` into the per-connection
|
||||
/// `plex_home_users_{connectionId}` SharedPreferences slot so
|
||||
/// [PlexHomeService] picks it up on cold start.
|
||||
/// `plex_home_users_{connectionId}` SharedPreferences slot and asks
|
||||
/// [PlexHomeService] to reload (or fetch) it.
|
||||
class ConnectionBootstrap {
|
||||
ConnectionBootstrap({
|
||||
required this.storage,
|
||||
required this.connectionRegistry,
|
||||
required this.serverRegistry,
|
||||
required this.profileRegistry,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
required this.plexHome,
|
||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
|
||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
}) : _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
|
||||
final StorageService storage;
|
||||
final ConnectionRegistry connectionRegistry;
|
||||
final ServerRegistry serverRegistry;
|
||||
final ProfileRegistry profileRegistry;
|
||||
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
|
||||
final PlexHomeService plexHome;
|
||||
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
|
||||
|
||||
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
|
||||
@@ -63,6 +62,9 @@ class ConnectionBootstrap {
|
||||
final prepared = await _preparePlexVirtualProfile(account);
|
||||
if (!prepared) {
|
||||
if (migratedAccount != null && hadLegacyPlexToken) {
|
||||
// A failed/empty fetch may have left an empty cache slot on disk
|
||||
// for the id we are about to drop; don't leave it orphaned.
|
||||
await storage.clearPlexHomeUsersCache(migratedAccount.id);
|
||||
await connectionRegistry.remove(migratedAccount.id);
|
||||
}
|
||||
appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later');
|
||||
@@ -147,9 +149,12 @@ class ConnectionBootstrap {
|
||||
/// profile. Plex users are never persisted as local Plezy profiles.
|
||||
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
|
||||
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
|
||||
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
|
||||
users ??= await _fetchAndCachePlexHomeUsers(account);
|
||||
final hydratedUsers = users;
|
||||
if (copied) {
|
||||
await plexHome.reloadFromStorage();
|
||||
} else {
|
||||
await plexHome.refresh(account);
|
||||
}
|
||||
final hydratedUsers = plexHome.current[account.id] ?? const [];
|
||||
if (hydratedUsers.isEmpty) return false;
|
||||
|
||||
final legacyActiveUuid = storage.getCurrentUserUUID();
|
||||
@@ -196,31 +201,6 @@ class ConnectionBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
|
||||
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return decodePlexHomeUsersCache(raw);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
|
||||
try {
|
||||
final users = await _plexHomeUserFetcher(account.accountToken);
|
||||
if (users.isNotEmpty) {
|
||||
await storage.savePlexHomeUsersCache(account.id, encodePlexHomeUsersCache(users));
|
||||
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
|
||||
}
|
||||
return users;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
|
||||
if (storage.prefs.getString('home_users_cache') == null) return;
|
||||
var target = account;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
@@ -16,6 +17,19 @@ 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.
|
||||
@@ -121,6 +135,7 @@ class ConnectionRegistry {
|
||||
dialect: kind.dialect!,
|
||||
),
|
||||
};
|
||||
_decryptedConfigs[connection] = revealed.config;
|
||||
if (revealed.migrated) {
|
||||
await upsert(connection);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ final class AppDatabaseBootstrap {
|
||||
Connections,
|
||||
Profiles,
|
||||
ProfileConnections,
|
||||
MusicSessions,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -365,7 +366,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
|
||||
|
||||
@override
|
||||
int get schemaVersion => 21;
|
||||
int get schemaVersion => 22;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -735,6 +736,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
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));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1094,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) {
|
||||
|
||||
@@ -5769,6 +5769,647 @@ class ProfileConnectionsCompanion
|
||||
}
|
||||
}
|
||||
|
||||
class $MusicSessionsTable extends MusicSessions
|
||||
with TableInfo<$MusicSessionsTable, MusicSessionRow> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$MusicSessionsTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _profileIdMeta = const VerificationMeta(
|
||||
'profileId',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> profileId = GeneratedColumn<String>(
|
||||
'profile_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _queueJsonMeta = const VerificationMeta(
|
||||
'queueJson',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> queueJson = GeneratedColumn<String>(
|
||||
'queue_json',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _orderJsonMeta = const VerificationMeta(
|
||||
'orderJson',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> orderJson = GeneratedColumn<String>(
|
||||
'order_json',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _cursorMeta = const VerificationMeta('cursor');
|
||||
@override
|
||||
late final GeneratedColumn<int> cursor = GeneratedColumn<int>(
|
||||
'cursor',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _shuffledMeta = const VerificationMeta(
|
||||
'shuffled',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> shuffled = GeneratedColumn<bool>(
|
||||
'shuffled',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("shuffled" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _repeatModeMeta = const VerificationMeta(
|
||||
'repeatMode',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> repeatMode = GeneratedColumn<String>(
|
||||
'repeat_mode',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant('off'),
|
||||
);
|
||||
static const VerificationMeta _contextTitleMeta = const VerificationMeta(
|
||||
'contextTitle',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> contextTitle = GeneratedColumn<String>(
|
||||
'context_title',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _contextKindMeta = const VerificationMeta(
|
||||
'contextKind',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> contextKind = GeneratedColumn<String>(
|
||||
'context_kind',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _positionMsMeta = const VerificationMeta(
|
||||
'positionMs',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> positionMs = GeneratedColumn<int>(
|
||||
'position_ms',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant(0),
|
||||
);
|
||||
static const VerificationMeta _updatedAtMeta = const VerificationMeta(
|
||||
'updatedAt',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> updatedAt = GeneratedColumn<int>(
|
||||
'updated_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
profileId,
|
||||
queueJson,
|
||||
orderJson,
|
||||
cursor,
|
||||
shuffled,
|
||||
repeatMode,
|
||||
contextTitle,
|
||||
contextKind,
|
||||
positionMs,
|
||||
updatedAt,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'music_sessions';
|
||||
@override
|
||||
VerificationContext validateIntegrity(
|
||||
Insertable<MusicSessionRow> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('profile_id')) {
|
||||
context.handle(
|
||||
_profileIdMeta,
|
||||
profileId.isAcceptableOrUnknown(data['profile_id']!, _profileIdMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_profileIdMeta);
|
||||
}
|
||||
if (data.containsKey('queue_json')) {
|
||||
context.handle(
|
||||
_queueJsonMeta,
|
||||
queueJson.isAcceptableOrUnknown(data['queue_json']!, _queueJsonMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_queueJsonMeta);
|
||||
}
|
||||
if (data.containsKey('order_json')) {
|
||||
context.handle(
|
||||
_orderJsonMeta,
|
||||
orderJson.isAcceptableOrUnknown(data['order_json']!, _orderJsonMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_orderJsonMeta);
|
||||
}
|
||||
if (data.containsKey('cursor')) {
|
||||
context.handle(
|
||||
_cursorMeta,
|
||||
cursor.isAcceptableOrUnknown(data['cursor']!, _cursorMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_cursorMeta);
|
||||
}
|
||||
if (data.containsKey('shuffled')) {
|
||||
context.handle(
|
||||
_shuffledMeta,
|
||||
shuffled.isAcceptableOrUnknown(data['shuffled']!, _shuffledMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('repeat_mode')) {
|
||||
context.handle(
|
||||
_repeatModeMeta,
|
||||
repeatMode.isAcceptableOrUnknown(data['repeat_mode']!, _repeatModeMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('context_title')) {
|
||||
context.handle(
|
||||
_contextTitleMeta,
|
||||
contextTitle.isAcceptableOrUnknown(
|
||||
data['context_title']!,
|
||||
_contextTitleMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('context_kind')) {
|
||||
context.handle(
|
||||
_contextKindMeta,
|
||||
contextKind.isAcceptableOrUnknown(
|
||||
data['context_kind']!,
|
||||
_contextKindMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('position_ms')) {
|
||||
context.handle(
|
||||
_positionMsMeta,
|
||||
positionMs.isAcceptableOrUnknown(data['position_ms']!, _positionMsMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
_updatedAtMeta,
|
||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_updatedAtMeta);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {profileId};
|
||||
@override
|
||||
MusicSessionRow map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return MusicSessionRow(
|
||||
profileId: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}profile_id'],
|
||||
)!,
|
||||
queueJson: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}queue_json'],
|
||||
)!,
|
||||
orderJson: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}order_json'],
|
||||
)!,
|
||||
cursor: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}cursor'],
|
||||
)!,
|
||||
shuffled: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}shuffled'],
|
||||
)!,
|
||||
repeatMode: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}repeat_mode'],
|
||||
)!,
|
||||
contextTitle: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}context_title'],
|
||||
),
|
||||
contextKind: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}context_kind'],
|
||||
),
|
||||
positionMs: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}position_ms'],
|
||||
)!,
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$MusicSessionsTable createAlias(String alias) {
|
||||
return $MusicSessionsTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class MusicSessionRow extends DataClass implements Insertable<MusicSessionRow> {
|
||||
/// Active Plezy profile that owns this session snapshot.
|
||||
final String profileId;
|
||||
|
||||
/// Canonical queue tracks (insertion order) as a JSON array of MediaItem
|
||||
/// JSON — self-contained so restore never depends on volatile cache rows.
|
||||
final String queueJson;
|
||||
|
||||
/// Playback-order permutation into [queueJson] as a JSON int array
|
||||
/// (identity while unshuffled).
|
||||
final String orderJson;
|
||||
|
||||
/// Position of the current track within the playback order.
|
||||
final int cursor;
|
||||
final bool shuffled;
|
||||
|
||||
/// Stable repeat-mode id ('off' | 'all' | 'one') — not the enum `.name`.
|
||||
final String repeatMode;
|
||||
|
||||
/// Play-context provenance ("Playing from …"); kind is a stable id.
|
||||
final String? contextTitle;
|
||||
final String? contextKind;
|
||||
|
||||
/// Playhead within the current track in milliseconds.
|
||||
final int positionMs;
|
||||
|
||||
/// Timestamp of the last write (milliseconds since epoch).
|
||||
final int updatedAt;
|
||||
const MusicSessionRow({
|
||||
required this.profileId,
|
||||
required this.queueJson,
|
||||
required this.orderJson,
|
||||
required this.cursor,
|
||||
required this.shuffled,
|
||||
required this.repeatMode,
|
||||
this.contextTitle,
|
||||
this.contextKind,
|
||||
required this.positionMs,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['profile_id'] = Variable<String>(profileId);
|
||||
map['queue_json'] = Variable<String>(queueJson);
|
||||
map['order_json'] = Variable<String>(orderJson);
|
||||
map['cursor'] = Variable<int>(cursor);
|
||||
map['shuffled'] = Variable<bool>(shuffled);
|
||||
map['repeat_mode'] = Variable<String>(repeatMode);
|
||||
if (!nullToAbsent || contextTitle != null) {
|
||||
map['context_title'] = Variable<String>(contextTitle);
|
||||
}
|
||||
if (!nullToAbsent || contextKind != null) {
|
||||
map['context_kind'] = Variable<String>(contextKind);
|
||||
}
|
||||
map['position_ms'] = Variable<int>(positionMs);
|
||||
map['updated_at'] = Variable<int>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
MusicSessionsCompanion toCompanion(bool nullToAbsent) {
|
||||
return MusicSessionsCompanion(
|
||||
profileId: Value(profileId),
|
||||
queueJson: Value(queueJson),
|
||||
orderJson: Value(orderJson),
|
||||
cursor: Value(cursor),
|
||||
shuffled: Value(shuffled),
|
||||
repeatMode: Value(repeatMode),
|
||||
contextTitle: contextTitle == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(contextTitle),
|
||||
contextKind: contextKind == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(contextKind),
|
||||
positionMs: Value(positionMs),
|
||||
updatedAt: Value(updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
factory MusicSessionRow.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return MusicSessionRow(
|
||||
profileId: serializer.fromJson<String>(json['profileId']),
|
||||
queueJson: serializer.fromJson<String>(json['queueJson']),
|
||||
orderJson: serializer.fromJson<String>(json['orderJson']),
|
||||
cursor: serializer.fromJson<int>(json['cursor']),
|
||||
shuffled: serializer.fromJson<bool>(json['shuffled']),
|
||||
repeatMode: serializer.fromJson<String>(json['repeatMode']),
|
||||
contextTitle: serializer.fromJson<String?>(json['contextTitle']),
|
||||
contextKind: serializer.fromJson<String?>(json['contextKind']),
|
||||
positionMs: serializer.fromJson<int>(json['positionMs']),
|
||||
updatedAt: serializer.fromJson<int>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'profileId': serializer.toJson<String>(profileId),
|
||||
'queueJson': serializer.toJson<String>(queueJson),
|
||||
'orderJson': serializer.toJson<String>(orderJson),
|
||||
'cursor': serializer.toJson<int>(cursor),
|
||||
'shuffled': serializer.toJson<bool>(shuffled),
|
||||
'repeatMode': serializer.toJson<String>(repeatMode),
|
||||
'contextTitle': serializer.toJson<String?>(contextTitle),
|
||||
'contextKind': serializer.toJson<String?>(contextKind),
|
||||
'positionMs': serializer.toJson<int>(positionMs),
|
||||
'updatedAt': serializer.toJson<int>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
MusicSessionRow copyWith({
|
||||
String? profileId,
|
||||
String? queueJson,
|
||||
String? orderJson,
|
||||
int? cursor,
|
||||
bool? shuffled,
|
||||
String? repeatMode,
|
||||
Value<String?> contextTitle = const Value.absent(),
|
||||
Value<String?> contextKind = const Value.absent(),
|
||||
int? positionMs,
|
||||
int? updatedAt,
|
||||
}) => MusicSessionRow(
|
||||
profileId: profileId ?? this.profileId,
|
||||
queueJson: queueJson ?? this.queueJson,
|
||||
orderJson: orderJson ?? this.orderJson,
|
||||
cursor: cursor ?? this.cursor,
|
||||
shuffled: shuffled ?? this.shuffled,
|
||||
repeatMode: repeatMode ?? this.repeatMode,
|
||||
contextTitle: contextTitle.present ? contextTitle.value : this.contextTitle,
|
||||
contextKind: contextKind.present ? contextKind.value : this.contextKind,
|
||||
positionMs: positionMs ?? this.positionMs,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
MusicSessionRow copyWithCompanion(MusicSessionsCompanion data) {
|
||||
return MusicSessionRow(
|
||||
profileId: data.profileId.present ? data.profileId.value : this.profileId,
|
||||
queueJson: data.queueJson.present ? data.queueJson.value : this.queueJson,
|
||||
orderJson: data.orderJson.present ? data.orderJson.value : this.orderJson,
|
||||
cursor: data.cursor.present ? data.cursor.value : this.cursor,
|
||||
shuffled: data.shuffled.present ? data.shuffled.value : this.shuffled,
|
||||
repeatMode: data.repeatMode.present
|
||||
? data.repeatMode.value
|
||||
: this.repeatMode,
|
||||
contextTitle: data.contextTitle.present
|
||||
? data.contextTitle.value
|
||||
: this.contextTitle,
|
||||
contextKind: data.contextKind.present
|
||||
? data.contextKind.value
|
||||
: this.contextKind,
|
||||
positionMs: data.positionMs.present
|
||||
? data.positionMs.value
|
||||
: this.positionMs,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MusicSessionRow(')
|
||||
..write('profileId: $profileId, ')
|
||||
..write('queueJson: $queueJson, ')
|
||||
..write('orderJson: $orderJson, ')
|
||||
..write('cursor: $cursor, ')
|
||||
..write('shuffled: $shuffled, ')
|
||||
..write('repeatMode: $repeatMode, ')
|
||||
..write('contextTitle: $contextTitle, ')
|
||||
..write('contextKind: $contextKind, ')
|
||||
..write('positionMs: $positionMs, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
profileId,
|
||||
queueJson,
|
||||
orderJson,
|
||||
cursor,
|
||||
shuffled,
|
||||
repeatMode,
|
||||
contextTitle,
|
||||
contextKind,
|
||||
positionMs,
|
||||
updatedAt,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is MusicSessionRow &&
|
||||
other.profileId == this.profileId &&
|
||||
other.queueJson == this.queueJson &&
|
||||
other.orderJson == this.orderJson &&
|
||||
other.cursor == this.cursor &&
|
||||
other.shuffled == this.shuffled &&
|
||||
other.repeatMode == this.repeatMode &&
|
||||
other.contextTitle == this.contextTitle &&
|
||||
other.contextKind == this.contextKind &&
|
||||
other.positionMs == this.positionMs &&
|
||||
other.updatedAt == this.updatedAt);
|
||||
}
|
||||
|
||||
class MusicSessionsCompanion extends UpdateCompanion<MusicSessionRow> {
|
||||
final Value<String> profileId;
|
||||
final Value<String> queueJson;
|
||||
final Value<String> orderJson;
|
||||
final Value<int> cursor;
|
||||
final Value<bool> shuffled;
|
||||
final Value<String> repeatMode;
|
||||
final Value<String?> contextTitle;
|
||||
final Value<String?> contextKind;
|
||||
final Value<int> positionMs;
|
||||
final Value<int> updatedAt;
|
||||
final Value<int> rowid;
|
||||
const MusicSessionsCompanion({
|
||||
this.profileId = const Value.absent(),
|
||||
this.queueJson = const Value.absent(),
|
||||
this.orderJson = const Value.absent(),
|
||||
this.cursor = const Value.absent(),
|
||||
this.shuffled = const Value.absent(),
|
||||
this.repeatMode = const Value.absent(),
|
||||
this.contextTitle = const Value.absent(),
|
||||
this.contextKind = const Value.absent(),
|
||||
this.positionMs = const Value.absent(),
|
||||
this.updatedAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
MusicSessionsCompanion.insert({
|
||||
required String profileId,
|
||||
required String queueJson,
|
||||
required String orderJson,
|
||||
required int cursor,
|
||||
this.shuffled = const Value.absent(),
|
||||
this.repeatMode = const Value.absent(),
|
||||
this.contextTitle = const Value.absent(),
|
||||
this.contextKind = const Value.absent(),
|
||||
this.positionMs = const Value.absent(),
|
||||
required int updatedAt,
|
||||
this.rowid = const Value.absent(),
|
||||
}) : profileId = Value(profileId),
|
||||
queueJson = Value(queueJson),
|
||||
orderJson = Value(orderJson),
|
||||
cursor = Value(cursor),
|
||||
updatedAt = Value(updatedAt);
|
||||
static Insertable<MusicSessionRow> custom({
|
||||
Expression<String>? profileId,
|
||||
Expression<String>? queueJson,
|
||||
Expression<String>? orderJson,
|
||||
Expression<int>? cursor,
|
||||
Expression<bool>? shuffled,
|
||||
Expression<String>? repeatMode,
|
||||
Expression<String>? contextTitle,
|
||||
Expression<String>? contextKind,
|
||||
Expression<int>? positionMs,
|
||||
Expression<int>? updatedAt,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (profileId != null) 'profile_id': profileId,
|
||||
if (queueJson != null) 'queue_json': queueJson,
|
||||
if (orderJson != null) 'order_json': orderJson,
|
||||
if (cursor != null) 'cursor': cursor,
|
||||
if (shuffled != null) 'shuffled': shuffled,
|
||||
if (repeatMode != null) 'repeat_mode': repeatMode,
|
||||
if (contextTitle != null) 'context_title': contextTitle,
|
||||
if (contextKind != null) 'context_kind': contextKind,
|
||||
if (positionMs != null) 'position_ms': positionMs,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
||||
MusicSessionsCompanion copyWith({
|
||||
Value<String>? profileId,
|
||||
Value<String>? queueJson,
|
||||
Value<String>? orderJson,
|
||||
Value<int>? cursor,
|
||||
Value<bool>? shuffled,
|
||||
Value<String>? repeatMode,
|
||||
Value<String?>? contextTitle,
|
||||
Value<String?>? contextKind,
|
||||
Value<int>? positionMs,
|
||||
Value<int>? updatedAt,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return MusicSessionsCompanion(
|
||||
profileId: profileId ?? this.profileId,
|
||||
queueJson: queueJson ?? this.queueJson,
|
||||
orderJson: orderJson ?? this.orderJson,
|
||||
cursor: cursor ?? this.cursor,
|
||||
shuffled: shuffled ?? this.shuffled,
|
||||
repeatMode: repeatMode ?? this.repeatMode,
|
||||
contextTitle: contextTitle ?? this.contextTitle,
|
||||
contextKind: contextKind ?? this.contextKind,
|
||||
positionMs: positionMs ?? this.positionMs,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (profileId.present) {
|
||||
map['profile_id'] = Variable<String>(profileId.value);
|
||||
}
|
||||
if (queueJson.present) {
|
||||
map['queue_json'] = Variable<String>(queueJson.value);
|
||||
}
|
||||
if (orderJson.present) {
|
||||
map['order_json'] = Variable<String>(orderJson.value);
|
||||
}
|
||||
if (cursor.present) {
|
||||
map['cursor'] = Variable<int>(cursor.value);
|
||||
}
|
||||
if (shuffled.present) {
|
||||
map['shuffled'] = Variable<bool>(shuffled.value);
|
||||
}
|
||||
if (repeatMode.present) {
|
||||
map['repeat_mode'] = Variable<String>(repeatMode.value);
|
||||
}
|
||||
if (contextTitle.present) {
|
||||
map['context_title'] = Variable<String>(contextTitle.value);
|
||||
}
|
||||
if (contextKind.present) {
|
||||
map['context_kind'] = Variable<String>(contextKind.value);
|
||||
}
|
||||
if (positionMs.present) {
|
||||
map['position_ms'] = Variable<int>(positionMs.value);
|
||||
}
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = Variable<int>(updatedAt.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MusicSessionsCompanion(')
|
||||
..write('profileId: $profileId, ')
|
||||
..write('queueJson: $queueJson, ')
|
||||
..write('orderJson: $orderJson, ')
|
||||
..write('cursor: $cursor, ')
|
||||
..write('shuffled: $shuffled, ')
|
||||
..write('repeatMode: $repeatMode, ')
|
||||
..write('contextTitle: $contextTitle, ')
|
||||
..write('contextKind: $contextKind, ')
|
||||
..write('positionMs: $positionMs, ')
|
||||
..write('updatedAt: $updatedAt, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
_$AppDatabase(QueryExecutor e) : super(e);
|
||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||
@@ -5787,6 +6428,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
late final $ProfilesTable profiles = $ProfilesTable(this);
|
||||
late final $ProfileConnectionsTable profileConnections =
|
||||
$ProfileConnectionsTable(this);
|
||||
late final $MusicSessionsTable musicSessions = $MusicSessionsTable(this);
|
||||
late final Index idxDownloadedMediaStatus = Index(
|
||||
'idx_downloaded_media_status',
|
||||
'CREATE INDEX idx_downloaded_media_status ON downloaded_media (status)',
|
||||
@@ -5858,6 +6500,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
connections,
|
||||
profiles,
|
||||
profileConnections,
|
||||
musicSessions,
|
||||
idxDownloadedMediaStatus,
|
||||
idxDownloadedMediaServer,
|
||||
idxDownloadedMediaParent,
|
||||
@@ -9190,6 +9833,309 @@ typedef $$ProfileConnectionsTableProcessedTableManager =
|
||||
ProfileConnectionRow,
|
||||
PrefetchHooks Function({bool connectionId})
|
||||
>;
|
||||
typedef $$MusicSessionsTableCreateCompanionBuilder =
|
||||
MusicSessionsCompanion Function({
|
||||
required String profileId,
|
||||
required String queueJson,
|
||||
required String orderJson,
|
||||
required int cursor,
|
||||
Value<bool> shuffled,
|
||||
Value<String> repeatMode,
|
||||
Value<String?> contextTitle,
|
||||
Value<String?> contextKind,
|
||||
Value<int> positionMs,
|
||||
required int updatedAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$MusicSessionsTableUpdateCompanionBuilder =
|
||||
MusicSessionsCompanion Function({
|
||||
Value<String> profileId,
|
||||
Value<String> queueJson,
|
||||
Value<String> orderJson,
|
||||
Value<int> cursor,
|
||||
Value<bool> shuffled,
|
||||
Value<String> repeatMode,
|
||||
Value<String?> contextTitle,
|
||||
Value<String?> contextKind,
|
||||
Value<int> positionMs,
|
||||
Value<int> updatedAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$MusicSessionsTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $MusicSessionsTable> {
|
||||
$$MusicSessionsTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get profileId => $composableBuilder(
|
||||
column: $table.profileId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get queueJson => $composableBuilder(
|
||||
column: $table.queueJson,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get orderJson => $composableBuilder(
|
||||
column: $table.orderJson,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get cursor => $composableBuilder(
|
||||
column: $table.cursor,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get shuffled => $composableBuilder(
|
||||
column: $table.shuffled,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get repeatMode => $composableBuilder(
|
||||
column: $table.repeatMode,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get contextTitle => $composableBuilder(
|
||||
column: $table.contextTitle,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get contextKind => $composableBuilder(
|
||||
column: $table.contextKind,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get positionMs => $composableBuilder(
|
||||
column: $table.positionMs,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MusicSessionsTableOrderingComposer
|
||||
extends Composer<_$AppDatabase, $MusicSessionsTable> {
|
||||
$$MusicSessionsTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<String> get profileId => $composableBuilder(
|
||||
column: $table.profileId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get queueJson => $composableBuilder(
|
||||
column: $table.queueJson,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get orderJson => $composableBuilder(
|
||||
column: $table.orderJson,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get cursor => $composableBuilder(
|
||||
column: $table.cursor,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get shuffled => $composableBuilder(
|
||||
column: $table.shuffled,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get repeatMode => $composableBuilder(
|
||||
column: $table.repeatMode,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get contextTitle => $composableBuilder(
|
||||
column: $table.contextTitle,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get contextKind => $composableBuilder(
|
||||
column: $table.contextKind,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get positionMs => $composableBuilder(
|
||||
column: $table.positionMs,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MusicSessionsTableAnnotationComposer
|
||||
extends Composer<_$AppDatabase, $MusicSessionsTable> {
|
||||
$$MusicSessionsTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<String> get profileId =>
|
||||
$composableBuilder(column: $table.profileId, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get queueJson =>
|
||||
$composableBuilder(column: $table.queueJson, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get orderJson =>
|
||||
$composableBuilder(column: $table.orderJson, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> get cursor =>
|
||||
$composableBuilder(column: $table.cursor, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<bool> get shuffled =>
|
||||
$composableBuilder(column: $table.shuffled, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get repeatMode => $composableBuilder(
|
||||
column: $table.repeatMode,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get contextTitle => $composableBuilder(
|
||||
column: $table.contextTitle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get contextKind => $composableBuilder(
|
||||
column: $table.contextKind,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get positionMs => $composableBuilder(
|
||||
column: $table.positionMs,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get updatedAt =>
|
||||
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$MusicSessionsTableTableManager
|
||||
extends
|
||||
RootTableManager<
|
||||
_$AppDatabase,
|
||||
$MusicSessionsTable,
|
||||
MusicSessionRow,
|
||||
$$MusicSessionsTableFilterComposer,
|
||||
$$MusicSessionsTableOrderingComposer,
|
||||
$$MusicSessionsTableAnnotationComposer,
|
||||
$$MusicSessionsTableCreateCompanionBuilder,
|
||||
$$MusicSessionsTableUpdateCompanionBuilder,
|
||||
(
|
||||
MusicSessionRow,
|
||||
BaseReferences<_$AppDatabase, $MusicSessionsTable, MusicSessionRow>,
|
||||
),
|
||||
MusicSessionRow,
|
||||
PrefetchHooks Function()
|
||||
> {
|
||||
$$MusicSessionsTableTableManager(_$AppDatabase db, $MusicSessionsTable table)
|
||||
: super(
|
||||
TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$MusicSessionsTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$MusicSessionsTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$MusicSessionsTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
Value<String> profileId = const Value.absent(),
|
||||
Value<String> queueJson = const Value.absent(),
|
||||
Value<String> orderJson = const Value.absent(),
|
||||
Value<int> cursor = const Value.absent(),
|
||||
Value<bool> shuffled = const Value.absent(),
|
||||
Value<String> repeatMode = const Value.absent(),
|
||||
Value<String?> contextTitle = const Value.absent(),
|
||||
Value<String?> contextKind = const Value.absent(),
|
||||
Value<int> positionMs = const Value.absent(),
|
||||
Value<int> updatedAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => MusicSessionsCompanion(
|
||||
profileId: profileId,
|
||||
queueJson: queueJson,
|
||||
orderJson: orderJson,
|
||||
cursor: cursor,
|
||||
shuffled: shuffled,
|
||||
repeatMode: repeatMode,
|
||||
contextTitle: contextTitle,
|
||||
contextKind: contextKind,
|
||||
positionMs: positionMs,
|
||||
updatedAt: updatedAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String profileId,
|
||||
required String queueJson,
|
||||
required String orderJson,
|
||||
required int cursor,
|
||||
Value<bool> shuffled = const Value.absent(),
|
||||
Value<String> repeatMode = const Value.absent(),
|
||||
Value<String?> contextTitle = const Value.absent(),
|
||||
Value<String?> contextKind = const Value.absent(),
|
||||
Value<int> positionMs = const Value.absent(),
|
||||
required int updatedAt,
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => MusicSessionsCompanion.insert(
|
||||
profileId: profileId,
|
||||
queueJson: queueJson,
|
||||
orderJson: orderJson,
|
||||
cursor: cursor,
|
||||
shuffled: shuffled,
|
||||
repeatMode: repeatMode,
|
||||
contextTitle: contextTitle,
|
||||
contextKind: contextKind,
|
||||
positionMs: positionMs,
|
||||
updatedAt: updatedAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$MusicSessionsTableProcessedTableManager =
|
||||
ProcessedTableManager<
|
||||
_$AppDatabase,
|
||||
$MusicSessionsTable,
|
||||
MusicSessionRow,
|
||||
$$MusicSessionsTableFilterComposer,
|
||||
$$MusicSessionsTableOrderingComposer,
|
||||
$$MusicSessionsTableAnnotationComposer,
|
||||
$$MusicSessionsTableCreateCompanionBuilder,
|
||||
$$MusicSessionsTableUpdateCompanionBuilder,
|
||||
(
|
||||
MusicSessionRow,
|
||||
BaseReferences<_$AppDatabase, $MusicSessionsTable, MusicSessionRow>,
|
||||
),
|
||||
MusicSessionRow,
|
||||
PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $AppDatabaseManager {
|
||||
final _$AppDatabase _db;
|
||||
@@ -9214,4 +10160,6 @@ class $AppDatabaseManager {
|
||||
$$ProfilesTableTableManager(_db, _db.profiles);
|
||||
$$ProfileConnectionsTableTableManager get profileConnections =>
|
||||
$$ProfileConnectionsTableTableManager(_db, _db.profileConnections);
|
||||
$$MusicSessionsTableTableManager get musicSessions =>
|
||||
$$MusicSessionsTableTableManager(_db, _db.musicSessions);
|
||||
}
|
||||
|
||||
@@ -433,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),
|
||||
|
||||
@@ -282,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).
|
||||
///
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Keeps a subtree from taking focus while the [ModalRoute] it lives in is
|
||||
/// covered by another route, and hands focus back when it is uncovered.
|
||||
///
|
||||
/// Flutter only marks a covered route's scope `skipTraversal`; any descendant
|
||||
/// may still call `requestFocus()` and win. That is harmless on a single
|
||||
/// navigator, where every screen's `ModalRoute.of(context).isCurrent` guard
|
||||
/// tells the truth. It breaks with a nested navigator: a route pushed on the
|
||||
/// *root* navigator (the profile picker, the PIN dialog) covers the whole
|
||||
/// nested stack, yet each nested route still reports `isCurrent == true` and
|
||||
/// its focus self-heals — sidebar reveal, library grid load, TV browse rail —
|
||||
/// yank the remote off the visible route, which on tvOS reads as a dead
|
||||
/// remote (#2034, #2239).
|
||||
///
|
||||
/// The boundary restores the invariant once, at the navigator boundary,
|
||||
/// instead of at every reclaim site: while the enclosing route is not current
|
||||
/// the subtree is [ExcludeFocus]ed, so every `requestFocus()` below it is a
|
||||
/// no-op. On uncover it re-requests the subtree's own [FocusScope], whose
|
||||
/// focus history still leads back to the leaf that had focus before the
|
||||
/// cover; Flutter's own restoration cannot, because the covering route's pop
|
||||
/// culls the excluded scope from the route scope's history before the
|
||||
/// exclusion lifts.
|
||||
class CoveredRouteFocusBoundary extends StatefulWidget {
|
||||
const CoveredRouteFocusBoundary({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<CoveredRouteFocusBoundary> createState() => _CoveredRouteFocusBoundaryState();
|
||||
}
|
||||
|
||||
class _CoveredRouteFocusBoundaryState extends State<CoveredRouteFocusBoundary> {
|
||||
final _scope = FocusScopeNode(debugLabel: 'CoveredRouteFocusBoundary');
|
||||
bool _covered = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ModalRoute.of subscribes to the route's status, so this rebuilds on
|
||||
// every isCurrent flip. Outside any route the subtree is never covered.
|
||||
final covered = !(ModalRoute.of(context)?.isCurrent ?? true);
|
||||
if (_covered && !covered) {
|
||||
// The exclusion lifts in this build's didUpdateWidget, after the pop
|
||||
// already parked focus on the route scope; restore once it has.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_scope.hasFocus) _scope.requestFocus();
|
||||
});
|
||||
}
|
||||
_covered = covered;
|
||||
return ExcludeFocus(
|
||||
excluding: covered,
|
||||
child: FocusScope(node: _scope, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
class FocusTheme {
|
||||
FocusTheme._();
|
||||
@@ -24,6 +25,19 @@ class FocusTheme {
|
||||
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
|
||||
}
|
||||
|
||||
/// How long a TV row (or the hub list) glides after one D-pad focus step.
|
||||
///
|
||||
/// Apple TV keeps the ~500ms ease-out measured from the native focus
|
||||
/// engine's scrollable containers (issue #2006): Siri Remote swipes chain
|
||||
/// steps into one continuous glide and users expect that inertia. D-pad
|
||||
/// platforms have no such reference: Leanback's `GridLayoutManager` prices a
|
||||
/// one-card step at roughly 100-150ms, so a 500ms glide there trails the
|
||||
/// focus border on every press and reads as input lag next to the launcher.
|
||||
/// Successive presses (including hold-repeats) retarget the animation from
|
||||
/// wherever the row currently is, so a fast series still glides continuously.
|
||||
static Duration navigationScrollDuration() =>
|
||||
PlatformDetector.isAppleTV() ? const Duration(milliseconds: 500) : const Duration(milliseconds: 150);
|
||||
|
||||
/// [radii] overrides [borderRadius] when per-corner radii are needed
|
||||
/// (M3E grouped cards: large outer / small inner corners).
|
||||
static BoxDecoration focusDecoration(
|
||||
|
||||
@@ -130,8 +130,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
void didUpdateWidget(FocusableActionBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_shouldRebuildFocusNodes(oldWidget)) {
|
||||
_disposeNodes();
|
||||
_initNodes();
|
||||
_rebindNodes(oldWidget.actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,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;
|
||||
@@ -223,6 +311,9 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
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,
|
||||
|
||||
@@ -86,6 +86,11 @@ class TvTextInputController {
|
||||
/// Focus the field without opening either native or Flutter text input for
|
||||
/// this focus entry.
|
||||
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
|
||||
|
||||
/// Focus the field and open its text input, as an explicit Select would —
|
||||
/// for a field the app creates on the user's behalf (a new editor row) that
|
||||
/// should be typed into at once, without a second press.
|
||||
void focusAndOpenTextInput() => _host?._focusAndOpenTextInput();
|
||||
}
|
||||
|
||||
String _describeTextInputKey(KeyEvent event) {
|
||||
@@ -227,9 +232,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
|
||||
@@ -1144,6 +1172,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Focus the field and open text input as an explicit Select would. Clears
|
||||
/// per-focus suppression first so a field configured with
|
||||
/// [TvTextInputAutoOpenBehavior.never] still opens.
|
||||
void _focusAndOpenTextInput() {
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||
if (focusNode.hasFocus) {
|
||||
_openTextInputForFocusedField();
|
||||
return;
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
// Focus lands in FocusManager's microtask. Activating before that would be
|
||||
// undone by the focus sync this frame's build already scheduled, which
|
||||
// deactivates an unfocused field.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted && focusNode.hasFocus) _openTextInputForFocusedField();
|
||||
});
|
||||
}
|
||||
|
||||
void _openTextInputForFocusedField() {
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_activateNativeTextInput();
|
||||
} else if (widget.input._hasTvKeyboard && !_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
|
||||
// The overlay is a navigator route; push it once the focus request has
|
||||
// landed so the route's focus scope does not race the field's.
|
||||
_tvKeyboardOpenScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_openTvKeyboard();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setNativeTextInputFocused(bool focused) {
|
||||
if (_reportedNativeTextInputFocused == focused) {
|
||||
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
|
||||
|
||||
@@ -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();
|
||||
|
||||
+177
-11
@@ -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,6 +213,8 @@
|
||||
"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": "Xüsusi bölmələr epizod sırasına görə",
|
||||
@@ -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,33 @@
|
||||
"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",
|
||||
@@ -675,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",
|
||||
@@ -806,6 +855,9 @@
|
||||
"presetDeleted": "Ön ayar silindi",
|
||||
"confirmDeletePreset": "Bu ön ayarı silmək istədiyinizə əminsiniz?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# şərh",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context və gpu-api Linux-da nəzərə alınmır: daxili video həmişə video müstəvisində vo=libmpv vasitəsilə göstərilir və gpu-next (ArtCNN kimi hesablama şeyderlərinə lazımdır) daxili işləyə bilməz."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -887,6 +939,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",
|
||||
@@ -1032,7 +1155,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",
|
||||
@@ -1282,6 +1406,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",
|
||||
@@ -1337,6 +1466,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ı",
|
||||
@@ -1420,6 +1559,8 @@
|
||||
"repeatAll": "Hamısını təkrarla",
|
||||
"repeatOne": "Birini təkrarla",
|
||||
"instantMixNoServer": "Ani miks üçün heç bir server 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",
|
||||
@@ -1451,6 +1592,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",
|
||||
@@ -1483,6 +1631,7 @@
|
||||
"participantPaused": "${name} fasilə etdi",
|
||||
"participantResumed": "${name} davam etdirdi",
|
||||
"participantSeeked": "${name} oynatma mövqeyini dəyişdi",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buferləyir",
|
||||
"participantNeedsUpdate": "${name} köhnə tətbiq versiyasındadır",
|
||||
"resumingWithout": "${name} olmadan davam edilir",
|
||||
@@ -1493,7 +1642,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",
|
||||
@@ -1641,7 +1796,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",
|
||||
@@ -1922,6 +2078,10 @@
|
||||
"qualityProfile": "Keyfiyyət profili",
|
||||
"rootFolder": "Kök qovluq",
|
||||
"languageProfile": "Dil profili",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Sorğu göndərildi",
|
||||
"requestFailed": "Sorğu uğursuz oldu: ${error}",
|
||||
"requestsLoadFailed": "Seçimlər yüklənə bilmədi",
|
||||
@@ -1930,8 +2090,12 @@
|
||||
"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})",
|
||||
"behindAuthProxy": "",
|
||||
"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",
|
||||
@@ -1944,6 +2108,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ə",
|
||||
@@ -2021,6 +2186,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",
|
||||
|
||||
+177
-11
@@ -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,6 +213,8 @@
|
||||
"rememberTrackSelectionsDescription": "Запомняй избора на аудиопътечка и субтитри за всяко заглавие",
|
||||
"followServerTrackSelections": "Използвай избора на пътечки от сървъра за всеки епизод",
|
||||
"followServerTrackSelectionsDescription": "При смяна на епизода прилагай избраните на сървъра аудио и субтитри, вместо да се пренася текущият избор",
|
||||
"resumeMusicOnLaunch": "Запомняне на музикалната сесия",
|
||||
"resumeMusicOnLaunchDescription": "При стартиране на приложението отваряй последната песен на пауза от мястото, докъдето е стигнала",
|
||||
"showChapterMarkersOnTimeline": "Показвай маркери на глави върху времевата линия",
|
||||
"showChapterMarkersOnTimelineDescription": "Разделяй времевата линия на сегменти по границите на главите",
|
||||
"specialsOrdering": "Специални епизоди в реда на епизодите",
|
||||
@@ -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,33 @@
|
||||
"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": "Скорост на възпроизвеждане",
|
||||
@@ -675,6 +723,7 @@
|
||||
"notSupported": "Устройството не поддържа режим картина в картината",
|
||||
"voSwitchFailed": "Неуспешна смяна на видео изхода за режим картина в картината",
|
||||
"failed": "Режимът картина в картината не успя да стартира",
|
||||
"prepareFailed": "Режимът картина в картината не можа да бъде подготвен",
|
||||
"unknown": "Възникна грешка: ${error}"
|
||||
},
|
||||
"chapters": "Глави",
|
||||
@@ -806,6 +855,9 @@
|
||||
"presetDeleted": "Пресетът е изтрит",
|
||||
"confirmDeletePreset": "Сигурни ли сте, че искате да изтриете този пресет?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context и gpu-api се игнорират на Linux: вграденото видео винаги се рендерира през vo=libmpv върху видео равнината, а gpu-next (който е нужен за compute шейдъри като ArtCNN) не може да работи вградено."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -887,6 +939,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": "Няма налично съдържание",
|
||||
@@ -1032,7 +1155,8 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"noServersFoundForAccount": "Не са намерени сървъри за ${username} (${email})",
|
||||
"failedToLoadServers": "Неуспешно зареждане на сървъри: ${error}"
|
||||
"failedToLoadServers": "Неуспешно зареждане на сървъри: ${error}",
|
||||
"noValidServers": "Не бяха намерени използваеми сървъри в този акаунт"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Заглавие",
|
||||
@@ -1282,6 +1406,11 @@
|
||||
"unknownChannel": "Неизвестен канал",
|
||||
"live": "НА ЖИВО",
|
||||
"reloadGuide": "Презареди ТВ програмата",
|
||||
"searchGuide": "Търсене в програмата",
|
||||
"searchHint": "Търсене на канали и предавания",
|
||||
"searchNoResults": "Няма съвпадения за \"${query}\"",
|
||||
"channelsSection": "Канали",
|
||||
"programsSection": "Предавания",
|
||||
"now": "Сега",
|
||||
"today": "Днес",
|
||||
"tomorrow": "Утре",
|
||||
@@ -1337,6 +1466,16 @@
|
||||
"guideReloadRequested": "Заявено е опресняване на ТВ програмата",
|
||||
"rulesProcessRequested": "Заявена е преоценка на правилата",
|
||||
"recordShow": "Запиши предаването",
|
||||
"recordSettings": {
|
||||
"startEarly": "Започване по-рано (секунди)",
|
||||
"endLate": "Приключване по-късно (секунди)",
|
||||
"newOnly": "Само нови епизоди",
|
||||
"anyChannel": "Записване от всеки канал",
|
||||
"anyTime": "Записване по всяко време",
|
||||
"skipInLibrary": "Пропускане на епизоди, които вече са в библиотеката",
|
||||
"keepUpTo": "Епизоди за запазване",
|
||||
"keepUpToHint": "0 запазва всички епизоди"
|
||||
},
|
||||
"startingInMinutes": "Започва след ${minutes} мин",
|
||||
"dayAtTime": "${day} в ${time}",
|
||||
"invalidPlaybackData": "${product} върна невалидни данни за възпроизвеждане на телевизия на живо",
|
||||
@@ -1420,6 +1559,8 @@
|
||||
"repeatAll": "Повтаряне на всички",
|
||||
"repeatOne": "Повтаряне на една",
|
||||
"instantMixNoServer": "Няма наличен сървър за незабавен микс",
|
||||
"instantMixFailed": "Мигновеният микс не можа да бъде зареден",
|
||||
"instantMixEmpty": "Мигновеният микс не върна песни",
|
||||
"noAudioUrl": "Няма наличен URL за аудиото на ${track}",
|
||||
"discography": {
|
||||
"singlesAndEps": "Сингли и EP",
|
||||
@@ -1451,6 +1592,13 @@
|
||||
"host": "Организатор",
|
||||
"hostBadge": "ОРГАНИЗАТОР",
|
||||
"youAreHost": "Вие сте организаторът",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Гледате с други",
|
||||
"endSession": "Край на сесията",
|
||||
"leaveSession": "Напусни сесията",
|
||||
@@ -1483,6 +1631,7 @@
|
||||
"participantPaused": "${name} постави на пауза",
|
||||
"participantResumed": "${name} продължи",
|
||||
"participantSeeked": "${name} промени позицията на възпроизвеждане",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} буферира",
|
||||
"participantNeedsUpdate": "${name} е с по-стара версия на приложението — синхронизирането не е налично",
|
||||
"resumingWithout": "Продължаване без ${name}",
|
||||
@@ -1493,7 +1642,13 @@
|
||||
"removeRoom": "Премахни",
|
||||
"guestSwitchUnavailable": "Превключването не е възможно — сървърът е недостъпен за синхронизация",
|
||||
"guestSwitchFailed": "Превключването не е възможно — съдържанието не е намерено на този сървър",
|
||||
"defaultDisplayName": "Потребител"
|
||||
"defaultDisplayName": "Потребител",
|
||||
"errors": {
|
||||
"timedOut": "Релейният сървър не отговори навреме",
|
||||
"connectionLost": "Връзката се затвори, преди сесията да е готова",
|
||||
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
|
||||
"sessionEnded": "Организаторът прекрати сесията"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Изтегляния",
|
||||
@@ -1641,7 +1796,8 @@
|
||||
"usePhoneToControl": "Използвайте мобилното си устройство, за да управлявате това приложение",
|
||||
"startServer": "Стартирай сървър",
|
||||
"stopServer": "Спри сървър",
|
||||
"minimize": "Минимизирай"
|
||||
"minimize": "Минимизирай",
|
||||
"manualAddressHint": "Ръчен адрес за връзка:"
|
||||
},
|
||||
"pairing": {
|
||||
"discoveryDescription": "Plezy устройства със същия Plex акаунт се показват тук",
|
||||
@@ -1922,6 +2078,10 @@
|
||||
"qualityProfile": "Профил за качество",
|
||||
"rootFolder": "Основна папка",
|
||||
"languageProfile": "Езиков профил",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Заявката е изпратена",
|
||||
"requestFailed": "Заявката се провали: ${error}",
|
||||
"requestsLoadFailed": "Неуспешно зареждане на опциите за заявка",
|
||||
@@ -1930,8 +2090,12 @@
|
||||
"statusPartiallyAvailable": "Частично налично",
|
||||
"statusRequested": "Заявено",
|
||||
"statusProcessing": "Обработва се",
|
||||
"statusBlocklisted": "В списъка с блокирани",
|
||||
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
|
||||
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
|
||||
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
|
||||
"noPlexTokenForReauth": "Няма наличен Plex токен за повторен вход",
|
||||
"noStoredCredentials": "Няма запазени данни за повторен вход",
|
||||
@@ -1944,6 +2108,7 @@
|
||||
"services": {
|
||||
"title": "Услуги",
|
||||
"hubSubtitle": "Синхронизирай прогреса на гледане и заявявай нови заглавия.",
|
||||
"integrations": "Интеграции",
|
||||
"notConnected": "Няма връзка",
|
||||
"connectedAs": "Свързан като @${username}",
|
||||
"scrobble": "Проследявай прогреса автоматично",
|
||||
@@ -2021,6 +2186,7 @@
|
||||
"borrowFromAnotherProfileSubtitle": "Използвай връзка от друг профил. PIN-защитените профили изискват PIN.",
|
||||
"invalidCredentials": "Невалидно потребителско име или парола",
|
||||
"authResponseNotJson": "Отговорът при удостоверяване не беше валиден JSON",
|
||||
"authResponseIncomplete": "Отговорът за вход от сървъра беше непълен",
|
||||
"quickConnectRejected": "Quick Connect беше отхвърлен от сървъра",
|
||||
"quickConnectNotJson": "Отговорът на Quick Connect не беше валиден JSON",
|
||||
"quickConnectMissingFields": "В отговора на Quick Connect липсва код или таен ключ",
|
||||
|
||||
+177
-11
@@ -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,6 +213,8 @@
|
||||
"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": "Specialafsnit i episoderekkefølge",
|
||||
@@ -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,33 @@
|
||||
"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",
|
||||
@@ -675,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",
|
||||
@@ -806,6 +855,9 @@
|
||||
"presetDeleted": "Forudindstilling slettet",
|
||||
"confirmDeletePreset": "Er du sikker på, at du vil slette denne forudindstilling?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context og gpu-api ignoreres på Linux: indlejret video renderes altid via vo=libmpv på videoplanen, og gpu-next (som compute-shaders som ArtCNN kræver) kan ikke køre indlejret."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -887,6 +939,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",
|
||||
@@ -1032,7 +1155,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",
|
||||
@@ -1282,6 +1406,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",
|
||||
@@ -1337,6 +1466,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",
|
||||
@@ -1420,6 +1559,8 @@
|
||||
"repeatAll": "Gentag alle",
|
||||
"repeatOne": "Gentag ét nummer",
|
||||
"instantMixNoServer": "Ingen server er tilgængelig til et øjeblikkeligt mix",
|
||||
"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",
|
||||
@@ -1451,6 +1592,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",
|
||||
@@ -1483,6 +1631,7 @@
|
||||
"participantPaused": "${name} satte på pause",
|
||||
"participantResumed": "${name} genoptog",
|
||||
"participantSeeked": "${name} ændrede afspilningspositionen",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} bufferer",
|
||||
"participantNeedsUpdate": "${name} bruger en ældre appversion — synkronisering er ikke tilgængelig",
|
||||
"resumingWithout": "Fortsætter uden ${name}",
|
||||
@@ -1493,7 +1642,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",
|
||||
@@ -1641,7 +1796,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",
|
||||
@@ -1922,6 +2078,10 @@
|
||||
"qualityProfile": "Kvalitetsprofil",
|
||||
"rootFolder": "Rodmappe",
|
||||
"languageProfile": "Sprogprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Anmodning sendt",
|
||||
"requestFailed": "Anmodning mislykkedes: ${error}",
|
||||
"requestsLoadFailed": "Kunne ikke indlæse anmodningsmuligheder",
|
||||
@@ -1930,8 +2090,12 @@
|
||||
"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})",
|
||||
"behindAuthProxy": "",
|
||||
"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",
|
||||
@@ -1944,6 +2108,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",
|
||||
@@ -2021,6 +2186,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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user