Compare commits
177
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33557f0eda | ||
|
|
a4f3c5393b | ||
|
|
533028fa0e | ||
|
|
d7f1d4a8fc | ||
|
|
c6c0ce8bea | ||
|
|
5a7e0966a3 | ||
|
|
c84b12eacd | ||
|
|
f9b9fbf611 | ||
|
|
e727f5bef4 | ||
|
|
3cf412b9c8 | ||
|
|
048bf688b2 | ||
|
|
8238705d3e | ||
|
|
722c4ddd00 | ||
|
|
56b990c04c | ||
|
|
b904c11750 | ||
|
|
8870852a9e | ||
|
|
6039fde33b | ||
|
|
82ae3609f6 | ||
|
|
32f72c6dd2 | ||
|
|
8c5d0b4213 | ||
|
|
3dd5b5d48e | ||
|
|
b6aa83af74 | ||
|
|
4712414b3d | ||
|
|
89b326e3f3 | ||
|
|
cf0dd0607e | ||
|
|
9a44f062a6 | ||
|
|
178634e943 | ||
|
|
99f4fda55a | ||
|
|
908ccbb233 | ||
|
|
2b176861b7 | ||
|
|
647971a5d6 | ||
|
|
a18c54f5ce | ||
|
|
c5ed666aa9 | ||
|
|
6580c4cf97 | ||
|
|
3ca565baa9 | ||
|
|
bc1a715411 | ||
|
|
c20a6cf162 | ||
|
|
ab1c9b9afd | ||
|
|
9ef3de985f | ||
|
|
54438c8d7a | ||
|
|
1d5b175cc8 | ||
|
|
b80aa1916e | ||
|
|
5c40ba0505 | ||
|
|
cbc47790c3 | ||
|
|
baa31742cb | ||
|
|
01750ad92b | ||
|
|
5de2d28166 | ||
|
|
88e6d33221 | ||
|
|
ac7fb87ade | ||
|
|
ce3a3b177c | ||
|
|
b795ef1604 | ||
|
|
deeec6810d | ||
|
|
d27cb61a2b | ||
|
|
e660a06499 | ||
|
|
2fb04a14ad | ||
|
|
c228a12d1e | ||
|
|
d47bcdec12 | ||
|
|
bd9fb38413 | ||
|
|
dca8029a99 | ||
|
|
7aa1679036 | ||
|
|
9ae34473af | ||
|
|
3fb8794619 | ||
|
|
98c6a09b41 | ||
|
|
621b50141c | ||
|
|
02dc57bd55 | ||
|
|
38f00f51c4 | ||
|
|
77422f2e6e | ||
|
|
d2d73f35f8 | ||
|
|
d6f0aa8936 | ||
|
|
e35f6b3a64 | ||
|
|
80bc37eb34 | ||
|
|
0490de5b5b | ||
|
|
f738e74994 | ||
|
|
65af57dd93 | ||
|
|
bc483a8206 | ||
|
|
c0d090079b | ||
|
|
6147736bba | ||
|
|
7885bea016 | ||
|
|
05598f1708 | ||
|
|
dc06b2e181 | ||
|
|
dbf8bf54c5 | ||
|
|
36e898067a | ||
|
|
a475e92d7e | ||
|
|
55985b8f9c | ||
|
|
c5968029e0 | ||
|
|
cd22475cb6 | ||
|
|
bc13ff007a | ||
|
|
29a6286fe6 | ||
|
|
6815850b61 | ||
|
|
a2c9bea041 | ||
|
|
a629a3407a | ||
|
|
7e930580cf | ||
|
|
b79af75a1f | ||
|
|
a873009fff | ||
|
|
41c92b6324 | ||
|
|
e59b79266a | ||
|
|
d2fe21b169 | ||
|
|
f45eaf1367 | ||
|
|
4f9e527ef2 | ||
|
|
5efc4f384c | ||
|
|
12a380807d | ||
|
|
af9fb3f17c | ||
|
|
781fb66421 | ||
|
|
1b45588d3b | ||
|
|
1a3dc3d3c9 | ||
|
|
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 |
+25
-20
@@ -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
|
||||
|
||||
@@ -1058,7 +1063,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
||||
with:
|
||||
files: ${{ steps.release-files.outputs.files }}
|
||||
draft: true
|
||||
|
||||
+23
-20
@@ -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
|
||||
@@ -404,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:
|
||||
@@ -645,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
|
||||
@@ -662,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 =="
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
version "2.19.1"
|
||||
sha256 "7d3ffd0efccf4db5faf063e8ecc03e897ee30d08e9fa3b007d127c63cbf187e2"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
@@ -16,10 +16,10 @@ cask "plezy" do
|
||||
|
||||
app "Plezy.app"
|
||||
|
||||
postflight do
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-cr", "#{appdir}/Plezy.app"],
|
||||
sudo: false
|
||||
postflight_steps do
|
||||
run "/usr/bin/xattr",
|
||||
args: ["-cr", "{{appdir}}/Plezy.app"],
|
||||
sudo: false
|
||||
end
|
||||
|
||||
uninstall quit: "com.edde746.plezy"
|
||||
|
||||
@@ -30,22 +30,47 @@ A modern client for Plex, Jellyfin, and Emby on desktop, mobile, and TV. Built w
|
||||
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
|
||||
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
|
||||
|
||||
Package managers:
|
||||
<details>
|
||||
<summary>Install with a package manager</summary>
|
||||
|
||||
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
|
||||
- **Homebrew** (macOS):
|
||||
```bash
|
||||
brew tap edde746/plezy https://github.com/edde746/plezy
|
||||
brew install --cask plezy
|
||||
```
|
||||
- [Pacman](https://archlinux.org/packages/extra/x86_64/plezy/) (Arch Linux) - Official package:
|
||||
```bash
|
||||
sudo pacman -S plezy
|
||||
```
|
||||
- **WinGet** (Windows):
|
||||
```bash
|
||||
winget install edde746.Plezy
|
||||
```
|
||||
### macOS — Homebrew
|
||||
|
||||
```bash
|
||||
brew tap edde746/plezy https://github.com/edde746/plezy
|
||||
brew install --cask plezy
|
||||
```
|
||||
|
||||
### Windows — WinGet
|
||||
|
||||
```bash
|
||||
winget install edde746.Plezy
|
||||
```
|
||||
|
||||
### Arch Linux — Pacman
|
||||
|
||||
[Distribution package](https://archlinux.org/packages/extra/x86_64/plezy/).
|
||||
|
||||
```bash
|
||||
sudo pacman -S plezy
|
||||
```
|
||||
|
||||
### Fedora / Red Hat — DNF
|
||||
|
||||
[Installation instructions](https://github.com/aldobarr/plezy-rpm) · Community repository by [@aldobarr](https://github.com/aldobarr).
|
||||
|
||||
### Nix
|
||||
|
||||
[Community package](https://search.nixos.org/packages?channel=unstable&query=plezy) maintained by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn).
|
||||
|
||||
### aerynOS — Moss
|
||||
|
||||
[Distribution package](https://github.com/aerynOS/recipes/tree/main/p/plezy).
|
||||
|
||||
```bash
|
||||
sudo moss it plezy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -110,6 +135,11 @@ Package managers:
|
||||
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
|
||||
- Synchronized playback with friends
|
||||
- Real-time play / pause / seek sync
|
||||
- Host handoff when the relay and every connected peer support safe transfers
|
||||
- Automatic reconnect authenticates retained room membership; it never silently joins a reused room code.
|
||||
|
||||
Self-hosted relays must support `authenticatedResume` for recovery. Deploy the updated relay before updating clients.
|
||||
Older relays still accept explicit create/join, but failed recovery requires joining or creating a room again.
|
||||
|
||||
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
|
||||
- Discord Rich Presence[^desktop]
|
||||
@@ -205,4 +235,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)
|
||||
|
||||
@@ -58,95 +58,31 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.2.2"
|
||||
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
// Dev-only escape hatch: point the build at a locally built libmpv AAR
|
||||
// (libmpv-android's build-local.sh output) to test fork patches before a
|
||||
// release is pinned. The URL + sha256 above stay authoritative otherwise.
|
||||
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
|
||||
// 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)
|
||||
inputs.property("localOverride", localMpvAar ?: "")
|
||||
localMpvAar?.let { inputs.file(it) }
|
||||
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)
|
||||
if (localMpvAar != null) {
|
||||
File(localMpvAar).copyTo(stagedAar, overwrite = true)
|
||||
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
|
||||
} else {
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
}
|
||||
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)
|
||||
@@ -216,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)
|
||||
@@ -235,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()
|
||||
@@ -349,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
|
||||
@@ -448,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")
|
||||
}
|
||||
}
|
||||
@@ -457,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,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
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Exercises MPV's JNI reachability against the production-shrunk app with
|
||||
* `-Pplezy.testBuildType=minified`. Native initialization resolves all nine static
|
||||
* callback descriptors, so a stale or missing keep fails before initialization completes.
|
||||
*
|
||||
* Only calls the core APIs used by production. Referencing MpvPlayer's callbacks
|
||||
* directly here would let the harness mask missing retention in the app APK.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvPlayerReachabilityTest {
|
||||
@Test
|
||||
fun productionCoreInitializesDeliversNativeLogAndDisposes() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
val initialized = CountDownLatch(1)
|
||||
val initializationSucceeded = AtomicBoolean()
|
||||
val commandCompleted = CountDownLatch(1)
|
||||
val commandSucceeded = AtomicBoolean()
|
||||
val nativeLogReceived = CountDownLatch(1)
|
||||
|
||||
instrumentation.runOnMainSync {
|
||||
// Pass every constructor argument: production uses this constructor, not
|
||||
// the default-argument bridge that R8 may legitimately remove.
|
||||
core.set(MpvPlayerCore(instrumentation.targetContext, true, true, 1f, "v"))
|
||||
core.get().delegate = object : PlayerDelegate {
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
if (name == "log-message" && data?.get("level") == "info" && data["text"] == LOG_MARKER) {
|
||||
nativeLogReceived.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().initialize {
|
||||
initializationSucceeded.set(it)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(initialized, "initialization")
|
||||
assertTrue("Native MPV initialization failed", initializationSucceeded.get())
|
||||
|
||||
// initialize completes after the production flow collectors subscribe.
|
||||
// This marker must cross the real native event thread and reach the delegate.
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().command(arrayOf("print-text", LOG_MARKER)) {
|
||||
commandSucceeded.set(it)
|
||||
commandCompleted.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(commandCompleted, "print-text")
|
||||
assertTrue("Native MPV print-text failed", commandSucceeded.get())
|
||||
assertCompletes(nativeLogReceived, "native log callback")
|
||||
} finally {
|
||||
val disposed = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
|
||||
assertCompletes(disposed, "native teardown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertCompletes(latch: CountDownLatch, operation: String) {
|
||||
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 15L
|
||||
const val LOG_MARKER = "plezy-mpv-r8-native-log"
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Instrumentation
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvLifecycleDeviceTest {
|
||||
@Test
|
||||
fun repeatedMediaCodecPlaybackCompletesTerminalTeardown() = runPlaybackTest(recreateSurfaces = false)
|
||||
|
||||
@Test
|
||||
fun pausedMediaCodecPlaybackSurvivesRepeatedSurfaceRecreation() = runPlaybackTest(recreateSurfaces = true)
|
||||
|
||||
@Test
|
||||
fun pausedGpuPlaybackSurvivesRepeatedSurfaceRecreation() = runPlaybackTest(recreateSurfaces = true, hardwareDecoding = false)
|
||||
|
||||
@Test
|
||||
fun placeholderConsumesFramesWithoutBlockingProducer() {
|
||||
val placeholder = runBlocking { MpvPlaceholderSurface.create() }
|
||||
val completed = CountDownLatch(1)
|
||||
val failure = AtomicReference<Throwable?>()
|
||||
val producer = Thread {
|
||||
try {
|
||||
// More than a BufferQueue can retain without an active consumer.
|
||||
repeat(16) {
|
||||
val canvas = placeholder.surface.lockCanvas(null)
|
||||
canvas.drawColor(Color.BLACK)
|
||||
placeholder.surface.unlockCanvasAndPost(canvas)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
failure.set(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}.apply { isDaemon = true }
|
||||
try {
|
||||
producer.start()
|
||||
assertCompletes(completed, "placeholder buffer consumption", 0)
|
||||
failure.get()?.let { throw AssertionError("Placeholder producer failed", it) }
|
||||
} finally {
|
||||
placeholder.close()
|
||||
producer.join(2_000)
|
||||
assertTrue("Placeholder producer survived cleanup", !producer.isAlive)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPlaybackTest(recreateSurfaces: Boolean, hardwareDecoding: Boolean = true) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val fixtureBytes = instrumentation.context.assets.open("ffmpeg/mediacodec_teardown.mp4").use { it.readBytes() }
|
||||
val fixture = copyFixture(fixtureBytes, instrumentation.targetContext.cacheDir)
|
||||
|
||||
try {
|
||||
val activity = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, MpvLifecycleTestActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
) as MpvLifecycleTestActivity
|
||||
try {
|
||||
instrumentation.waitForIdleSync()
|
||||
repeat(CYCLE_COUNT) { cycle ->
|
||||
runPlaybackCycle(instrumentation, activity, fixture, cycle, recreateSurfaces, hardwareDecoding)
|
||||
}
|
||||
} finally {
|
||||
instrumentation.runOnMainSync(activity::finish)
|
||||
instrumentation.waitForIdleSync()
|
||||
}
|
||||
} finally {
|
||||
fixture.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPlaybackCycle(
|
||||
instrumentation: Instrumentation,
|
||||
activity: MpvLifecycleTestActivity,
|
||||
fixture: File,
|
||||
cycle: Int,
|
||||
recreateSurfaces: Boolean,
|
||||
hardwareDecoding: Boolean
|
||||
) {
|
||||
val initialized = CountDownLatch(1)
|
||||
val initializationResult = AtomicReference<Boolean>()
|
||||
val events = RecordingDelegate()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
|
||||
instrumentation.runOnMainSync {
|
||||
core.set(
|
||||
MpvPlayerCore(activity, hardwareDecoding = hardwareDecoding).also { playerCore ->
|
||||
playerCore.delegate = events
|
||||
playerCore.initialize { success ->
|
||||
initializationResult.set(success)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
assertCompletes(initialized, "MPV initialization", cycle)
|
||||
assertTrue("MPV initialization failed in cycle $cycle", initializationResult.get())
|
||||
setProperty(instrumentation, core.get(), "hwdec", if (hardwareDecoding) "mediacodec" else "no", cycle)
|
||||
setProperty(instrumentation, core.get(), "aid", "no", cycle)
|
||||
if (recreateSurfaces) setProperty(instrumentation, core.get(), "loop-file", "inf", cycle)
|
||||
|
||||
val commandCompleted = CountDownLatch(1)
|
||||
val commandResult = AtomicReference<Boolean>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().command(arrayOf("loadfile", fixture.absolutePath, "replace")) { success ->
|
||||
commandResult.set(success)
|
||||
commandCompleted.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(commandCompleted, "loadfile command", cycle)
|
||||
assertTrue("loadfile command failed in cycle $cycle", commandResult.get())
|
||||
assertCompletes(events.fileLoaded, "file-loaded event", cycle)
|
||||
assertCompletes(events.playbackRestart, "playback-restart event", cycle)
|
||||
|
||||
assertVideoOutput(core.get(), cycle, hardwareDecoding)
|
||||
if (recreateSurfaces) exerciseSurfaceRecreation(instrumentation, activity, core.get(), cycle, hardwareDecoding)
|
||||
} finally {
|
||||
disposeCore(instrumentation, activity, core.get(), cycle)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disposeCore(
|
||||
instrumentation: Instrumentation,
|
||||
activity: MpvLifecycleTestActivity,
|
||||
core: MpvPlayerCore,
|
||||
cycle: Int
|
||||
) {
|
||||
val disposed = CountDownLatch(1)
|
||||
val nextMainTurn = CountDownLatch(1)
|
||||
val disposeElapsedMs = AtomicReference<Long>()
|
||||
val synchronousDisposeElapsedMs = AtomicReference<Long>()
|
||||
val disposeStartedAt = SystemClock.elapsedRealtime()
|
||||
instrumentation.runOnMainSync {
|
||||
val synchronousDisposeStartedAt = SystemClock.elapsedRealtime()
|
||||
core.dispose {
|
||||
disposeElapsedMs.set(SystemClock.elapsedRealtime() - disposeStartedAt)
|
||||
disposed.countDown()
|
||||
}
|
||||
Handler(Looper.getMainLooper()).post(nextMainTurn::countDown)
|
||||
synchronousDisposeElapsedMs.set(SystemClock.elapsedRealtime() - synchronousDisposeStartedAt)
|
||||
}
|
||||
try {
|
||||
assertTrue(
|
||||
"dispose() blocked the main thread for ${synchronousDisposeElapsedMs.get()}ms in cycle $cycle",
|
||||
synchronousDisposeElapsedMs.get() <= MAX_SYNCHRONOUS_DISPOSE_MS
|
||||
)
|
||||
assertCompletes(nextMainTurn, "main-looper turn after dispose", cycle, MAIN_LOOP_TIMEOUT_SECONDS)
|
||||
} finally {
|
||||
assertCompletes(disposed, "terminal teardown", cycle, DISPOSE_TIMEOUT_SECONDS)
|
||||
}
|
||||
assertTrue(
|
||||
"Terminal teardown took ${disposeElapsedMs.get()}ms in cycle $cycle",
|
||||
disposeElapsedMs.get() <= MAX_DISPOSE_LATENCY_MS
|
||||
)
|
||||
instrumentation.runOnMainSync {
|
||||
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
assertEquals("Player surface container leaked in cycle $cycle", 1, content.childCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun exerciseSurfaceRecreation(
|
||||
instrumentation: Instrumentation,
|
||||
activity: MpvLifecycleTestActivity,
|
||||
core: MpvPlayerCore,
|
||||
cycle: Int,
|
||||
hardwareDecoding: Boolean
|
||||
) {
|
||||
val surfaces = mutableListOf<SurfaceView>()
|
||||
instrumentation.runOnMainSync {
|
||||
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
val container = content.getChildAt(0) as ViewGroup
|
||||
// The host's first plane only punches out letterboxing; the remaining
|
||||
// SurfaceViews are the actual video and OSD planes.
|
||||
for (index in 1 until container.childCount) {
|
||||
(container.getChildAt(index) as? SurfaceView)?.let(surfaces::add)
|
||||
}
|
||||
}
|
||||
assertEquals("Expected video and optional OSD surfaces", if (hardwareDecoding) 2 else 1, surfaces.size)
|
||||
|
||||
// Separate visibility changes and callback latches guarantee both real
|
||||
// destruction/creation orders, including video returning before the OSD.
|
||||
val orders = if (hardwareDecoding) listOf(surfaces, surfaces.reversed()) else listOf(surfaces)
|
||||
for (order in orders) {
|
||||
setProperty(instrumentation, core, "pause", "yes", cycle)
|
||||
awaitProperty(core, "pause", "public pause", cycle) { it == "yes" }
|
||||
for (surface in order) {
|
||||
changeSurfaceVisibility(instrumentation, surface, visible = false, cycle = cycle)
|
||||
assertEquals("Surface loss cleared public pause in cycle $cycle", "yes", core.getProperty("pause"))
|
||||
}
|
||||
for (surface in order) {
|
||||
changeSurfaceVisibility(instrumentation, surface, visible = true, cycle = cycle)
|
||||
assertEquals("Surface restoration cleared public pause in cycle $cycle", "yes", core.getProperty("pause"))
|
||||
}
|
||||
|
||||
val pausedPosition = awaitProperty(core, "time-pos", "paused playback position", cycle) {
|
||||
it?.toDoubleOrNull() != null
|
||||
}.toDouble()
|
||||
setProperty(instrumentation, core, "pause", "no", cycle)
|
||||
awaitProperty(core, "pause", "public resume", cycle) { it == "no" }
|
||||
var progressStart = pausedPosition
|
||||
awaitProperty(core, "time-pos", "playback progress after surface restoration", cycle) { value ->
|
||||
val position = value?.toDoubleOrNull()
|
||||
if (position == null) {
|
||||
false
|
||||
} else {
|
||||
// The fixture is two seconds long and loops. Start measuring again
|
||||
// across a loop boundary rather than mistaking a wrap for a stall.
|
||||
if (position < progressStart) progressStart = position
|
||||
position >= progressStart + 0.1
|
||||
}
|
||||
}
|
||||
assertVideoOutput(core, cycle, hardwareDecoding)
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeSurfaceVisibility(
|
||||
instrumentation: Instrumentation,
|
||||
surface: SurfaceView,
|
||||
visible: Boolean,
|
||||
cycle: Int
|
||||
) {
|
||||
val changed = CountDownLatch(1)
|
||||
val callback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
if (visible) changed.countDown()
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
if (!visible) changed.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
surface.holder.addCallback(callback)
|
||||
surface.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
assertCompletes(changed, if (visible) "real surface creation" else "real surface destruction", cycle)
|
||||
instrumentation.runOnMainSync {
|
||||
assertEquals("Unexpected surface validity in cycle $cycle", visible, surface.holder.surface.isValid)
|
||||
}
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { surface.holder.removeCallback(callback) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitProperty(
|
||||
core: MpvPlayerCore,
|
||||
name: String,
|
||||
operation: String,
|
||||
cycle: Int,
|
||||
matches: (String?) -> Boolean
|
||||
): String {
|
||||
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(OPERATION_TIMEOUT_SECONDS)
|
||||
var value: String?
|
||||
do {
|
||||
value = core.getProperty(name)
|
||||
if (matches(value)) return requireNotNull(value)
|
||||
SystemClock.sleep(20)
|
||||
} while (SystemClock.elapsedRealtime() < deadline)
|
||||
throw AssertionError("$operation timed out in cycle $cycle; $name=$value")
|
||||
}
|
||||
|
||||
private fun assertVideoOutput(core: MpvPlayerCore, cycle: Int, hardwareDecoding: Boolean) {
|
||||
if (hardwareDecoding) {
|
||||
assertEquals("mediacodec", core.getProperty("current-vo"))
|
||||
assertTrue(
|
||||
"Expected MediaCodec hardware decoding in cycle $cycle",
|
||||
core.getProperty("hwdec-current")?.startsWith("mediacodec") == true
|
||||
)
|
||||
} else {
|
||||
assertEquals("gpu", core.getProperty("current-vo"))
|
||||
assertEquals("no", core.getProperty("hwdec-current"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProperty(
|
||||
instrumentation: Instrumentation,
|
||||
core: MpvPlayerCore,
|
||||
name: String,
|
||||
value: String,
|
||||
cycle: Int
|
||||
) {
|
||||
val completed = CountDownLatch(1)
|
||||
val result = AtomicReference<Result<Unit>>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.setProperty(name, value) { outcome ->
|
||||
result.set(outcome)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, "$name property write", cycle)
|
||||
assertTrue("$name property write failed in cycle $cycle", result.get().isSuccess)
|
||||
}
|
||||
|
||||
private fun assertCompletes(
|
||||
latch: CountDownLatch,
|
||||
operation: String,
|
||||
cycle: Int,
|
||||
timeoutSeconds: Long = OPERATION_TIMEOUT_SECONDS
|
||||
) {
|
||||
assertTrue(
|
||||
"$operation timed out in cycle $cycle after ${timeoutSeconds}s",
|
||||
latch.await(timeoutSeconds, TimeUnit.SECONDS)
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyFixture(bytes: ByteArray, cacheDir: File): File = File.createTempFile("mpv-lifecycle-", ".mp4", cacheDir).apply { writeBytes(bytes) }
|
||||
|
||||
private class RecordingDelegate : PlayerDelegate {
|
||||
val fileLoaded = CountDownLatch(1)
|
||||
val playbackRestart = CountDownLatch(1)
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
when (name) {
|
||||
"file-loaded" -> fileLoaded.countDown()
|
||||
"playback-restart" -> playbackRestart.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CYCLE_COUNT = 8
|
||||
const val OPERATION_TIMEOUT_SECONDS = 10L
|
||||
const val DISPOSE_TIMEOUT_SECONDS = 15L
|
||||
const val MAIN_LOOP_TIMEOUT_SECONDS = 1L
|
||||
const val MAX_SYNCHRONOUS_DISPOSE_MS = 500L
|
||||
const val MAX_DISPOSE_LATENCY_MS = 2_000L
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Instrumentation
|
||||
import android.os.SystemClock
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvLogLevelDeviceTest {
|
||||
@Test
|
||||
fun normalLoggingPreservesErrorsAndCanToggleVerboseOffAgain() = withCore { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "normal", expectInfo = false)
|
||||
setLogLevel(instrumentation, core, "v")
|
||||
assertLogPhase(instrumentation, core, logs, "verbose", expectInfo = true)
|
||||
setLogLevel(instrumentation, core, "warn")
|
||||
assertLogPhase(instrumentation, core, logs, "normal-again", expectInfo = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verboseLoggingCanBeSelectedBeforeNativeInitialization() = withCore(initialLogLevel = "v") { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "initial-verbose", expectInfo = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedInitialLogLevelDoesNotBlockTheNextPlayer() {
|
||||
withCore(initialLogLevel = "not-a-level", initializationSucceeds = false) { _, _, _ -> }
|
||||
withCore { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "after-rejected-init", expectInfo = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun withCore(
|
||||
initialLogLevel: String = "warn",
|
||||
initializationSucceeds: Boolean = true,
|
||||
block: (Instrumentation, MpvPlayerCore, LinkedBlockingQueue<Pair<String, String>>) -> Unit
|
||||
) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val logs = LinkedBlockingQueue<Pair<String, String>>()
|
||||
val initialized = CountDownLatch(1)
|
||||
val success = AtomicReference<Boolean>()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.set(MpvPlayerCore(instrumentation.targetContext, audioOnly = true, initialLogLevel = initialLogLevel))
|
||||
core.get().delegate = object : PlayerDelegate {
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
if (name == "log-message") {
|
||||
logs.add((data?.get("level") as? String ?: "") to (data?.get("text") as? String ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
core.get().initialize {
|
||||
success.set(it)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertCompletes(initialized, "initialization")
|
||||
assertEquals("Native MPV initialization result", initializationSucceeds, success.get())
|
||||
block(instrumentation, core.get(), logs)
|
||||
} finally {
|
||||
val disposed = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
|
||||
assertCompletes(disposed, "teardown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLogLevel(instrumentation: Instrumentation, core: MpvPlayerCore, level: String) {
|
||||
val completed = CountDownLatch(1)
|
||||
val result = AtomicReference<Result<Unit>>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.setLogLevel(level) {
|
||||
result.set(it)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, "setLogLevel($level)")
|
||||
result.get().getOrThrow()
|
||||
}
|
||||
|
||||
private fun command(instrumentation: Instrumentation, core: MpvPlayerCore, vararg args: String) {
|
||||
val completed = CountDownLatch(1)
|
||||
val success = AtomicReference<Boolean>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.command(arrayOf(*args)) {
|
||||
success.set(it)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, args.first())
|
||||
assertTrue("MPV command failed: ${args.first()}", success.get())
|
||||
}
|
||||
|
||||
private fun assertLogPhase(
|
||||
instrumentation: Instrumentation,
|
||||
core: MpvPlayerCore,
|
||||
logs: LinkedBlockingQueue<Pair<String, String>>,
|
||||
phase: String,
|
||||
expectInfo: Boolean
|
||||
) {
|
||||
val token = "mpv-log-$phase-${UUID.randomUUID()}"
|
||||
val infoMarker = "$token-info"
|
||||
val errorMarker = "$token-missing"
|
||||
val missingFile = File(instrumentation.targetContext.cacheDir, errorMarker)
|
||||
command(instrumentation, core, "print-text", infoMarker)
|
||||
command(instrumentation, core, "loadfile", missingFile.absolutePath, "replace")
|
||||
|
||||
// The failed open is an error-level barrier in the same ordered log stream.
|
||||
// Seeing it proves the preceding informational message was either delivered
|
||||
// or filtered; no sleep is needed to assert that a quiet log stayed quiet.
|
||||
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)
|
||||
var sawInfo = false
|
||||
while (true) {
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
assertTrue("Missing native error log in $phase", remaining > 0)
|
||||
val log = logs.poll(remaining, TimeUnit.MILLISECONDS)
|
||||
assertTrue("Missing native error log in $phase", log != null)
|
||||
if (log!!.second.contains(infoMarker)) {
|
||||
assertEquals("info", log.first)
|
||||
sawInfo = true
|
||||
}
|
||||
if (log.first == "error" && log.second.contains(errorMarker)) break
|
||||
}
|
||||
assertEquals("Informational log visibility in $phase", expectInfo, sawInfo)
|
||||
}
|
||||
|
||||
private fun assertCompletes(latch: CountDownLatch, operation: String) {
|
||||
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 15L
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,10 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application>
|
||||
<activity
|
||||
android:name=".mpv.MpvLifecycleTestActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/NormalTheme" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class MpvLifecycleTestActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(FrameLayout(this))
|
||||
}
|
||||
}
|
||||
@@ -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,6 +31,7 @@ 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
|
||||
@@ -96,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
|
||||
@@ -593,6 +597,9 @@ class MainActivity : FlutterActivity() {
|
||||
carRestrictions?.release()
|
||||
carRestrictions = null
|
||||
carRestrictionsChannel = null
|
||||
assistiveTechnology?.release()
|
||||
assistiveTechnology = null
|
||||
assistiveTechnologyChannel = null
|
||||
activityStarted = false
|
||||
flutterSurfaceReconnectPending = false
|
||||
flutterTextureView = null
|
||||
@@ -781,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)
|
||||
}
|
||||
|
||||
@@ -163,10 +163,7 @@ internal fun mpvSpdifCodecs(
|
||||
@Suppress("DEPRECATION")
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
val audioAttributes = movieMedia3AudioAttributes()
|
||||
val capabilities = try {
|
||||
AudioCapabilities.getCapabilities(context, audioAttributes, null)
|
||||
} catch (error: Exception) {
|
||||
@@ -301,6 +298,31 @@ internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the route carries the IEC 61937 tuple *and* advertises DTS-HD. TrueHD rides the same
|
||||
* tuple, so carrying it says nothing about whether the receiver decodes DTS-HD.
|
||||
*/
|
||||
internal fun dtsHdCarrierUsable(
|
||||
supportsEncoding: (Int) -> Boolean,
|
||||
supportsCarrier: () -> Boolean
|
||||
): Boolean = supportsEncoding(C.ENCODING_DTS_HD) && supportsCarrier()
|
||||
|
||||
/** [dtsHdCarrierUsable] resolved against the audio route [context] is currently routed to. */
|
||||
internal fun supportsDtsHdIecCarrier(context: Context): Boolean = dtsHdCarrierUsable(
|
||||
// Encoding first: it skips the carrier tiering's several route calls.
|
||||
supportsEncoding = { encoding -> routeSupportsEncoding(context, encoding) },
|
||||
supportsCarrier = { supportsIecCarrier(context) }
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun routeSupportsEncoding(context: Context, encoding: Int): Boolean = try {
|
||||
AudioCapabilities.getCapabilities(context, movieMedia3AudioAttributes(), null).supportsEncoding(encoding)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; DTS-HD will not bitstream", error)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only
|
||||
* consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`)
|
||||
@@ -322,8 +344,7 @@ internal fun iecRouteSupported(
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
|
||||
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
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
|
||||
@@ -371,8 +392,7 @@ 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 =
|
||||
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
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)
|
||||
@@ -380,8 +400,9 @@ private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int):
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
|
||||
private fun movieMedia3AudioAttributes(): AudioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
.getPlatformAudioAttributes()
|
||||
|
||||
private fun movieAudioAttributes(): android.media.AudioAttributes = movieMedia3AudioAttributes().getPlatformAudioAttributes()
|
||||
|
||||
@@ -2445,7 +2445,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
* is false the stream decodes regardless of the passthrough setting.
|
||||
*/
|
||||
private fun routeCanBitstreamDts(mimeType: String): Boolean {
|
||||
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
|
||||
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsDtsHdIecCarrier(activity)) return true
|
||||
val audioAttributes = buildMovieAudioAttributes()
|
||||
return try {
|
||||
AudioCapabilities
|
||||
|
||||
@@ -45,8 +45,11 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
internal class IecCarrierSink(
|
||||
private val defaultSink: AudioSink,
|
||||
private val carrierSink: AudioSink,
|
||||
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
|
||||
private val carrierRouteAvailable: () -> Boolean,
|
||||
/**
|
||||
* Whether the current route can bitstream this format on the carrier. Per format: the tuple is
|
||||
* shared, but a route carrying it does not necessarily advertise every codec riding it.
|
||||
*/
|
||||
private val carrierRouteAvailable: (Format) -> Boolean,
|
||||
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
|
||||
private val directOutputBlocked: (Format) -> Boolean,
|
||||
private val log: ((String, String, String) -> Unit)? = null
|
||||
@@ -126,7 +129,7 @@ internal class IecCarrierSink(
|
||||
if (!isCarrierRateFamily(format.sampleRate)) return false
|
||||
if (playbackParameters.speed != 1f) return false
|
||||
if (directOutputBlocked(format)) return false
|
||||
return carrierRouteAvailable()
|
||||
return carrierRouteAvailable(format)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,14 +175,14 @@ internal class IecCarrierSink(
|
||||
override fun supportsFormat(format: Format): Boolean = when {
|
||||
shouldUseCarrier(format) -> true
|
||||
isTrueHd(format) -> false
|
||||
isDtsHd(format) && carrierRouteAvailable() -> false
|
||||
isDtsHd(format) && carrierRouteAvailable(format) -> false
|
||||
else -> defaultSink.supportsFormat(format)
|
||||
}
|
||||
|
||||
override fun getFormatSupport(format: Format): Int = when {
|
||||
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
|
||||
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
isDtsHd(format) && carrierRouteAvailable(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
else -> defaultSink.getFormatSupport(format)
|
||||
}
|
||||
|
||||
|
||||
@@ -207,12 +207,15 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
return IecCarrierSink(
|
||||
defaultSink = processedSink,
|
||||
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
||||
carrierRouteAvailable = { supportsIecCarrier(context) },
|
||||
carrierRouteAvailable = { format -> carrierRouteAvailableFor(context, format) },
|
||||
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
||||
log = audioDiagnosticsLogger
|
||||
).also { iecCarrierSink = it }
|
||||
}
|
||||
|
||||
/** TrueHD needs only the carrier tuple (#1804); DTS-HD also needs the route to advertise it. */
|
||||
private fun carrierRouteAvailableFor(context: Context, format: Format): Boolean = if (format.sampleMimeType == MimeTypes.AUDIO_DTS_HD) supportsDtsHdIecCarrier(context) else supportsIecCarrier(context)
|
||||
|
||||
/**
|
||||
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
|
||||
*
|
||||
|
||||
@@ -35,6 +35,83 @@ internal object GpuVoPolicy {
|
||||
*/
|
||||
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
|
||||
|
||||
/**
|
||||
* Select native software decoding before opening a decoder when hardware
|
||||
* cannot serve the stream. H.264 High 10 needs an advertised profile
|
||||
* (#2065); AV1 needs an actual hardware decoder (#2272), not a software
|
||||
* MediaCodec component whose surface/copy paths can fail on VO changes.
|
||||
* [codec] and [codecProfile] come from mpv's pending video track.
|
||||
*/
|
||||
fun needsSoftwareDecode(
|
||||
codec: String?,
|
||||
codecProfile: String?,
|
||||
hardwareHigh10: Boolean,
|
||||
hardwareAv1: Boolean
|
||||
): Boolean = when (codec) {
|
||||
"h264" -> !hardwareHigh10 && codecProfile?.startsWith("High 10") == true
|
||||
"av1" -> !hardwareAv1
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* The video track the per-file policies ([needsDvReshaping],
|
||||
* [needsSoftwareDecode]) decide for, or null when no video track will be
|
||||
* selected. They run inside on_preloaded, where the track list is complete
|
||||
* but nothing is selected yet, so "the selected track" does not exist:
|
||||
* `vid` still reads the option value, and mpv's own selection
|
||||
* (default/forced flags, --vlang, attached pictures skipped) only runs
|
||||
* after the hook. [vid] is the `vid` property: `no` and an explicit id are
|
||||
* authoritative on their own; `auto` defers to [pendingVid], the fork's
|
||||
* `pending-vid` property, which runs that selection ahead of time. A null
|
||||
* [pendingVid] means the libmpv has no such property, and the first track
|
||||
* in [videoTrackIds] (track-list order) is the best guess left — wrong for
|
||||
* files whose first video track is not the one mpv picks.
|
||||
*/
|
||||
fun pendingVideoTrackId(vid: String?, pendingVid: String?, videoTrackIds: List<Long>): Long? {
|
||||
val requested = when {
|
||||
vid == null || vid == "auto" -> pendingVid ?: return videoTrackIds.firstOrNull()
|
||||
else -> vid
|
||||
}
|
||||
val id = requested.toLongOrNull() ?: return null
|
||||
return id.takeIf { it in videoTrackIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a GL vo session should drop to the cheap render tier: bilinear
|
||||
* scalers and no dither. Keyed on `GL_EXT_texture_norm16` being absent,
|
||||
* which on Android singles out the low-end Mali/Adreno TV class whose
|
||||
* texture units cannot afford a second full-resolution pass at 1080p
|
||||
* (measured on an S905X4/Mali-G31: mpv's default lanczos chroma pass alone
|
||||
* runs the frame over budget, bilinear brings it back under; every
|
||||
* norm16-capable GPU tested renders the whole default ladder in a few
|
||||
* milliseconds). The video plane never scales in GL, so hardware sessions
|
||||
* on it are untouched.
|
||||
*/
|
||||
fun needsCheapRenderTier(glVoActive: Boolean, textureNorm16: Boolean): Boolean = glVoActive && !textureNorm16
|
||||
|
||||
/** mpv option -> value for the cheap render tier, applied only where the
|
||||
* option still carries its mpv default (a user's mpv.conf line wins). */
|
||||
val CHEAP_RENDER_OPTIONS: Map<String, String> = linkedMapOf(
|
||||
"scale" to "bilinear",
|
||||
"cscale" to "bilinear",
|
||||
"dscale" to "bilinear",
|
||||
"dither" to "no"
|
||||
)
|
||||
|
||||
/** The values mpv 0.41 reports for [CHEAP_RENDER_OPTIONS] when nothing
|
||||
* set them; anything else is a user choice and stays. `cscale` inherits
|
||||
* `scale` by default, which the property reads back as an empty string
|
||||
* (measured on 0.41) or `inherit`. */
|
||||
val MPV_DEFAULT_RENDER_OPTIONS: Map<String, Set<String>> = mapOf(
|
||||
"scale" to setOf("lanczos"),
|
||||
"cscale" to setOf("", "inherit"),
|
||||
"dscale" to setOf("hermite"),
|
||||
"dither" to setOf("fruit")
|
||||
)
|
||||
|
||||
/** Whether [value] is what mpv reports for [option] by default. */
|
||||
fun isDefaultRenderOption(option: String, value: String?): Boolean = value != null && MPV_DEFAULT_RENDER_OPTIONS[option]?.contains(value) == true
|
||||
|
||||
/**
|
||||
* The vo a session with these active requirements should run, or null for
|
||||
* the video plane.
|
||||
@@ -57,4 +134,5 @@ internal object GpuVoPolicy {
|
||||
const val REASON_CHAIN_FAILURE = "chain-failure"
|
||||
const val REASON_HDR_SDR = "hdr-sdr"
|
||||
const val REASON_SW_DECODE = "sw-decode"
|
||||
const val REASON_CODEC_SW_DECODE = "codec-sw-decode"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
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.MpvError
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
|
||||
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
|
||||
internal class MpvEndFileDiagnostics {
|
||||
private var errorMessage: String? = null
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The audio device stopped taking audio (or never could) and mpv gave up
|
||||
* on it. Keep in sync with PlayerError.audioOutputFailed in Dart: a device
|
||||
* fault, so no stream retry or backend switch can recover it.
|
||||
*/
|
||||
const val CAUSE_AUDIO_OUTPUT_FAILED = "audio-output-failed"
|
||||
}
|
||||
|
||||
fun onStartFile() {
|
||||
errorMessage = null
|
||||
}
|
||||
@@ -20,16 +30,16 @@ internal class MpvEndFileDiagnostics {
|
||||
}
|
||||
|
||||
fun onEndFile(event: MpvEvent.EndFile): Map<String, Any>? {
|
||||
val reason = event.reason
|
||||
if (reason == null) {
|
||||
errorMessage = null
|
||||
return null
|
||||
}
|
||||
val data = mutableMapOf<String, Any>("reason" to reason.id)
|
||||
if (reason == EndFileReason.Error) {
|
||||
errorMessage?.let { data["message"] = it }
|
||||
val data = mutableMapOf<String, Any>()
|
||||
event.sourceId?.let { data["sourceId"] = it }
|
||||
event.reason?.let { reason ->
|
||||
data["reason"] = reason.id
|
||||
if (reason == EndFileReason.Error) {
|
||||
errorMessage?.let { data["message"] = it }
|
||||
if (event.error == MpvError.AoInitFailed) data["cause"] = CAUSE_AUDIO_OUTPUT_FAILED
|
||||
}
|
||||
}
|
||||
errorMessage = null
|
||||
return data
|
||||
return data.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Surface
|
||||
import androidx.media3.common.util.EGLSurfaceTexture
|
||||
import kotlinx.coroutines.android.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* An offscreen consumer, not an undrained ImageReader queue. MediaCodec and GPU
|
||||
* output can both keep posting buffers while the real SurfaceViews are absent.
|
||||
* Media3's common EGL utility consumes each frame on its own GL thread, including
|
||||
* while the main thread is waiting for MPV to acknowledge a surface handoff.
|
||||
*/
|
||||
internal class MpvPlaceholderSurface private constructor(
|
||||
val surface: Surface,
|
||||
private val handler: Handler,
|
||||
private val texture: EGLSurfaceTexture
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
/** The caller must retain or close the result even if initialization is canceled. */
|
||||
suspend fun create(): MpvPlaceholderSurface {
|
||||
val thread = HandlerThread("MpvPlaceholder").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val texture = EGLSurfaceTexture(handler)
|
||||
return withContext(handler.asCoroutineDispatcher("MpvPlaceholder")) {
|
||||
try {
|
||||
texture.init(EGLSurfaceTexture.SECURE_MODE_NONE)
|
||||
MpvPlaceholderSurface(Surface(texture.surfaceTexture), handler, texture)
|
||||
} catch (error: Throwable) {
|
||||
try {
|
||||
texture.release()
|
||||
} finally {
|
||||
thread.quitSafely()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Call only after native consumers have retired; destruction stays on the GL thread. */
|
||||
override fun close() {
|
||||
handler.post {
|
||||
try {
|
||||
surface.release()
|
||||
texture.release()
|
||||
} finally {
|
||||
handler.looper.quitSafely()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,10 +69,10 @@ open class MpvPlayerPlugin(
|
||||
// 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.
|
||||
// How long a Dart `dispose` waits for native teardown before being
|
||||
// acknowledged. Native lifecycle operations remain serialized on background
|
||||
// workers after the watchdog fires, so a successor cannot overlap a stuck
|
||||
// decoder, exhaust codec instances, or block Android's main thread.
|
||||
private val disposeWatchdogMs = 6_000L
|
||||
|
||||
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
|
||||
@@ -206,6 +206,11 @@ open class MpvPlayerPlugin(
|
||||
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
|
||||
// from older callers; hardware decode is the setting's default.
|
||||
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
|
||||
// Subtitle "Render Resolution" for the vo=mediacodec OSD plane (Full / ¾ / ½ /
|
||||
// ⅓ / ¼ of the surface); the same fraction the ExoPlayer overlay applies.
|
||||
// Absent from older callers and the audio-only core; full is the default.
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1f
|
||||
val logLevel = call.argument<String>("logLevel") ?: "warn"
|
||||
// Video cores need the Activity (surface/view hierarchy); the audio-only
|
||||
// core is built on the application context so it can outlive it.
|
||||
val coreContext: Context? = if (audioOnly) applicationContext else activity
|
||||
@@ -264,7 +269,7 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding, subtitleRenderScale, logLevel).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
@@ -356,10 +361,10 @@ open class MpvPlayerPlugin(
|
||||
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.
|
||||
// A hung native teardown must not wedge the Dart-side release chain.
|
||||
// Native create/destroy remains serialized on background workers behind
|
||||
// that teardown, so a successor cannot accumulate another MediaCodec
|
||||
// instance while the old one still owns its resources.
|
||||
val completed = AtomicBoolean(false)
|
||||
fun completeOnce(reason: String) {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
@@ -469,25 +474,42 @@ open class MpvPlayerPlugin(
|
||||
result.error("NOT_INITIALIZED", "Player not initialized", null)
|
||||
return
|
||||
}
|
||||
core.command(args.toTypedArray()) { success ->
|
||||
if (success) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("COMMAND_FAILED", "mpv command failed", args)
|
||||
}
|
||||
// `loadfile` answers with the playlist entry it created so Dart can tie
|
||||
// the load to that source's start-file/playback-restart/end-file events;
|
||||
// every other command answers null.
|
||||
core.commandForSource(args.toTypedArray()) { outcome ->
|
||||
outcome.fold(
|
||||
onSuccess = { playlistEntryId ->
|
||||
result.success(playlistEntryId?.let { mapOf("playlistEntryId" to it) })
|
||||
},
|
||||
onFailure = { error ->
|
||||
result.error("COMMAND_FAILED", error.message ?: "mpv command failed", args)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetLogLevel(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.argument<String>("level") == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'level'", null)
|
||||
val level = call.argument<Any>("level") as? String
|
||||
if (level == null) {
|
||||
result.error("INVALID_ARGS", "Missing or invalid 'level'", null)
|
||||
return
|
||||
}
|
||||
result.error(
|
||||
"UNSUPPORTED",
|
||||
"Runtime mpv log level changes are not supported on Android",
|
||||
null
|
||||
)
|
||||
val core = playerCore
|
||||
if (core?.isInitialized != true) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return
|
||||
}
|
||||
core.setLogLevel(level) { outcome ->
|
||||
when (val failure = outcome.exceptionOrNull()) {
|
||||
null -> result.success(null)
|
||||
is CancellationException -> completeMpvPropertyNotInitialized(result)
|
||||
else -> {
|
||||
Log.w(tag, "MPV rejected log level change", failure)
|
||||
result.error("SET_LOG_LEVEL_FAILED", "MPV log level change was rejected", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
|
||||
@@ -608,8 +630,12 @@ open class MpvPlayerPlugin(
|
||||
// PlayerDelegate
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?) {
|
||||
onPropertyChange(name, value, null)
|
||||
}
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?, sourceId: Long?) {
|
||||
val propId = nameToId[name] ?: return
|
||||
channels.emitProperty(propId, value)
|
||||
channels.emitProperty(propId, value, sourceId)
|
||||
}
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Pure geometry for the `vo=mediacodec` subtitle/OSD plane.
|
||||
*
|
||||
* mpv rasterizes subtitles at whatever size the OSD Surface reports, so the
|
||||
* "Render Resolution" setting is applied by giving the OSD SurfaceView a
|
||||
* fixed buffer size below its view size; SurfaceFlinger scales the plane back
|
||||
* up. Every stage of the OSD pass (libass rasterization, the software
|
||||
* composite, the Surface copy) is proportional to that pixel count, which is
|
||||
* what makes the setting a throughput knob on render-bound TV hardware. The
|
||||
* ExoPlayer path applies the same fraction to its libass overlay.
|
||||
*/
|
||||
internal object OsdPlanePolicy {
|
||||
data class Size(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* Fixed buffer size for an OSD plane whose view measures [viewWidth] x
|
||||
* [viewHeight], or null when the plane should track the view (full
|
||||
* resolution, or a view that has not been laid out yet).
|
||||
*
|
||||
* Dimensions are rounded to even values: the 4:2:0-derived alignment
|
||||
* assumptions in the compositor stack are cheap to honor here and an odd
|
||||
* OSD width buys nothing.
|
||||
*/
|
||||
fun fixedSizeFor(viewWidth: Int, viewHeight: Int, renderScale: Float): Size? {
|
||||
if (viewWidth <= 0 || viewHeight <= 0) return null
|
||||
if (!(renderScale > 0f) || renderScale >= 1f) return null
|
||||
return Size(
|
||||
width = scaledEven(viewWidth, renderScale),
|
||||
height = scaledEven(viewHeight, renderScale)
|
||||
)
|
||||
}
|
||||
|
||||
private fun scaledEven(value: Int, scale: Float): Int {
|
||||
val scaled = (value * scale).roundToInt().coerceAtLeast(2)
|
||||
return scaled and 1.inv()
|
||||
}
|
||||
}
|
||||
@@ -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,104 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES20
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* GLES capabilities that decide render policy before mpv creates its own
|
||||
* context. Probed once per process on a throwaway pbuffer context; the
|
||||
* answers are hardware/driver properties and do not change at runtime.
|
||||
*/
|
||||
internal object GlCapabilities {
|
||||
private const val TAG = "GlCapabilities"
|
||||
private const val EGL_OPENGL_ES3_BIT_KHR = 0x0040
|
||||
|
||||
@Volatile private var textureNorm16: Boolean? = null
|
||||
|
||||
/**
|
||||
* Whether the GLES driver exposes `GL_EXT_texture_norm16`. Without it mpv
|
||||
* cannot upload >8-bit planes as filterable textures, and the GPUs that
|
||||
* lack it are the ones that cannot afford mpv's default scalers either
|
||||
* (see [com.edde746.plezy.mpv.GpuVoPolicy.needsCheapRenderTier]).
|
||||
*
|
||||
* A probe failure reports `true`: the default render path is the safe
|
||||
* answer for an unknown GPU, not the cheap tier.
|
||||
*/
|
||||
fun hasTextureNorm16(): Boolean {
|
||||
textureNorm16?.let { return it }
|
||||
synchronized(this) {
|
||||
textureNorm16?.let { return it }
|
||||
val probed = probe { extensions -> extensions.contains("GL_EXT_texture_norm16") } ?: true
|
||||
textureNorm16 = probed
|
||||
return probed
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: pretend the probe answered [value]. */
|
||||
internal fun overrideTextureNorm16ForTesting(value: Boolean?) {
|
||||
textureNorm16 = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [read] against the extension string of a fresh ES3 pbuffer context
|
||||
* and tears everything down again. Returns null when EGL refuses any step;
|
||||
* callers pick the safe default. Must not run on a thread that already
|
||||
* has an EGL context current — the temporary context replaces it.
|
||||
*/
|
||||
private fun probe(read: (String) -> Boolean): Boolean? {
|
||||
var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
var context: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
var surface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
try {
|
||||
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (display == EGL14.EGL_NO_DISPLAY) return null
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
|
||||
val configAttributes = intArrayOf(
|
||||
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_PBUFFER_BIT,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR,
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val count = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(display, configAttributes, 0, configs, 0, 1, count, 0) || count[0] < 1) return null
|
||||
val config = configs[0] ?: return null
|
||||
surface = EGL14.eglCreatePbufferSurface(
|
||||
display,
|
||||
config,
|
||||
intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE),
|
||||
0
|
||||
)
|
||||
if (surface == EGL14.EGL_NO_SURFACE) return null
|
||||
context = EGL14.eglCreateContext(
|
||||
display,
|
||||
config,
|
||||
EGL14.EGL_NO_CONTEXT,
|
||||
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE),
|
||||
0
|
||||
)
|
||||
if (context == EGL14.EGL_NO_CONTEXT) return null
|
||||
if (!EGL14.eglMakeCurrent(display, surface, surface, context)) return null
|
||||
val extensions = GLES20.glGetString(GLES20.GL_EXTENSIONS) ?: return null
|
||||
return read(extensions)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "GLES capability probe failed", e)
|
||||
return null
|
||||
} finally {
|
||||
if (display != EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
|
||||
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
|
||||
if (surface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, surface)
|
||||
// No eglTerminate: the default display is shared with the Flutter
|
||||
// engine and mpv; eglInitialize on an initialized display is a no-op.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,33 @@ internal object MediaCodecQuery {
|
||||
return mimeTypes
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a hardware `video/avc` decoder advertises H.264 High 10 (Hi10P).
|
||||
* Decoders that do not advertise it either refuse the stream (this is what
|
||||
* the mpv/FFmpeg MediaCodec path sees) or, on some SoCs, accept it and
|
||||
* render garbage; neither is a reason to let the hardware path try first
|
||||
* (#2065). Answered once per process: the codec list is static.
|
||||
*/
|
||||
fun hardwareAvcHigh10Support(): Boolean = hardwareAvcHigh10.value
|
||||
|
||||
private val hardwareAvcHigh10: Lazy<Boolean> = lazy {
|
||||
findHardwareDecoder("video/avc") { info, type ->
|
||||
val profiles = try {
|
||||
info.getCapabilitiesForType(type).profileLevels
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@findHardwareDecoder false
|
||||
}
|
||||
profiles.any { it.profile == MediaCodecInfo.CodecProfileLevel.AVCProfileHigh10 }
|
||||
} != null
|
||||
}
|
||||
|
||||
/** Software MediaCodec AV1 components are not a hardware decode path (#2272). */
|
||||
fun hardwareAv1Support(): Boolean = hardwareAv1.value
|
||||
|
||||
private val hardwareAv1: Lazy<Boolean> = lazy {
|
||||
findHardwareDecoder("video/av01") != null
|
||||
}
|
||||
|
||||
fun findHardwareDecoder(
|
||||
mimeType: String,
|
||||
codecKind: Int = MediaCodecList.REGULAR_CODECS,
|
||||
|
||||
@@ -62,6 +62,10 @@ internal class PlayerChannelBinding(
|
||||
runOnMain { eventSink?.success(listOf(id, value)) }
|
||||
}
|
||||
|
||||
fun emitProperty(id: Int, value: Any?, sourceId: Long?) {
|
||||
runOnMain { eventSink?.success(listOf(id, value, sourceId)) }
|
||||
}
|
||||
|
||||
fun emitEvent(name: String, data: Map<String, Any>? = null) {
|
||||
val event = mutableMapOf<String, Any>(
|
||||
"type" to "event",
|
||||
|
||||
@@ -2,5 +2,9 @@ package com.edde746.plezy.shared
|
||||
|
||||
interface PlayerDelegate {
|
||||
fun onPropertyChange(name: String, value: Any?)
|
||||
fun onPropertyChange(name: String, value: Any?, sourceId: Long?) {
|
||||
onPropertyChange(name, value)
|
||||
}
|
||||
|
||||
fun onEvent(name: String, data: Map<String, Any>?)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.edde746.plezy.mpv.OsdPlanePolicy
|
||||
|
||||
/** Shared Android view scaffold beneath the ExoPlayer and mpv cores. */
|
||||
internal object PlayerSurfaceHost {
|
||||
@@ -63,8 +64,13 @@ internal object PlayerSurfaceHost {
|
||||
* 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.
|
||||
*
|
||||
* [renderScale] < 1 gives the plane a fixed buffer size below its view size
|
||||
* (see [OsdPlanePolicy]); mpv then rasterizes at that size and the
|
||||
* compositor scales the plane to the view. Re-applied on every layout so a
|
||||
* container resize keeps the ratio.
|
||||
*/
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback, renderScale: Float = 1f): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
@@ -74,6 +80,13 @@ internal object PlayerSurfaceHost {
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(true)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -1)
|
||||
if (renderScale < 1f) {
|
||||
addOnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (right - left == oldRight - oldLeft && bottom - top == oldBottom - oldTop) return@addOnLayoutChangeListener
|
||||
val size = OsdPlanePolicy.fixedSizeFor(right - left, bottom - top, renderScale) ?: return@addOnLayoutChangeListener
|
||||
(view as SurfaceView).holder.setFixedSize(size.width, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
|
||||
|
||||
@@ -13,3 +13,17 @@ 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)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
add_executable(mpv_lifecycle_test mpv_lifecycle_test.cpp)
|
||||
target_compile_features(mpv_lifecycle_test PRIVATE cxx_std_17)
|
||||
target_include_directories(mpv_lifecycle_test PRIVATE fakes ../../../../libmpv/src/main/cpp/include)
|
||||
target_link_libraries(mpv_lifecycle_test PRIVATE Threads::Threads)
|
||||
|
||||
add_test(NAME mpv_lifecycle_test COMMAND mpv_lifecycle_test)
|
||||
set_tests_properties(mpv_lifecycle_test PROPERTIES TIMEOUT 30)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#define ANDROID_LOG_INFO 4
|
||||
#define ANDROID_LOG_WARN 5
|
||||
#define ANDROID_LOG_ERROR 6
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -14,6 +15,10 @@ using jboolean = uint8_t;
|
||||
using jbyte = int8_t;
|
||||
using jint = int32_t;
|
||||
using jsize = jint;
|
||||
using jlong = int64_t;
|
||||
using jdouble = double;
|
||||
using jobject = void*;
|
||||
using jmethodID = void*;
|
||||
|
||||
struct _jclass {};
|
||||
using jclass = _jclass*;
|
||||
@@ -28,11 +33,53 @@ struct _jstring {
|
||||
};
|
||||
using jstring = _jstring*;
|
||||
|
||||
struct _jobjectArray {
|
||||
std::vector<jobject> objects;
|
||||
};
|
||||
using jobjectArray = _jobjectArray*;
|
||||
|
||||
class JavaVM {
|
||||
public:
|
||||
void (*on_detach)() = nullptr;
|
||||
|
||||
jint DetachCurrentThread() {
|
||||
if (on_detach) on_detach();
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
class JNIEnv {
|
||||
public:
|
||||
bool exception_pending = false;
|
||||
bool fail_next_write = false;
|
||||
|
||||
JavaVM* vm = nullptr;
|
||||
jobject (*on_new_global_ref)(jobject) = nullptr;
|
||||
void (*on_delete_global_ref)(jobject) = nullptr;
|
||||
void (*on_static_void_method)(jmethodID, va_list) = nullptr;
|
||||
|
||||
jint GetJavaVM(JavaVM** result) {
|
||||
*result = vm;
|
||||
return 0;
|
||||
}
|
||||
|
||||
jobject NewGlobalRef(jobject object) { return on_new_global_ref ? on_new_global_ref(object) : object; }
|
||||
|
||||
void DeleteGlobalRef(jobject object) {
|
||||
if (on_delete_global_ref) on_delete_global_ref(object);
|
||||
}
|
||||
|
||||
void DeleteLocalRef(jobject) {}
|
||||
|
||||
void CallStaticVoidMethod(jclass, jmethodID method, ...) {
|
||||
va_list args;
|
||||
va_start(args, method);
|
||||
if (on_static_void_method) on_static_void_method(method, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
jsize GetArrayLength(jobjectArray array) { return static_cast<jsize>(array->objects.size()); }
|
||||
jobject GetObjectArrayElement(jobjectArray array, jsize index) { return array->objects.at(index); }
|
||||
jsize GetArrayLength(jbyteArray array) { return static_cast<jsize>(array->bytes.size()); }
|
||||
|
||||
void GetByteArrayRegion(jbyteArray array, jsize offset, jsize length, jbyte* destination) {
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
#include <pthread.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
static int tracked_mutex_lock(pthread_mutex_t* mutex);
|
||||
static int tracked_write_lock(pthread_rwlock_t* lock);
|
||||
static int controlled_thread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*entry)(void*), void* arg);
|
||||
|
||||
// Compile the real JNI entries, admission guards, event loop and surface
|
||||
// cleanup. Only external dependencies are controlled; no copy of the lock
|
||||
// algorithm or direct assignment to production lifecycle globals is used.
|
||||
#define UTIL_EXTERN
|
||||
#define pthread_mutex_lock tracked_mutex_lock
|
||||
#define pthread_rwlock_wrlock tracked_write_lock
|
||||
#define pthread_create controlled_thread_create
|
||||
// Android's two-argument thread naming API is not available on macOS.
|
||||
#define pthread_setname_np(thread, name) ((void)0)
|
||||
#include "../../../../libmpv/src/main/cpp/main.cpp"
|
||||
#undef pthread_setname_np
|
||||
#undef pthread_create
|
||||
#undef pthread_rwlock_wrlock
|
||||
#undef pthread_mutex_lock
|
||||
#include "../../../../libmpv/src/main/cpp/event.cpp"
|
||||
#define pthread_mutex_lock tracked_mutex_lock
|
||||
#include "../../../../libmpv/src/main/cpp/render.cpp"
|
||||
#undef pthread_mutex_lock
|
||||
|
||||
struct mpv_handle {
|
||||
bool initialized = false;
|
||||
bool terminated = false;
|
||||
bool event_started = false;
|
||||
bool event_exited = false;
|
||||
bool woken = false;
|
||||
bool hook_pending = false;
|
||||
bool reject_hook_during_destroy = false;
|
||||
bool callback_entered = false;
|
||||
bool callback_returned = false;
|
||||
bool command_active = false;
|
||||
bool termination_entered = false;
|
||||
int initialize_result = 0;
|
||||
int hook_continuations = 0;
|
||||
int commands = 0;
|
||||
jlong callback_session = 0;
|
||||
jobject video = nullptr;
|
||||
jobject osd = nullptr;
|
||||
jobject osd_option = nullptr;
|
||||
int rebuilds = 0;
|
||||
mpv_event_hook hook{"on_preloaded", 17};
|
||||
mpv_event event{};
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex gate;
|
||||
std::condition_variable changed;
|
||||
JavaVM vm;
|
||||
JNIEnv jni;
|
||||
int app_context;
|
||||
mpv_handle* active_handle = nullptr;
|
||||
// Retain fake allocations after termination so any erroneous late MPV access
|
||||
// fails explicitly rather than depending on allocator reuse or undefined UAF.
|
||||
std::vector<std::unique_ptr<mpv_handle>> handles;
|
||||
std::vector<std::unique_ptr<_jstring>> callback_strings;
|
||||
// Each NewGlobalRef is a distinct handle, even for the same Java Surface.
|
||||
std::vector<std::unique_ptr<jobject>> reference_handles;
|
||||
std::map<jobject, jobject> global_refs;
|
||||
int fail_ref_after = 0;
|
||||
std::vector<std::string> option_failures;
|
||||
bool consume_osd_before_failure = false;
|
||||
bool hold_wid = false;
|
||||
bool wid_entered = false;
|
||||
bool allow_wid = false;
|
||||
bool surface_waiting = false;
|
||||
mpv_handle* held_termination = nullptr;
|
||||
bool allow_termination = false;
|
||||
bool allow_command = false;
|
||||
bool successor_waiting = false;
|
||||
bool reader_draining = false;
|
||||
bool fail_thread_create = false;
|
||||
thread_local mpv_handle* event_handle = nullptr;
|
||||
enum class Operation { ordinary, successor, replacing_reader, surface_handoff };
|
||||
thread_local Operation operation = Operation::ordinary;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (condition) return;
|
||||
std::fprintf(stderr, "%s\n", message);
|
||||
std::abort();
|
||||
}
|
||||
|
||||
template <typename Predicate>
|
||||
void await(std::unique_lock<std::mutex>& lock, Predicate predicate, const char* message) {
|
||||
require(changed.wait_for(lock, std::chrono::seconds(5), predicate), message);
|
||||
}
|
||||
|
||||
void require_live(mpv_handle* handle) {
|
||||
require(handle && handle == active_handle && !handle->terminated, "MPV accessed outside its native lifetime");
|
||||
}
|
||||
|
||||
void require_surfaces(mpv_handle* handle) {
|
||||
for (jobject ref : {handle->video, handle->osd, handle->osd_option}) {
|
||||
if (ref) require(global_refs.count(ref) == 1, "Surface option/consumer retained a deleted JNI reference");
|
||||
}
|
||||
}
|
||||
|
||||
jobject new_global_ref(jobject object) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
if (!object) return nullptr;
|
||||
if (fail_ref_after > 0 && --fail_ref_after == 0) return nullptr;
|
||||
reference_handles.push_back(std::make_unique<jobject>(object));
|
||||
jobject ref = reference_handles.back().get();
|
||||
global_refs.emplace(ref, object);
|
||||
return ref;
|
||||
}
|
||||
|
||||
void delete_global_ref(jobject object) {
|
||||
if (!object) return;
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(global_refs.count(object) == 1, "JNI global reference released more than once");
|
||||
if (active_handle &&
|
||||
(active_handle->video == object || active_handle->osd == object || active_handle->osd_option == object)) {
|
||||
require(active_handle->terminated, "Surface released while still referenced by MPV");
|
||||
}
|
||||
global_refs.erase(object);
|
||||
}
|
||||
|
||||
int live_surface_refs() {
|
||||
int count = 0;
|
||||
for (const auto& entry : global_refs) {
|
||||
if (entry.second != &app_context) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void require_pair(mpv_handle* handle, jobject video, jobject osd) {
|
||||
require_surfaces(handle);
|
||||
require(global_refs.at(handle->video) == video, "VO retained the wrong video Surface");
|
||||
require((handle->osd ? global_refs.at(handle->osd) : nullptr) == osd, "VO retained the wrong OSD Surface");
|
||||
require(handle->osd_option == handle->osd, "OSD option and active consumer disagree");
|
||||
}
|
||||
|
||||
void detach_event_thread() {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(event_handle);
|
||||
event_handle->event_exited = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
|
||||
void on_static_void_method(jmethodID method, va_list args) {
|
||||
require(method == mpv_MpvPlayer_onHook, "unexpected callback in lifecycle scenario");
|
||||
const jlong session = va_arg(args, jlong);
|
||||
const jstring name = va_arg(args, jstring);
|
||||
const jlong hook = va_arg(args, jlong);
|
||||
require(name->value == "on_preloaded", "wrong hook callback delivered");
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(event_handle);
|
||||
event_handle->callback_session = session;
|
||||
event_handle->callback_entered = true;
|
||||
changed.notify_all();
|
||||
if (event_handle->reject_hook_during_destroy) {
|
||||
// The callback is already inside Java when teardown starts. Kotlin's
|
||||
// rejected-channel path continues synchronously on this event thread.
|
||||
await(lock, [] { return event_handle->woken; }, "destroy never woke the overlapping hook callback");
|
||||
}
|
||||
}
|
||||
jni_func_name(nativeHookContinue)(&jni, nullptr, session, hook);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
event_handle->callback_returned = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
jlong create_player() {
|
||||
const jlong session = jni_func_name(nativeCreate)(&jni, nullptr, &app_context);
|
||||
require(session > 0, "nativeCreate failed");
|
||||
return session;
|
||||
}
|
||||
|
||||
jlong command(jlong session, const char* name = "play") {
|
||||
_jstring argument{name};
|
||||
_jobjectArray arguments{{&argument}};
|
||||
return jni_func_name(nativeCommand)(&jni, nullptr, session, &arguments);
|
||||
}
|
||||
|
||||
void initialize_player(jlong session) {
|
||||
require(jni_func_name(nativeInit)(&jni, nullptr, session) == 0, "nativeInit failed");
|
||||
}
|
||||
|
||||
void destroy_player(jlong session) { jni_func_name(nativeDestroy)(&jni, nullptr, session); }
|
||||
|
||||
jint attach_surfaces(jlong session, jobject video, jobject osd) {
|
||||
return jni_func_name(nativeAttachSurfaces)(&jni, nullptr, session, video, osd);
|
||||
}
|
||||
|
||||
void reset_dependencies() {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(active_handle == nullptr, "previous test left a live MPV instance");
|
||||
require(live_surface_refs() == 0, "terminal Surface cleanup leaked a global reference");
|
||||
handles.clear();
|
||||
callback_strings.clear();
|
||||
global_refs.clear();
|
||||
reference_handles.clear();
|
||||
fail_ref_after = 0;
|
||||
option_failures.clear();
|
||||
consume_osd_before_failure = false;
|
||||
hold_wid = false;
|
||||
wid_entered = false;
|
||||
allow_wid = false;
|
||||
surface_waiting = false;
|
||||
held_termination = nullptr;
|
||||
allow_termination = false;
|
||||
allow_command = false;
|
||||
successor_waiting = false;
|
||||
reader_draining = false;
|
||||
fail_thread_create = false;
|
||||
jni.exception_pending = false;
|
||||
}
|
||||
|
||||
void rejected_hook_and_successor_retirement() {
|
||||
reset_dependencies();
|
||||
const jlong old_session = create_player();
|
||||
mpv_handle* old = active_handle;
|
||||
int video, osd;
|
||||
require(attach_surfaces(old_session, &video, &osd) == 0, "initial surface handoff failed");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
old->hook_pending = true;
|
||||
old->reject_hook_during_destroy = true;
|
||||
held_termination = old;
|
||||
}
|
||||
initialize_player(old_session);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return old->callback_entered; }, "event loop did not deliver the hook");
|
||||
}
|
||||
std::thread retiring([&] { destroy_player(old_session); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return old->termination_entered; }, "retirement deadlocked with the synchronous hook callback");
|
||||
require(old->callback_returned && old->event_exited, "termination preceded event-thread completion");
|
||||
require(old->callback_session == old_session, "retiring callback lost its bound session");
|
||||
require(old->hook_continuations == 0, "revoked hook reached the retiring MPV handle");
|
||||
require_surfaces(old);
|
||||
}
|
||||
|
||||
jlong successor_session = 0;
|
||||
std::thread creating([&] {
|
||||
operation = Operation::successor;
|
||||
successor_session = create_player();
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return successor_waiting; }, "successor did not wait for terminal retirement");
|
||||
}
|
||||
// S must be available while termination holds L. These calls must reject
|
||||
// without touching the retiring core, surfaces, or a future successor.
|
||||
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "revoked command was admitted during termination");
|
||||
jni_func_name(nativeHookContinue)(&jni, nullptr, old_session, old->hook.id);
|
||||
require(
|
||||
attach_surfaces(old_session, nullptr, nullptr) == MPV_ERROR_UNINITIALIZED,
|
||||
"revoked surface handoff was admitted during termination");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_surfaces(old);
|
||||
allow_termination = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
retiring.join();
|
||||
creating.join();
|
||||
require(successor_session > old_session, "successor did not receive a new session");
|
||||
mpv_handle* successor = active_handle;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
successor->hook_pending = true;
|
||||
}
|
||||
require(attach_surfaces(successor_session, &video, &osd) == 0, "successor surface handoff failed");
|
||||
initialize_player(successor_session);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return successor->callback_returned; }, "successor event loop did not serve its own hook");
|
||||
require(successor->callback_session == successor_session, "successor inherited the old event binding");
|
||||
require(successor->hook_continuations == 1, "live hook was not continued exactly once");
|
||||
}
|
||||
destroy_player(old_session);
|
||||
require(
|
||||
jni_func_name(nativeInit)(&jni, nullptr, old_session) == MPV_ERROR_UNINITIALIZED, "stale init reached successor");
|
||||
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "stale command reached successor");
|
||||
jni_func_name(nativeHookContinue)(&jni, nullptr, old_session, old->hook.id);
|
||||
require(
|
||||
attach_surfaces(old_session, nullptr, nullptr) == MPV_ERROR_UNINITIALIZED,
|
||||
"stale surface handoff reached successor");
|
||||
require(command(successor_session) == 0, "stale teardown retired the successor");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_surfaces(successor);
|
||||
require(successor->hook_continuations == 1, "old hook id was forwarded to successor");
|
||||
}
|
||||
destroy_player(successor_session);
|
||||
}
|
||||
|
||||
void admitted_command_survives_replacement() {
|
||||
reset_dependencies();
|
||||
const jlong old_session = create_player();
|
||||
mpv_handle* old = active_handle;
|
||||
initialize_player(old_session);
|
||||
jlong result = MPV_ERROR_GENERIC;
|
||||
std::thread reader([&] { result = command(old_session, "hold"); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return old->command_active; }, "command was not admitted");
|
||||
}
|
||||
jlong successor_session = 0;
|
||||
std::thread replacing([&] {
|
||||
operation = Operation::replacing_reader;
|
||||
successor_session = create_player();
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return reader_draining; }, "replacement did not drain the admitted command");
|
||||
require(!old->termination_entered && !old->woken, "retirement overtook an admitted command");
|
||||
allow_command = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
reader.join();
|
||||
replacing.join();
|
||||
require(result == 0 && old->commands == 1, "admitted command lost its handle before returning");
|
||||
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "late old-session command was admitted");
|
||||
destroy_player(old_session);
|
||||
initialize_player(successor_session);
|
||||
require(command(successor_session) == 0, "replacement was affected by stale owner traffic");
|
||||
destroy_player(successor_session);
|
||||
}
|
||||
|
||||
void partial_initialization_can_retire() {
|
||||
enum class Failure { configuration, initialization, thread_start };
|
||||
for (Failure failure : {Failure::configuration, Failure::initialization, Failure::thread_start}) {
|
||||
reset_dependencies();
|
||||
const jlong session = create_player();
|
||||
mpv_handle* failed = active_handle;
|
||||
if (failure == Failure::configuration) {
|
||||
_jstring invalid{"not-a-level"};
|
||||
require(
|
||||
jni_func_name(nativeSetLogLevel)(&jni, nullptr, session, &invalid) < 0, "invalid configuration succeeded");
|
||||
} else {
|
||||
failed->initialize_result = failure == Failure::initialization ? MPV_ERROR_GENERIC : 0;
|
||||
fail_thread_create = failure == Failure::thread_start;
|
||||
require(jni_func_name(nativeInit)(&jni, nullptr, session) < 0, "injected initialization failure was lost");
|
||||
if (failure == Failure::thread_start)
|
||||
require(jni.ExceptionCheck(), "thread-start failure lost its JNI exception");
|
||||
}
|
||||
destroy_player(session);
|
||||
require(
|
||||
failed->terminated && !failed->event_started && !failed->woken, "partial init used an unstarted event thread");
|
||||
require(command(session) == MPV_ERROR_UNINITIALIZED, "failed session retained public admission");
|
||||
jni.exception_pending = false;
|
||||
const jlong successor = create_player();
|
||||
initialize_player(successor);
|
||||
require(command(successor) == 0, "partial init prevented the next player from working");
|
||||
destroy_player(successor);
|
||||
}
|
||||
}
|
||||
|
||||
void paired_surface_replacements() {
|
||||
reset_dependencies();
|
||||
const jlong session = create_player();
|
||||
initialize_player(session);
|
||||
mpv_handle* handle = active_handle;
|
||||
int video_a, video_b, osd_a, osd_b;
|
||||
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
|
||||
require_pair(handle, &video_a, &osd_a);
|
||||
require(attach_surfaces(session, &video_b, &osd_b) == 0, "paired replacement failed");
|
||||
require_pair(handle, &video_b, &osd_b);
|
||||
for (jobject next_osd : {static_cast<jobject>(&osd_a), static_cast<jobject>(nullptr), static_cast<jobject>(&osd_b)}) {
|
||||
const int rebuilds = handle->rebuilds;
|
||||
require(attach_surfaces(session, &video_b, next_osd) == 0, "OSD-only replacement failed");
|
||||
require_pair(handle, &video_b, next_osd);
|
||||
require(handle->rebuilds == rebuilds + 1, "OSD-only change did not rebuild the VO");
|
||||
require(live_surface_refs() == (next_osd ? 2 : 1), "replacement leaked overwritten Surface references");
|
||||
}
|
||||
destroy_player(session);
|
||||
}
|
||||
|
||||
void surface_handoff_failures() {
|
||||
reset_dependencies();
|
||||
const jlong session = create_player();
|
||||
initialize_player(session);
|
||||
mpv_handle* handle = active_handle;
|
||||
int video_a, video_b, osd_a, osd_b;
|
||||
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
|
||||
require(
|
||||
attach_surfaces(session, nullptr, &osd_b) == MPV_ERROR_INVALID_PARAMETER,
|
||||
"live session admitted a null video Surface");
|
||||
for (int allocation : {1, 2}) {
|
||||
fail_ref_after = allocation;
|
||||
require(
|
||||
attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_NOMEM, "global-reference allocation failure was lost");
|
||||
require_pair(handle, &video_a, &osd_a);
|
||||
require(live_surface_refs() == 2, "allocation failure leaked a staged Surface");
|
||||
}
|
||||
option_failures = {"vo-mediacodec-osd-surface"};
|
||||
require(attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_GENERIC, "OSD option failure was lost");
|
||||
require_pair(handle, &video_a, &osd_a);
|
||||
require(live_surface_refs() == 2, "rejected OSD option leaked staged references");
|
||||
|
||||
option_failures = {"wid"};
|
||||
require(attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_GENERIC, "wid option failure was lost");
|
||||
require_pair(handle, &video_a, &osd_a);
|
||||
require(attach_surfaces(session, &video_b, &osd_b) == 0, "handoff did not recover from wid failure");
|
||||
require_pair(handle, &video_b, &osd_b);
|
||||
require(live_surface_refs() == 2, "successful replacement retained failed handoff references");
|
||||
|
||||
// A VO may start between the option writes. Rollback changes only the OSD
|
||||
// option, so the staged reference must survive even when rollback succeeds.
|
||||
consume_osd_before_failure = true;
|
||||
for (bool fail_rollback : {false, true}) {
|
||||
option_failures = {"wid"};
|
||||
if (fail_rollback) option_failures.push_back("vo-mediacodec-osd-surface");
|
||||
require(attach_surfaces(session, &video_a, &osd_a) == MPV_ERROR_GENERIC, "failed handoff reported success");
|
||||
require(option_failures.empty(), "OSD option rollback was not attempted");
|
||||
require_surfaces(handle);
|
||||
}
|
||||
require(attach_surfaces(session, &video_a, nullptr) == 0, "handoff did not recover from rollback failure");
|
||||
require_pair(handle, &video_a, nullptr);
|
||||
require(live_surface_refs() == 1, "successful null-OSD replacement leaked failed handoff references");
|
||||
|
||||
option_failures = {"wid", "vo-mediacodec-osd-surface"};
|
||||
require(attach_surfaces(session, &video_b, &osd_b) < 0, "terminal failed handoff reported success");
|
||||
destroy_player(session);
|
||||
require(live_surface_refs() == 0, "termination leaked pending failed-handoff references");
|
||||
}
|
||||
|
||||
void overlapping_handoffs_and_teardown() {
|
||||
reset_dependencies();
|
||||
const jlong session = create_player();
|
||||
initialize_player(session);
|
||||
mpv_handle* handle = active_handle;
|
||||
int video_a, video_b, video_c, osd_a, osd_b, osd_c;
|
||||
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
|
||||
hold_wid = true;
|
||||
jint first_result = MPV_ERROR_GENERIC, second_result = MPV_ERROR_GENERIC;
|
||||
std::thread first([&] { first_result = attach_surfaces(session, &video_b, &osd_b); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return wid_entered; }, "first handoff did not enter synchronous VO rebuild");
|
||||
require_surfaces(handle);
|
||||
require(live_surface_refs() == 4, "old Surface references did not survive the wid rebuild");
|
||||
}
|
||||
std::thread second([&] {
|
||||
operation = Operation::surface_handoff;
|
||||
second_result = attach_surfaces(session, &video_c, &osd_c);
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return surface_waiting; }, "overlapping handoff bypassed surface serialization");
|
||||
require(live_surface_refs() == 4, "waiting handoff mutated active Surface references");
|
||||
}
|
||||
std::thread retiring([&] {
|
||||
operation = Operation::replacing_reader;
|
||||
destroy_player(session);
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return reader_draining; }, "teardown did not drain admitted surface handoffs");
|
||||
require(!handle->termination_entered, "teardown overtook an admitted surface handoff");
|
||||
allow_wid = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
first.join();
|
||||
second.join();
|
||||
retiring.join();
|
||||
require(first_result == 0 && second_result == 0, "admitted handoff lost its session during teardown");
|
||||
require(handle->rebuilds == 3, "overlapping handoffs lost a complete VO rebuild");
|
||||
require(live_surface_refs() == 0, "overlapping replacement/teardown leaked Surface references");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Observe real contention to arrange overlap without sleeps or a guessed
|
||||
// scheduling delay. The production guards still acquire the real pthread
|
||||
// locks; the notification only lets the test release its external MPV gates.
|
||||
static int tracked_mutex_lock(pthread_mutex_t* mutex) {
|
||||
if (operation != Operation::successor && operation != Operation::surface_handoff) return pthread_mutex_lock(mutex);
|
||||
const int result = pthread_mutex_trylock(mutex);
|
||||
if (result != EBUSY) return result;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
if (operation == Operation::successor) successor_waiting = true;
|
||||
if (operation == Operation::surface_handoff) surface_waiting = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
return pthread_mutex_lock(mutex);
|
||||
}
|
||||
|
||||
static int tracked_write_lock(pthread_rwlock_t* lock) {
|
||||
if (operation != Operation::replacing_reader) return pthread_rwlock_wrlock(lock);
|
||||
const int result = pthread_rwlock_trywrlock(lock);
|
||||
if (result != EBUSY) return result;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(gate);
|
||||
reader_draining = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
return pthread_rwlock_wrlock(lock);
|
||||
}
|
||||
|
||||
static int controlled_thread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*entry)(void*), void* arg) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
if (fail_thread_create) {
|
||||
fail_thread_create = false;
|
||||
return EAGAIN;
|
||||
}
|
||||
const int result = pthread_create(thread, attr, entry, arg);
|
||||
if (result == 0) active_handle->event_started = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
extern "C" mpv_handle* mpv_create() {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(active_handle == nullptr, "successor MPV creation overlapped predecessor termination");
|
||||
require(live_surface_refs() == 0, "successor creation preceded retiring Surface cleanup");
|
||||
handles.push_back(std::make_unique<mpv_handle>());
|
||||
active_handle = handles.back().get();
|
||||
return active_handle;
|
||||
}
|
||||
|
||||
extern "C" int mpv_initialize(mpv_handle* handle) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
if (handle->initialize_result < 0) return handle->initialize_result;
|
||||
handle->initialized = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" void mpv_wakeup(mpv_handle* handle) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(!handle->command_active, "wake overtook an admitted command");
|
||||
require(handle->event_started, "wake targeted an unstarted event thread");
|
||||
handle->woken = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
|
||||
extern "C" void mpv_terminate_destroy(mpv_handle* handle) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(!handle->command_active, "termination overtook an admitted command");
|
||||
require(!handle->event_started || handle->event_exited, "termination overtook the bound event thread");
|
||||
require_surfaces(handle);
|
||||
handle->termination_entered = true;
|
||||
changed.notify_all();
|
||||
if (handle == held_termination)
|
||||
await(lock, [] { return allow_termination; }, "test did not release native termination");
|
||||
require_surfaces(handle);
|
||||
handle->terminated = true;
|
||||
active_handle = nullptr;
|
||||
}
|
||||
|
||||
extern "C" mpv_event* mpv_wait_event(mpv_handle* handle, double) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
event_handle = handle;
|
||||
require_live(handle);
|
||||
await(lock, [&] { return handle->hook_pending || handle->woken; }, "event loop was not woken for teardown");
|
||||
handle->event = {};
|
||||
if (handle->hook_pending) {
|
||||
handle->hook_pending = false;
|
||||
handle->event.event_id = MPV_EVENT_HOOK;
|
||||
handle->event.data = &handle->hook;
|
||||
}
|
||||
return &handle->event;
|
||||
}
|
||||
|
||||
extern "C" int mpv_hook_add(mpv_handle* handle, uint64_t, const char*, int) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_hook_continue(mpv_handle* handle, uint64_t id) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(id == handle->hook.id && handle->hook_continuations == 0, "hook continued twice or on the wrong handle");
|
||||
++handle->hook_continuations;
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_command_ret(mpv_handle* handle, const char** args, mpv_node* result) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(handle->initialized, "command reached an uninitialized core");
|
||||
handle->command_active = true;
|
||||
changed.notify_all();
|
||||
if (std::strcmp(args[0], "hold") == 0)
|
||||
await(lock, [] { return allow_command; }, "test did not release admitted command");
|
||||
require_live(handle);
|
||||
handle->command_active = false;
|
||||
++handle->commands;
|
||||
*result = {};
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_request_log_messages(mpv_handle* handle, const char* level) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
return std::strcmp(level, "not-a-level") == 0 ? MPV_ERROR_INVALID_PARAMETER : 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_set_option(mpv_handle* handle, const char* name, mpv_format format, void* data) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require_surfaces(handle);
|
||||
require(format == MPV_FORMAT_INT64, "unexpected Surface option format");
|
||||
jobject object = reinterpret_cast<jobject>(static_cast<intptr_t>(*static_cast<int64_t*>(data)));
|
||||
require(!object || global_refs.count(object) == 1, "option received an invalid JNI reference");
|
||||
const bool is_wid = std::strcmp(name, "wid") == 0;
|
||||
if (is_wid && hold_wid) {
|
||||
wid_entered = true;
|
||||
changed.notify_all();
|
||||
await(lock, [] { return allow_wid; }, "test did not release the synchronous wid rebuild");
|
||||
require_surfaces(handle);
|
||||
}
|
||||
if (!option_failures.empty() && option_failures.front() == name) {
|
||||
option_failures.erase(option_failures.begin());
|
||||
if (is_wid && consume_osd_before_failure) handle->osd = handle->osd_option;
|
||||
return MPV_ERROR_GENERIC;
|
||||
}
|
||||
if (is_wid) {
|
||||
// mpv ignores equal option values; changing wid synchronously retires the
|
||||
// consumers and rebuilds using the OSD option, not the former OSD plane.
|
||||
if (handle->video != object) {
|
||||
handle->video = object;
|
||||
handle->osd = handle->osd_option;
|
||||
++handle->rebuilds;
|
||||
}
|
||||
} else {
|
||||
require(std::strcmp(name, "vo-mediacodec-osd-surface") == 0, "unexpected Surface option");
|
||||
handle->osd_option = object;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_get_property(mpv_handle*, const char*, mpv_format, void*) {
|
||||
require(false, "unexpected property read in lifecycle scenario");
|
||||
return MPV_ERROR_GENERIC;
|
||||
}
|
||||
|
||||
extern "C" const char* mpv_error_string(int) { return "controlled MPV failure"; }
|
||||
extern "C" void mpv_free_node_contents(mpv_node*) {}
|
||||
extern "C" int av_jni_set_java_vm(void*, void*) { return 0; }
|
||||
extern "C" int av_jni_set_android_app_ctx(void*, void*) { return 0; }
|
||||
extern "C" int __android_log_print(int, const char*, const char*, ...) { return 0; }
|
||||
|
||||
bool acquire_jni_env(JavaVM* supplied_vm, JNIEnv** env) {
|
||||
require(supplied_vm == &vm, "event thread acquired the wrong Java VM");
|
||||
*env = &jni;
|
||||
return true;
|
||||
}
|
||||
|
||||
void init_methods_cache(JNIEnv*) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(active_handle == nullptr, "JNI environment rewritten before predecessor retirement");
|
||||
mpv_MpvPlayer_onHook = reinterpret_cast<jmethodID>(1);
|
||||
}
|
||||
|
||||
jstring new_java_string(JNIEnv*, const char* value) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
callback_strings.push_back(std::make_unique<_jstring>(_jstring{value ? value : ""}));
|
||||
return callback_strings.back().get();
|
||||
}
|
||||
|
||||
std::string java_string_to_utf8(JNIEnv*, jstring value) { return value ? value->value : ""; }
|
||||
void die(const char*) { jni.exception_pending = true; }
|
||||
|
||||
int main() {
|
||||
jni.vm = &vm;
|
||||
jni.on_new_global_ref = new_global_ref;
|
||||
jni.on_delete_global_ref = delete_global_ref;
|
||||
jni.on_static_void_method = on_static_void_method;
|
||||
vm.on_detach = detach_event_thread;
|
||||
rejected_hook_and_successor_retirement();
|
||||
admitted_command_survives_replacement();
|
||||
partial_initialization_can_retire();
|
||||
paired_surface_replacements();
|
||||
surface_handoff_failures();
|
||||
overlapping_handoffs_and_teardown();
|
||||
reset_dependencies();
|
||||
std::puts("MPV lifecycle: session isolation, paired handoffs, rollback ownership and overlapping teardown passed");
|
||||
return 0;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -80,6 +80,20 @@ class AudioOutputPolicyTest {
|
||||
assertFalse(shouldForceFfmpegDtsDecode(MimeTypes.AUDIO_TRUEHD, { true }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdTakesTheCarrierOnlyWhenTheRouteAdvertisesDtsHd() {
|
||||
assertTrue(dtsHdCarrierUsable({ it == C.ENCODING_DTS_HD }, { true }))
|
||||
// Carries the tuple for TrueHD but decodes no DTS-HD: the burst would drain unheard.
|
||||
assertFalse(dtsHdCarrierUsable({ false }, { true }))
|
||||
assertFalse(dtsHdCarrierUsable({ true }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCarrierProbeIsSkippedWhenDtsHdIsNotAdvertised() {
|
||||
// The carrier tiering costs several route calls; a route without DTS-HD must not pay them.
|
||||
assertFalse(dtsHdCarrierUsable({ false }, { throw AssertionError("carrier probed without DTS-HD") }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonDtsMimesNeverConsultTheDtsProbes() {
|
||||
// Decoder selection runs this predicate for every mime, video included; the probes make
|
||||
@@ -303,6 +317,5 @@ class AudioOutputPolicyTest {
|
||||
|
||||
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 })
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String = mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
}
|
||||
|
||||
@@ -236,7 +236,8 @@ class ExoPlayerPluginTest {
|
||||
fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val exoCore = ExoPlayerCore(activity)
|
||||
val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit }
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val mpvCore = MpvPlayerCore(activity, true, { _, _ -> Unit }, recordingLoads(loads))
|
||||
val plugin = ExoPlayerPlugin()
|
||||
val sink = RecordingEventSink()
|
||||
plugin.onListen(null, sink)
|
||||
@@ -273,15 +274,30 @@ class ExoPlayerPluginTest {
|
||||
assertEquals(1, superseded.completionCount)
|
||||
assertEquals("OPEN_SUPERSEDED", superseded.errorCode)
|
||||
assertEquals(0, active.completionCount)
|
||||
assertTrue(loads.isEmpty())
|
||||
|
||||
initializeCallback!!(true)
|
||||
mpvCore.delegate?.onEvent("file-loaded", null)
|
||||
awaitCompletion(active)
|
||||
|
||||
assertEquals(null, active.errorCode)
|
||||
assertEquals(true, getField(plugin, "usingMpvFallback"))
|
||||
assertEquals(false, getField(plugin, "fallbackInProgress"))
|
||||
assertNull(getField(plugin, "pendingOpen"))
|
||||
// mpv received exactly the newest open, with its play intent; neither the
|
||||
// failed Exo URI nor the superseded episode was ever loaded.
|
||||
assertEquals(listOf("https://example.test/episode-3.mkv"), loads.map { it[1] })
|
||||
assertEquals("pause=no", loads.single()[4].split(",")[1])
|
||||
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
|
||||
|
||||
// The fallback owns subsequent opens: they reach mpv directly, without
|
||||
// another backend switch.
|
||||
val next = RecordingResult()
|
||||
plugin.onMethodCall(
|
||||
MethodCall("open", mapOf("uri" to "https://example.test/episode-4.mkv", "autoPlay" to false)),
|
||||
next
|
||||
)
|
||||
awaitCompletion(next)
|
||||
assertEquals(null, next.errorCode)
|
||||
assertEquals("https://example.test/episode-4.mkv", loads.last()[1])
|
||||
assertEquals("pause=yes", loads.last()[4].split(",")[1])
|
||||
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
|
||||
|
||||
val disposeResult = RecordingResult()
|
||||
@@ -643,7 +659,9 @@ class ExoPlayerPluginTest {
|
||||
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
|
||||
// A reused core that lost its surface mid-playback with a resume still deferred.
|
||||
core.setPrivateField("desiredPaused", false)
|
||||
core.setPrivateField("cachedPaused", false)
|
||||
core.setPrivateField("resumeBlockedByPublicPause", false)
|
||||
@@ -664,11 +682,10 @@ class ExoPlayerPluginTest {
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(true, core.getPrivateField("desiredPaused"))
|
||||
assertEquals(true, core.getPrivateField("cachedPaused"))
|
||||
assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||
assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
|
||||
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
|
||||
// The load itself carries the hold; the stale deferred resume must not
|
||||
// undo it with a late pause write.
|
||||
assertEquals("https://example.test/video.mkv", loads.single()[1])
|
||||
assertEquals("pause=yes", loads.single()[4].split(",")[1])
|
||||
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||
core.dispose()
|
||||
}
|
||||
@@ -677,10 +694,10 @@ class ExoPlayerPluginTest {
|
||||
fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||
core.setPrivateField("desiredPaused", true)
|
||||
core.setPrivateField("cachedPaused", true)
|
||||
core.setPrivateField("resumeBlockedByPublicPause", true)
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
|
||||
// A reused core whose previous load was explicitly held.
|
||||
core.setPauseIntentForLoad(true)
|
||||
val plugin = reusedFallbackPlugin(activity, core)
|
||||
val result = RecordingResult()
|
||||
|
||||
@@ -695,9 +712,10 @@ class ExoPlayerPluginTest {
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(false, core.getPrivateField("desiredPaused"))
|
||||
assertEquals(false, core.getPrivateField("cachedPaused"))
|
||||
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||
// The new load carries the play intent; the earlier hold is not replayed
|
||||
// as a separate pause write before or after it.
|
||||
assertEquals("https://example.test/video.mkv", loads.single()[1])
|
||||
assertEquals("pause=no", loads.single()[4].split(",")[1])
|
||||
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||
assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
|
||||
core.dispose()
|
||||
@@ -955,6 +973,14 @@ class ExoPlayerPluginTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts every native command, recording each one; a `loadfile` yields playlist entry 1. */
|
||||
private fun recordingLoads(
|
||||
loads: ConcurrentLinkedQueue<List<String>>
|
||||
): suspend (Array<String>) -> Long? = { args ->
|
||||
loads += args.toList()
|
||||
if (args.firstOrNull() == "loadfile") 1L else null
|
||||
}
|
||||
|
||||
private fun awaitQueueEntry(
|
||||
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||
expected: Pair<String, String>
|
||||
|
||||
@@ -126,6 +126,20 @@ class IecCarrierSinkTest {
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/** The tuple is shared, so the gate is per format: TrueHD keeps the carrier while DTS-HD leaves. */
|
||||
@Test
|
||||
fun theCarrierGateIsEvaluatedPerFormat() {
|
||||
val carrier = FakeSink()
|
||||
val normal = FakeSink()
|
||||
val carrierSink = IecCarrierSink(normal, carrier, { it.sampleMimeType != MimeTypes.AUDIO_DTS_HD }, { false })
|
||||
|
||||
carrierSink.configure(audioSinkConfig(trueHdFormat()))
|
||||
assertEquals(IecCarrier.SAMPLE_RATE, checkNotNull(carrier.configuredConfig).format.sampleRate)
|
||||
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
assertEquals(MimeTypes.AUDIO_DTS_HD, checkNotNull(normal.configuredConfig).format.sampleMimeType)
|
||||
}
|
||||
|
||||
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
|
||||
@Test
|
||||
fun dtsExpressIsLeftToTheNormalSink() {
|
||||
|
||||
@@ -80,6 +80,91 @@ class GpuVoPolicyTest {
|
||||
assertNull(GpuVoPolicy.targetFor(emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `High 10 without a hardware profile is software-decoded up front`() {
|
||||
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = false, hardwareAv1 = true))
|
||||
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10 Intra", hardwareHigh10 = false, hardwareAv1 = true))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CODEC_SW_DECODE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hardware supported and unrelated streams retain the configured decoder`() {
|
||||
// A decoder that advertises the profile gets to try.
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true, hardwareAv1 = false))
|
||||
// 8-bit profiles, other codecs, and streams whose container carries no
|
||||
// profile (Annex B transport streams) are not routed.
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High", hardwareHigh10 = false, hardwareAv1 = false))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "Constrained Baseline", hardwareHigh10 = false, hardwareAv1 = false))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("hevc", "Main 10", hardwareHigh10 = false, hardwareAv1 = false))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", null, hardwareHigh10 = false, hardwareAv1 = false))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "", hardwareHigh10 = false, hardwareAv1 = false))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false, hardwareAv1 = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AV1 bypasses software MediaCodec even without a reported profile`() {
|
||||
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", "Main", hardwareHigh10 = true, hardwareAv1 = false))
|
||||
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", null, hardwareHigh10 = true, hardwareAv1 = false))
|
||||
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", "", hardwareHigh10 = true, hardwareAv1 = false))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("av1", "Main", hardwareHigh10 = false, hardwareAv1 = true))
|
||||
}
|
||||
|
||||
// The per-file policies run inside on_preloaded, before mpv selects a
|
||||
// track, so the track they decide for comes from the pending selection.
|
||||
|
||||
@Test
|
||||
fun `auto selection follows mpv's pending choice, not track-list order`() {
|
||||
// Cover art first, the default-flagged feature second: mpv picks 2.
|
||||
assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("auto", "2", listOf(1L, 2L)))
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", "1", listOf(1L, 2L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit vid answers on its own`() {
|
||||
assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("2", pendingVid = null, videoTrackIds = listOf(1L, 2L)))
|
||||
// A user's explicit choice is never re-selected, even when mpv would
|
||||
// pick differently.
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("1", "2", listOf(1L, 2L)))
|
||||
// No such track: mpv selects nothing, so nothing is routed.
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("7", "2", listOf(1L, 2L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no video selection yields no track`() {
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("no", "1", listOf(1L, 2L)))
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "no", listOf(1L, 2L)))
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "1", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without the pending-vid property the first track is the fallback`() {
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", null, listOf(1L, 2L)))
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId(null, null, listOf(1L, 2L)))
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", null, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cheap render tier needs a GL vo on a driver without norm16`() {
|
||||
assertTrue(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = false))
|
||||
// The plane never scales in GL; a capable GPU keeps mpv's defaults.
|
||||
assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = false, textureNorm16 = false))
|
||||
assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cheap tier replaces only options still at their mpv default`() {
|
||||
for ((option, defaults) in GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS) {
|
||||
for (default in defaults) assertTrue(option, GpuVoPolicy.isDefaultRenderOption(option, default))
|
||||
// A user's mpv.conf value, or an unreadable option, is left alone.
|
||||
assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, "ewa_lanczos"))
|
||||
assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, null))
|
||||
}
|
||||
assertEquals(GpuVoPolicy.CHEAP_RENDER_OPTIONS.keys, GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS.keys)
|
||||
// cscale's default is "inherit", which mpv 0.41 reads back as empty.
|
||||
assertTrue(GpuVoPolicy.isDefaultRenderOption("cscale", ""))
|
||||
assertFalse(GpuVoPolicy.isDefaultRenderOption("scale", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dv reshaping targets gpu-next even alongside other reasons`() {
|
||||
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import com.edde746.plezy.libmpv.EndFileReason
|
||||
import com.edde746.plezy.libmpv.LogLevel
|
||||
import com.edde746.plezy.libmpv.LogMessage
|
||||
import com.edde746.plezy.libmpv.MpvError
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MpvEndFileDiagnosticsTest {
|
||||
private val diagnostics = MpvEndFileDiagnostics()
|
||||
|
||||
@Test
|
||||
fun `an AO failure ends the file with the audio-output cause and the last error line`() {
|
||||
diagnostics.onStartFile()
|
||||
diagnostics.onLogMessage(LogMessage("cplayer", LogLevel.Error, "Audio output stopped responding; stopping playback."))
|
||||
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.AoInitFailed))
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"sourceId" to 7L,
|
||||
"reason" to EndFileReason.Error.id,
|
||||
"message" to "Audio output stopped responding; stopping playback.",
|
||||
"cause" to MpvEndFileDiagnostics.CAUSE_AUDIO_OUTPUT_FAILED
|
||||
),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other errors carry no cause so Dart keeps its generic handling`() {
|
||||
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.LoadingFailed))
|
||||
assertNull(data?.get("cause"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clean stop never reports a cause even after an earlier error line`() {
|
||||
diagnostics.onLogMessage(LogMessage("ao/audiotrack", LogLevel.Error, "AudioTrack.write failed with -32"))
|
||||
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Stop, 7))
|
||||
assertEquals(mapOf("sourceId" to 7L, "reason" to EndFileReason.Stop.id), data)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -49,6 +49,22 @@ class MpvPlayerPluginTest {
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandWithoutNativePlayerReportsFailureInsteadOfSilentSuccess() {
|
||||
// A load that never reached mpv produces no source; answering success
|
||||
// would leave Dart waiting on a start-file that never comes.
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore(null))
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("command", mapOf("args" to listOf("loadfile", "x", "replace"))), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals("COMMAND_FAILED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
|
||||
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
|
||||
@@ -758,15 +774,53 @@ class MpvPlayerPluginTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLogLevelReportsUnsupported() {
|
||||
fun setLogLevelWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "warn")), result)
|
||||
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLogLevelRejectsMissingOrNonStringLevel() {
|
||||
for (level in listOf(null, 42)) {
|
||||
val result = RecordingResult()
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(MethodCall("setLogLevel", mapOf("level" to level)), result)
|
||||
|
||||
assertEquals("INVALID_ARGS", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disposeCompletesQueuedLogLevelChangeOnceWithoutAnActiveNativePlayer() {
|
||||
val blockerStarted = CountDownLatch(1)
|
||||
val releaseBlocker = CountDownLatch(1)
|
||||
val core = testCore { _, _ ->
|
||||
blockerStarted.countDown()
|
||||
check(releaseBlocker.await(2, TimeUnit.SECONDS))
|
||||
}
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, core)
|
||||
val result = RecordingResult()
|
||||
try {
|
||||
core.setProperty("block", "value")
|
||||
assertTrue(blockerStarted.await(1, TimeUnit.SECONDS))
|
||||
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "v")), result)
|
||||
core.dispose()
|
||||
} finally {
|
||||
releaseBlocker.countDown()
|
||||
}
|
||||
awaitCompletion(result)
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(
|
||||
MethodCall("setLogLevel", mapOf("level" to "warn")),
|
||||
result
|
||||
)
|
||||
|
||||
assertEquals("UNSUPPORTED", result.errorCode)
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@@ -778,10 +832,11 @@ class MpvPlayerPluginTest {
|
||||
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"sourceId" to 73L,
|
||||
"reason" to 4,
|
||||
"message" to "Invalid data found when processing input"
|
||||
),
|
||||
diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error))
|
||||
diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 73L))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -791,9 +846,10 @@ class MpvPlayerPluginTest {
|
||||
diagnostics.onLogMessage(LogMessage("ffmpeg", LogLevel.Error, "old failure"))
|
||||
diagnostics.onStartFile()
|
||||
|
||||
assertEquals(mapOf("reason" to 0), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Eof)))
|
||||
assertEquals(mapOf("reason" to 4), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error)))
|
||||
assertNull(diagnostics.onEndFile(MpvEvent.EndFile(null)))
|
||||
assertEquals(mapOf("reason" to 0), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Eof, null)))
|
||||
assertEquals(mapOf("reason" to 4), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, null)))
|
||||
assertEquals(mapOf("sourceId" to 81L), diagnostics.onEndFile(MpvEvent.EndFile(null, 81L)))
|
||||
assertNull(diagnostics.onEndFile(MpvEvent.EndFile(null, null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -805,6 +861,7 @@ class MpvPlayerPluginTest {
|
||||
plugin.onEvent(
|
||||
"end-file",
|
||||
mapOf(
|
||||
"sourceId" to 92L,
|
||||
"reason" to 4,
|
||||
"message" to "Failed to open stream"
|
||||
)
|
||||
@@ -815,6 +872,7 @@ class MpvPlayerPluginTest {
|
||||
"type" to "event",
|
||||
"name" to "end-file",
|
||||
"data" to mapOf(
|
||||
"sourceId" to 92L,
|
||||
"reason" to 4,
|
||||
"message" to "Failed to open stream"
|
||||
)
|
||||
@@ -823,6 +881,52 @@ class MpvPlayerPluginTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceQualifiedLifecycleAndPropertiesKeepTheirDequeueIdentity() {
|
||||
val sink = RecordingEventSink()
|
||||
val plugin = MpvPlayerPlugin()
|
||||
plugin.onListen(null, sink)
|
||||
plugin.onMethodCall(
|
||||
MethodCall(
|
||||
"observeProperty",
|
||||
mapOf("name" to "time-pos", "format" to "double", "id" to 7)
|
||||
),
|
||||
RecordingResult()
|
||||
)
|
||||
|
||||
plugin.onPropertyChange("time-pos", 0.0)
|
||||
plugin.onEvent("start-file", mapOf("sourceId" to 202L))
|
||||
plugin.onEvent("file-loaded", mapOf("sourceId" to 202L))
|
||||
plugin.onPropertyChange("time-pos", 12.5, 101L)
|
||||
plugin.onEvent(
|
||||
"playback-restart",
|
||||
mapOf("sourceId" to 202L, "positionSeconds" to 18.75)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf(7, 0.0, null),
|
||||
mapOf(
|
||||
"type" to "event",
|
||||
"name" to "start-file",
|
||||
"data" to mapOf("sourceId" to 202L)
|
||||
),
|
||||
mapOf(
|
||||
"type" to "event",
|
||||
"name" to "file-loaded",
|
||||
"data" to mapOf("sourceId" to 202L)
|
||||
),
|
||||
listOf(7, 12.5, 101L),
|
||||
mapOf(
|
||||
"type" to "event",
|
||||
"name" to "playback-restart",
|
||||
"data" to mapOf("sourceId" to 202L, "positionSeconds" to 18.75)
|
||||
)
|
||||
),
|
||||
sink.successValues
|
||||
)
|
||||
}
|
||||
|
||||
private fun propertyCall() = MethodCall(
|
||||
"setProperty",
|
||||
mapOf("name" to "volume", "value" to "50")
|
||||
@@ -990,6 +1094,39 @@ class MpvPlayerPluginTest {
|
||||
assertEquals("mediacodec", lastVo())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hwdecWritesParkWhileAPerFileHoldIsActive() {
|
||||
// While DV P5 reshaping or Hi10 routing holds hwdec at `no`, a session
|
||||
// write of a hardware value must not reach mpv (it would re-enable the
|
||||
// decoder that was just refused) but must be kept for the restore.
|
||||
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)
|
||||
setBoolean(core, "hwdecHeld", true)
|
||||
|
||||
var outcome: Result<Unit>? = null
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
assertTrue(outcome!!.isSuccess)
|
||||
assertTrue(writes.isEmpty())
|
||||
val parked = MpvPlayerCore::class.java.getDeclaredField("parkedHwdec").run {
|
||||
isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(get(core) as java.util.concurrent.atomic.AtomicReference<String?>).get()
|
||||
}
|
||||
assertEquals("mediacodec,mediacodec-copy", parked)
|
||||
|
||||
// Once the hold is gone, hwdec writes flow through again.
|
||||
setBoolean(core, "hwdecHeld", false)
|
||||
outcome = null
|
||||
core.setProperty("hwdec", "no") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
assertEquals(listOf("hwdec" to "no"), writes.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dvConversionModeMapsOntoForkDecoderOptions() {
|
||||
// The app-level `dv-conversion-mode` property must translate to the fork
|
||||
@@ -1101,9 +1238,11 @@ class MpvPlayerPluginTest {
|
||||
|
||||
private class RecordingEventSink : EventChannel.EventSink {
|
||||
var successValue: Any? = null
|
||||
val successValues = mutableListOf<Any?>()
|
||||
|
||||
override fun success(event: Any?) {
|
||||
successValue = event
|
||||
successValues += event
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OsdPlanePolicyTest {
|
||||
@Test
|
||||
fun `full resolution leaves the plane tracking its view`() {
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fractions shrink both dimensions by the same scale`() {
|
||||
assertEquals(OsdPlanePolicy.Size(1440, 810), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.75f))
|
||||
assertEquals(OsdPlanePolicy.Size(960, 540), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.5f))
|
||||
assertEquals(OsdPlanePolicy.Size(640, 360), OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f / 3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `odd results round down to even`() {
|
||||
// 1/3 of 1000 is 333.3: rounds to 333, then to the even 332.
|
||||
assertEquals(OsdPlanePolicy.Size(332, 250), OsdPlanePolicy.fixedSizeFor(1000, 750, 1f / 3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlaid-out views and nonsense scales are ignored`() {
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(0, 0, 0.5f))
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 0f))
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1.5f))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -10,7 +11,6 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Teardown ordering for frame-rate matching (#2172): restoring the display
|
||||
@@ -21,8 +21,7 @@ import java.time.Duration
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FrameRateManagerRestoreTest {
|
||||
|
||||
private fun buildManager(activity: Activity): FrameRateManager =
|
||||
FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
private fun buildManager(activity: Activity): FrameRateManager = FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
|
||||
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
|
||||
|
||||
|
||||
@@ -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,18 @@
|
||||
# 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.
|
||||
# Match callback names, return type and static access without duplicating JNI
|
||||
# argument descriptors here: adding the session token made the old signatures
|
||||
# silently stop matching. Native initialization resolves every exact descriptor.
|
||||
-keep class com.edde746.plezy.libmpv.MpvPlayer {
|
||||
public static void onPropertyChanged(...);
|
||||
public static void onEvent(...);
|
||||
public static void onEndFile(...);
|
||||
public static void onLogMessage(...);
|
||||
public static void onHook(...);
|
||||
}
|
||||
@@ -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,163 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
// The session every callback below names, bound once before the thread
|
||||
// starts (event_thread_bind). Kotlin drops a callback whose session is not
|
||||
// the wrapper it published for, so a retiring core's tail — end-file,
|
||||
// property changes, a hook raised just before teardown — can never be read
|
||||
// as the successor's. L keeps this binding immutable through join, including
|
||||
// the interval after admission is revoked; callbacks never acquire L.
|
||||
static mpv_handle* thread_mpv;
|
||||
static uint64_t thread_session;
|
||||
|
||||
void event_thread_bind(mpv_handle* mpv, uint64_t session) {
|
||||
thread_mpv = mpv;
|
||||
thread_session = session;
|
||||
}
|
||||
|
||||
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop, int64_t source_id, bool has_source_id) {
|
||||
jstring jprop = new_java_string(env, prop->name);
|
||||
jstring jvalue = NULL;
|
||||
const jboolean jhas_source_id = has_source_id ? JNI_TRUE : JNI_FALSE;
|
||||
const jlong jsession = (jlong)thread_session;
|
||||
switch (prop->format) {
|
||||
case MPV_FORMAT_NONE:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jsession, jprop, (jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jsession, jprop, (jboolean) * (int*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_INT64:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jsession, jprop, (jlong) * (int64_t*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_DOUBLE:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jsession, jprop, (jdouble) * (double*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_STRING:
|
||||
jvalue = new_java_string(env, *(const char**)prop->data);
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jsession, jprop, jvalue, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (jprop) env->DeleteLocalRef(jprop);
|
||||
if (jvalue) env->DeleteLocalRef(jvalue);
|
||||
}
|
||||
|
||||
static void sendEventToJava(
|
||||
JNIEnv* env, int event, int64_t source_id, bool has_source_id, double position_seconds = 0.0,
|
||||
bool has_position_seconds = false) {
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jlong)thread_session, (jint)event, (jlong)source_id,
|
||||
has_source_id ? JNI_TRUE : JNI_FALSE, (jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
|
||||
}
|
||||
|
||||
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
|
||||
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
|
||||
const int reason = end_file ? end_file->reason : -1;
|
||||
const int64_t source_id = end_file ? end_file->playlist_entry_id : 0;
|
||||
// mpv_error code when reason is MPV_END_FILE_REASON_ERROR, 0 otherwise.
|
||||
const int error = end_file ? end_file->error : 0;
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jlong)thread_session, (jint)reason, (jlong)source_id,
|
||||
end_file ? JNI_TRUE : JNI_FALSE, (jint)error);
|
||||
}
|
||||
|
||||
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, (jlong)thread_session, jprefix, (jint)msg->log_level, jtext);
|
||||
|
||||
if (jprefix) env->DeleteLocalRef(jprefix);
|
||||
if (jtext) env->DeleteLocalRef(jtext);
|
||||
}
|
||||
|
||||
// A hook holds mpv (playback does not proceed) until Kotlin answers with
|
||||
// nativeHookContinue(session, id); MpvPlayer guarantees that answer for every
|
||||
// hook it is handed, and mpv itself continues any hook still held when the
|
||||
// handle is destroyed.
|
||||
static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) {
|
||||
jstring jname = new_java_string(env, hook->name);
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, (jlong)thread_session, jname, (jlong)hook->id);
|
||||
if (jname) env->DeleteLocalRef(jname);
|
||||
}
|
||||
|
||||
void* event_thread(void* arg) {
|
||||
JNIEnv* env = NULL;
|
||||
acquire_jni_env(g_vm, &env);
|
||||
if (!env) die("failed to acquire java env");
|
||||
|
||||
int64_t source_id = 0;
|
||||
bool has_source_id = false;
|
||||
|
||||
while (true) {
|
||||
mpv_event* mp_event;
|
||||
mpv_event_property* mp_property;
|
||||
mpv_event_log_message* msg;
|
||||
|
||||
mp_event = mpv_wait_event(thread_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;
|
||||
sendLogMessageToJava(env, msg);
|
||||
break;
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
mp_property = (mpv_event_property*)mp_event->data;
|
||||
sendPropertyUpdateToJava(env, mp_property, source_id, has_source_id);
|
||||
break;
|
||||
case MPV_EVENT_END_FILE:
|
||||
sendEndFileToJava(env, mp_event);
|
||||
break;
|
||||
case MPV_EVENT_START_FILE: {
|
||||
mpv_event_start_file* start_file = (mpv_event_start_file*)mp_event->data;
|
||||
has_source_id = start_file != NULL;
|
||||
source_id = start_file ? start_file->playlist_entry_id : 0;
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id);
|
||||
break;
|
||||
}
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id);
|
||||
break;
|
||||
case MPV_EVENT_HOOK:
|
||||
sendHookToJava(env, (mpv_event_hook*)mp_event->data);
|
||||
break;
|
||||
case MPV_EVENT_PLAYBACK_RESTART: {
|
||||
double position_seconds = 0.0;
|
||||
const bool has_position_seconds =
|
||||
mpv_get_property(thread_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
|
||||
std::isfinite(position_seconds);
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id, position_seconds, has_position_seconds);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Nothing on the Kotlin side consumes the remaining ids (MpvEvent.fromId).
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
g_vm->DetachCurrentThread();
|
||||
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Names the session the next event_thread serves. Called under L and S before
|
||||
// pthread_create publishes the binding. L prevents rebinding until that thread
|
||||
// is joined; the thread never reads the live admission slots and can safely
|
||||
// finish a callback after revocation. Callbacks must never acquire L.
|
||||
void event_thread_bind(mpv_handle* mpv, uint64_t session);
|
||||
|
||||
void* event_thread(void* arg);
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
extern JavaVM* g_vm;
|
||||
|
||||
// The process-global native session. One mpv_handle lives at a time; each is
|
||||
// identified by a monotonic session id handed to Kotlin by nativeCreate. Every
|
||||
// JNI entry names the session it was issued for, every callback carries the
|
||||
// session it originated from, so a retired wrapper can neither touch nor be
|
||||
// fed by its successor.
|
||||
//
|
||||
// g_session_lock (S) guards admission: write-held for publication, init and
|
||||
// revocation, read-held by every other JNI entry through its last handle use.
|
||||
// Retirement drains admitted readers and clears these slots under S, then
|
||||
// releases S BEFORE wake/join/terminate so callbacks can reenter and reject.
|
||||
// A separate lifecycle mutex (L, private to main.cpp) serializes create/init/
|
||||
// destroy through termination AND surface cleanup. Lock order is L -> S;
|
||||
// readers and event callbacks never acquire L. The event thread borrows its
|
||||
// immutable bound handle until joined, even after the admission slots clear.
|
||||
extern mpv_handle* g_mpv;
|
||||
extern uint64_t g_session;
|
||||
extern pthread_rwlock_t g_session_lock;
|
||||
extern std::atomic<bool> g_event_thread_request_exit;
|
||||
|
||||
// Read-locked admission of one JNI entry to the live session. `mpv` is NULL
|
||||
// when `session` is not the live one (retired, superseded, or never created);
|
||||
// the caller then reports MPV_ERROR_UNINITIALIZED / null and touches nothing.
|
||||
// A refused entry is an expected outcome of teardown racing in-flight work,
|
||||
// not a programming error, so it never throws into Java.
|
||||
class SessionGuard {
|
||||
public:
|
||||
explicit SessionGuard(jlong session) {
|
||||
pthread_rwlock_rdlock(&g_session_lock);
|
||||
mpv = (g_mpv && g_session == (uint64_t)session) ? g_mpv : NULL;
|
||||
}
|
||||
~SessionGuard() { pthread_rwlock_unlock(&g_session_lock); }
|
||||
SessionGuard(const SessionGuard&) = delete;
|
||||
SessionGuard& operator=(const SessionGuard&) = delete;
|
||||
|
||||
mpv_handle* mpv;
|
||||
};
|
||||
@@ -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,71 @@
|
||||
#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));
|
||||
// Every callback leads with the origin session (J); see event.cpp.
|
||||
mpv_MpvPlayer_onPropertyChanged_SJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SZJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;ZJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SJJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SDJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;DJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SSJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;Ljava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(JIJZDZ)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(JIJZI)V");
|
||||
mpv_MpvPlayer_onLogMessage =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(JLjava/lang/String;ILjava/lang/String;)V");
|
||||
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(JLjava/lang/String;J)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_SJZ, mpv_MpvPlayer_onPropertyChanged_SZJZ,
|
||||
mpv_MpvPlayer_onPropertyChanged_SJJZ, mpv_MpvPlayer_onPropertyChanged_SDJZ, mpv_MpvPlayer_onPropertyChanged_SSJZ,
|
||||
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage, mpv_MpvPlayer_onHook;
|
||||
@@ -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,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define LOG_TAG "mpv"
|
||||
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
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,240 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <clocale>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/jni.h>
|
||||
}
|
||||
|
||||
#include "event.h"
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
#define ARRAYLEN(a) (sizeof(a) / sizeof(a[0]))
|
||||
|
||||
void render_cleanup(JNIEnv* env);
|
||||
|
||||
extern "C" {
|
||||
jni_func(jlong, nativeCreate, jobject appctx);
|
||||
jni_func(jint, nativeInit, jlong session);
|
||||
jni_func(void, nativeDestroy, jlong session);
|
||||
|
||||
jni_func(jint, nativeSetLogLevel, jlong session, jstring level);
|
||||
|
||||
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray);
|
||||
jni_func(void, nativeHookContinue, jlong session, jlong id);
|
||||
};
|
||||
|
||||
JavaVM* g_vm;
|
||||
mpv_handle* g_mpv;
|
||||
uint64_t g_session;
|
||||
pthread_rwlock_t g_session_lock = PTHREAD_RWLOCK_INITIALIZER;
|
||||
std::atomic<bool> g_event_thread_request_exit(false);
|
||||
|
||||
// Lifecycle serialization (L) is separate from session admission (S).
|
||||
// Only nativeCreate/nativeInit/nativeDestroy acquire L, always before S.
|
||||
static pthread_mutex_t lifecycle_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static uint64_t next_session = 0;
|
||||
static pthread_t event_thread_id;
|
||||
static bool event_thread_started = false;
|
||||
|
||||
// Held through retirement, termination and surface cleanup, so a successor
|
||||
// cannot overlap or rebind the retiring event thread. Callbacks never take L.
|
||||
class LifecycleGuard {
|
||||
public:
|
||||
LifecycleGuard() { pthread_mutex_lock(&lifecycle_lock); }
|
||||
~LifecycleGuard() { pthread_mutex_unlock(&lifecycle_lock); }
|
||||
LifecycleGuard(const LifecycleGuard&) = delete;
|
||||
LifecycleGuard& operator=(const LifecycleGuard&) = delete;
|
||||
};
|
||||
|
||||
// Bounded admission-write scope (S), always nested inside L. Never hold this
|
||||
// while joining or terminating: an event callback can reenter SessionGuard.
|
||||
class SessionWriteGuard {
|
||||
public:
|
||||
SessionWriteGuard() { pthread_rwlock_wrlock(&g_session_lock); }
|
||||
~SessionWriteGuard() { pthread_rwlock_unlock(&g_session_lock); }
|
||||
SessionWriteGuard(const SessionWriteGuard&) = delete;
|
||||
SessionWriteGuard& operator=(const SessionWriteGuard&) = delete;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Caller holds L and S(write). Acquiring S drained every admitted JNI reader;
|
||||
// revoke further admission before letting an in-flight callback finish.
|
||||
// The lifecycle owner retains the handle and immutable event-thread binding.
|
||||
static mpv_handle* revoke_locked() {
|
||||
mpv_handle* local_mpv = g_mpv;
|
||||
g_mpv = NULL;
|
||||
g_session = 0;
|
||||
if (event_thread_started) g_event_thread_request_exit = true;
|
||||
return local_mpv;
|
||||
}
|
||||
|
||||
// Caller holds L, but NOT S. The event thread is the revoked handle's only
|
||||
// remaining borrower; a rejected hook can take S and return during this join.
|
||||
static void destroy_locked(JNIEnv* env, mpv_handle* local_mpv) {
|
||||
// Configuration (including an invalid initial log level) can fail before
|
||||
// nativeInit starts the event thread.
|
||||
if (event_thread_started) {
|
||||
mpv_wakeup(local_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
event_thread_started = false;
|
||||
}
|
||||
// The MediaCodec VO can retain the Surface until final decoder teardown.
|
||||
// Keep its JNI refs alive for the entire blocking termination.
|
||||
mpv_terminate_destroy(local_mpv);
|
||||
render_cleanup(env);
|
||||
}
|
||||
|
||||
jni_func(jlong, nativeCreate, jobject appctx) {
|
||||
LifecycleGuard lock;
|
||||
mpv_handle* predecessor;
|
||||
{
|
||||
SessionWriteGuard admission;
|
||||
predecessor = revoke_locked();
|
||||
}
|
||||
if (predecessor) {
|
||||
ALOGE("destroying leaked mpv instance");
|
||||
destroy_locked(env, predecessor);
|
||||
}
|
||||
|
||||
// Do not rewrite process-wide JNI state while the predecessor can callback.
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
SessionWriteGuard admission;
|
||||
g_mpv = mpv_create();
|
||||
if (!g_mpv) {
|
||||
die("context init failed");
|
||||
return 0;
|
||||
}
|
||||
g_session = ++next_session;
|
||||
|
||||
mpv_request_log_messages(g_mpv, "warn");
|
||||
return (jlong)g_session;
|
||||
}
|
||||
|
||||
jni_func(jint, nativeInit, jlong session) {
|
||||
LifecycleGuard lock;
|
||||
SessionWriteGuard admission;
|
||||
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const int result = mpv_initialize(g_mpv);
|
||||
if (result < 0) {
|
||||
ALOGE("mpv_initialize returned error %s", mpv_error_string(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Per-file decode routing (Dolby Vision P5, H.264 High 10) has to land
|
||||
// before mpv creates the decoder; file-loaded is already too late for the
|
||||
// MediaCodec path. on_preloaded runs after the demuxer opened the file and
|
||||
// holds playback until Kotlin continues it (MpvPlayer.onHook).
|
||||
mpv_hook_add(g_mpv, 0, "on_preloaded", 0);
|
||||
|
||||
g_event_thread_request_exit = false;
|
||||
event_thread_bind(g_mpv, g_session);
|
||||
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
|
||||
die("thread create failed");
|
||||
return MPV_ERROR_GENERIC;
|
||||
}
|
||||
event_thread_started = true;
|
||||
pthread_setname_np(event_thread_id, "event_thread");
|
||||
return 0;
|
||||
}
|
||||
|
||||
jni_func(void, nativeDestroy, jlong session) {
|
||||
LifecycleGuard lock;
|
||||
mpv_handle* local_mpv;
|
||||
{
|
||||
SessionWriteGuard admission;
|
||||
// A wrapper whose session a later nativeCreate already retired has nothing
|
||||
// left to destroy; the successor is not its to touch.
|
||||
if (!g_mpv || g_session != (uint64_t)session) return;
|
||||
local_mpv = revoke_locked();
|
||||
}
|
||||
destroy_locked(env, local_mpv);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetLogLevel, jlong session, jstring jlevel) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const std::string level = java_string_to_utf8(env, jlevel);
|
||||
if (env->ExceptionCheck()) return MPV_ERROR_NOMEM;
|
||||
const int result = mpv_request_log_messages(guard.mpv, level.c_str());
|
||||
if (result < 0) ALOGE("mpv_request_log_messages returned error %s", mpv_error_string(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Runs a command synchronously. Returns the negative mpv error on failure,
|
||||
// the `playlist_entry_id` a `loadfile` created (always > 0), or 0 for a
|
||||
// command that succeeded without one. A retired session reports
|
||||
// MPV_ERROR_UNINITIALIZED like any other rejected command.
|
||||
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* arguments[128] = {0};
|
||||
int len = env->GetArrayLength(jarray);
|
||||
if (len >= (int)ARRAYLEN(arguments)) {
|
||||
die("too many command arguments");
|
||||
return MPV_ERROR_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
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_node result{};
|
||||
const int status = mpv_command_ret(guard.mpv, arguments, &result);
|
||||
if (status < 0) {
|
||||
ALOGE("mpv_command(%s) returned error %s", len > 0 ? arguments[0] : "", mpv_error_string(status));
|
||||
return status;
|
||||
}
|
||||
|
||||
jlong playlist_entry_id = 0;
|
||||
const mpv_node_list* map = result.format == MPV_FORMAT_NODE_MAP ? result.u.list : nullptr;
|
||||
if (map && map->keys && map->values) {
|
||||
for (int i = 0; i < map->num; ++i) {
|
||||
if (map->keys[i] && strcmp(map->keys[i], "playlist_entry_id") == 0 && map->values[i].format == MPV_FORMAT_INT64) {
|
||||
playlist_entry_id = (jlong)map->values[i].u.int64;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
mpv_free_node_contents(&result);
|
||||
return playlist_entry_id;
|
||||
}
|
||||
|
||||
// A continuation for a revoked session is dropped, even while its handle is
|
||||
// still retiring. Destruction releases any outstanding hooks; the successor
|
||||
// must never receive an old hook id.
|
||||
jni_func(void, nativeHookContinue, jlong session, jlong id) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
mpv_hook_continue(guard.mpv, (uint64_t)id);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#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, jlong session, jstring option, jstring value);
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jlong session, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jlong session, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue);
|
||||
|
||||
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetOptionString, jlong session, jstring joption, jstring jvalue) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* option = env->GetStringUTFChars(joption, NULL);
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
|
||||
int result = mpv_set_option_string(guard.mpv, option, value.c_str());
|
||||
|
||||
env->ReleaseStringUTFChars(joption, option);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// A retired session reads as "no value" and writes nowhere: the in-flight
|
||||
// hook handler of a torn-down player must not learn about, or reconfigure,
|
||||
// its successor.
|
||||
static int common_get_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* output) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_get_property(guard.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, jlong session, jstring jproperty, mpv_format format, void* value) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_set_property(guard.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, jlong session, jstring jproperty) {
|
||||
int64_t value = 0;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring jproperty) {
|
||||
double value = 0;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring jproperty) {
|
||||
int value = 0;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
|
||||
}
|
||||
|
||||
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty) {
|
||||
char* value;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
jstring jvalue = new_java_string(env, value);
|
||||
mpv_free(value);
|
||||
return jvalue;
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyInt, jlong session, jstring jproperty, jint jvalue) {
|
||||
int64_t value = static_cast<int64_t>(jvalue);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_INT64, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyDouble, jlong session, jstring jproperty, jdouble jvalue) {
|
||||
double value = static_cast<double>(jvalue);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring jproperty, jboolean jvalue) {
|
||||
int value = jvalue == JNI_TRUE ? 1 : 0;
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyString, jlong session, 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, session, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
}
|
||||
|
||||
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
const char* prop = env->GetStringUTFChars(property, NULL);
|
||||
int result = mpv_observe_property(guard.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,99 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_);
|
||||
};
|
||||
|
||||
// Admission (S) precedes this mutex. Teardown drains S before cleanup and
|
||||
// never takes this mutex, so a waiting handoff cannot outlive its mpv handle.
|
||||
static pthread_mutex_t surface_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
class SurfaceGuard {
|
||||
public:
|
||||
SurfaceGuard() { pthread_mutex_lock(&surface_lock); }
|
||||
~SurfaceGuard() { pthread_mutex_unlock(&surface_lock); }
|
||||
SurfaceGuard(const SurfaceGuard&) = delete;
|
||||
SurfaceGuard& operator=(const SurfaceGuard&) = delete;
|
||||
};
|
||||
static jobject surface;
|
||||
static jobject osd_surface;
|
||||
// A failed wid update can leave an OSD option/consumer using a staged ref.
|
||||
// Even a successful option rollback cannot retire a consumer created between
|
||||
// the two writes. Release these only after a successful wid rebuild or destroy.
|
||||
static std::vector<jobject> pending_osd_surfaces;
|
||||
|
||||
jni_func(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
if (!surface_) return MPV_ERROR_INVALID_PARAMETER;
|
||||
SurfaceGuard lock;
|
||||
|
||||
if (osd_surface_) {
|
||||
try {
|
||||
pending_osd_surfaces.reserve(pending_osd_surfaces.size() + 1);
|
||||
} catch (const std::bad_alloc&) {
|
||||
return MPV_ERROR_NOMEM;
|
||||
}
|
||||
}
|
||||
jobject next_surface = env->NewGlobalRef(surface_);
|
||||
if (!next_surface) return MPV_ERROR_NOMEM;
|
||||
jobject next_osd = osd_surface_ ? env->NewGlobalRef(osd_surface_) : nullptr;
|
||||
if (osd_surface_ && !next_osd) {
|
||||
env->DeleteGlobalRef(next_surface);
|
||||
return MPV_ERROR_NOMEM;
|
||||
}
|
||||
|
||||
int64_t osd_wid = reinterpret_cast<intptr_t>(next_osd);
|
||||
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &osd_wid);
|
||||
if (result < 0) {
|
||||
env->DeleteGlobalRef(next_surface);
|
||||
if (next_osd) env->DeleteGlobalRef(next_osd);
|
||||
return result;
|
||||
}
|
||||
if (next_osd) pending_osd_surfaces.push_back(next_osd);
|
||||
|
||||
// wid has UPDATE_VO: mpv_set_option synchronously tears down the old VO and
|
||||
// decoder before rebuilding with both options. A fresh video global ref also
|
||||
// changes wid when only the Java OSD Surface changed (equal values are ignored).
|
||||
int64_t wid = reinterpret_cast<intptr_t>(next_surface);
|
||||
result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) {
|
||||
osd_wid = reinterpret_cast<intptr_t>(osd_surface);
|
||||
const int rollback = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &osd_wid);
|
||||
if (rollback < 0) ALOGE("OSD surface rollback failed: %s", mpv_error_string(rollback));
|
||||
env->DeleteGlobalRef(next_surface);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (next_osd) pending_osd_surfaces.pop_back();
|
||||
if (surface) env->DeleteGlobalRef(surface);
|
||||
if (osd_surface) env->DeleteGlobalRef(osd_surface);
|
||||
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
|
||||
pending_osd_surfaces.clear();
|
||||
surface = next_surface;
|
||||
osd_surface = next_osd;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Caller holds L after revoking admission, draining JNI readers, joining the
|
||||
// event thread and terminating mpv. S is not held. L prevents a successor from
|
||||
// publishing new surfaces until these retiring references have been released.
|
||||
void render_cleanup(JNIEnv* env) {
|
||||
if (surface) {
|
||||
env->DeleteGlobalRef(surface);
|
||||
surface = nullptr;
|
||||
}
|
||||
if (osd_surface) {
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = nullptr;
|
||||
}
|
||||
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
|
||||
pending_osd_surfaces.clear();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/** The `mpv_error` codes an end-file event can carry (client.h). */
|
||||
enum class MpvError(val code: Int) {
|
||||
LoadingFailed(-13),
|
||||
AoInitFailed(-14),
|
||||
VoInitFailed(-15),
|
||||
NothingToPlay(-16),
|
||||
UnknownFormat(-17),
|
||||
Unsupported(-18),
|
||||
NotImplemented(-19),
|
||||
Generic(-20);
|
||||
|
||||
companion object {
|
||||
/** Null for success (0) and for codes this enum does not model. */
|
||||
fun fromCode(code: Int): MpvError? = entries.find { it.code == code }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
sealed interface MpvEvent {
|
||||
val sourceId: Long?
|
||||
|
||||
data class StartFile(override val sourceId: Long?) : MpvEvent
|
||||
data class EndFile(
|
||||
val reason: EndFileReason?,
|
||||
override val sourceId: Long?,
|
||||
/** mpv_error code when [reason] is [EndFileReason.Error]; null otherwise. */
|
||||
val error: MpvError? = null
|
||||
) : MpvEvent
|
||||
data class FileLoaded(override val sourceId: Long?) : MpvEvent
|
||||
data class PlaybackRestart(
|
||||
override val sourceId: Long?,
|
||||
val positionSeconds: Double?
|
||||
) : MpvEvent
|
||||
|
||||
companion object {
|
||||
// Mirrors the ids event.cpp forwards; END_FILE arrives via its own JNI path.
|
||||
internal fun fromId(
|
||||
id: Int,
|
||||
sourceId: Long?,
|
||||
positionSeconds: Double?
|
||||
): MpvEvent? = when (id) {
|
||||
6 -> StartFile(sourceId)
|
||||
8 -> FileLoaded(sourceId)
|
||||
21 -> PlaybackRestart(sourceId, positionSeconds)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvException(message: String) : RuntimeException(message)
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
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
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Kotlin face of the process-global native player. Each instance is bound to
|
||||
* one immutable native [session]; the JNI layer refuses any call that names
|
||||
* a session it has retired and Kotlin drops any callback stamped with a
|
||||
* session other than the published wrapper's. Together they make a retired
|
||||
* wrapper's in-flight work (a hook handler still reshaping tracks, a queued
|
||||
* property write, a late hook continuation) inert against its successor,
|
||||
* and keep a retiring core's tail events out of the successor's flows.
|
||||
*/
|
||||
class MpvPlayer private constructor(
|
||||
/** Identity of the native session this wrapper owns; see nativeCreate. */
|
||||
val session: Long
|
||||
) : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** Upper bound for a hook handler; longer stalls playback start. */
|
||||
private const val HOOK_TIMEOUT_MS = 3_000L
|
||||
|
||||
init {
|
||||
System.loadLibrary("mpv")
|
||||
System.loadLibrary("player")
|
||||
}
|
||||
|
||||
private val instance = AtomicReference<MpvPlayer?>(null)
|
||||
|
||||
/**
|
||||
* Creates and initializes the process-global native player off the Android
|
||||
* main thread. A predecessor may still be terminating under the native
|
||||
* lifecycle lock, so this call must remain safe to suspend behind it.
|
||||
*/
|
||||
suspend fun create(
|
||||
context: Context,
|
||||
configure: MpvPlayerConfig.() -> Unit = {}
|
||||
): MpvPlayer = withContext(Dispatchers.IO) {
|
||||
checkNotMainThread("MPV initialization")
|
||||
// Retires any leaked predecessor natively and mints the new session.
|
||||
val player = MpvPlayer(nativeCreate(context.applicationContext))
|
||||
synchronized(instance) {
|
||||
val current = instance.get()
|
||||
// Sessions are monotonic: a concurrent create that already published a
|
||||
// newer one has retired this native session underneath us.
|
||||
if (current != null && current.session > player.session) {
|
||||
player.closed = true
|
||||
throw MpvException("MPV session ${player.session} was superseded before it initialized")
|
||||
}
|
||||
instance.set(player)
|
||||
// The predecessor's native session is gone; its close() finds nothing to destroy.
|
||||
current?.closed = true
|
||||
}
|
||||
try {
|
||||
MpvPlayerConfig(player.session).apply(configure)
|
||||
val result = nativeInit(player.session)
|
||||
if (result < 0) throw MpvException("Failed to initialize mpv: error $result")
|
||||
ensureActive()
|
||||
player
|
||||
} catch (e: Throwable) {
|
||||
player.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// JNI callbacks — called from the native event thread. Each names the
|
||||
// session it originated from; only the wrapper published for that
|
||||
// session may receive it.
|
||||
|
||||
private fun target(session: Long): MpvPlayer? = instance.get()?.takeIf { it.session == session }
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(session: Long, name: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.None(name, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(session: Long, name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(session: Long, name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(session: Long, name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(session: Long, name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEvent(
|
||||
session: Long,
|
||||
eventId: Int,
|
||||
sourceId: Long,
|
||||
hasSourceId: Boolean,
|
||||
positionSeconds: Double,
|
||||
hasPositionSeconds: Boolean
|
||||
) {
|
||||
val event = MpvEvent.fromId(
|
||||
eventId,
|
||||
sourceId.takeIf { hasSourceId },
|
||||
positionSeconds.takeIf { hasPositionSeconds && it.isFinite() }
|
||||
) ?: return
|
||||
target(session)?.rawEvents?.trySend(event)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean, error: Int) {
|
||||
target(session)?.rawEvents?.trySend(
|
||||
MpvEvent.EndFile(
|
||||
EndFileReason.fromId(reason),
|
||||
sourceId.takeIf { hasSourceId },
|
||||
MpvError.fromCode(error)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onLogMessage(session: Long, prefix: String, level: Int, text: String) {
|
||||
val logLevel = LogLevel.fromNative(level) ?: return
|
||||
target(session)?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onHook(session: Long, name: String, id: Long) {
|
||||
val player = target(session)
|
||||
if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) {
|
||||
// Nobody will answer: release mpv rather than leave it waiting. The
|
||||
// native side drops this if the session has since been retired.
|
||||
nativeHookContinue(session, id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotMainThread(operation: String) {
|
||||
check(Looper.myLooper() != Looper.getMainLooper()) {
|
||||
"$operation must not run on the Android main thread"
|
||||
}
|
||||
}
|
||||
|
||||
// JNI native declarations — private to avoid internal name mangling.
|
||||
// Every entry after nativeCreate names the session it acts for; the
|
||||
// native side refuses a retired one.
|
||||
|
||||
/** Retires any leaked native session and returns the new session's identity. */
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context): Long
|
||||
|
||||
/** 0 on success, otherwise a negative mpv error. */
|
||||
@JvmStatic private external fun nativeInit(session: Long): Int
|
||||
|
||||
@JvmStatic private external fun nativeDestroy(session: Long)
|
||||
|
||||
/** Negative mpv error, the playlist entry id a `loadfile` created, or 0 when the command returned none. */
|
||||
@JvmStatic private external fun nativeCommand(session: Long, cmd: Array<out String>): Long
|
||||
|
||||
@JvmStatic private external fun nativeSetLogLevel(session: Long, level: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeHookContinue(session: Long, id: Long)
|
||||
|
||||
@JvmStatic private external fun nativeSetOptionString(session: Long, name: String, value: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeAttachSurfaces(session: Long, surface: Surface, osdSurface: Surface?): Int
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyInt(session: Long, name: String): Int?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(session: Long, name: String): Double?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(session: Long, name: String): Boolean?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyString(session: Long, name: String): String?
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyInt(session: Long, name: String, value: Int)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(session: Long, name: String, value: Double)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(session: Long, name: String, value: Boolean)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyString(session: Long, name: String, value: String)
|
||||
|
||||
@JvmStatic private external fun nativeObserveProperty(session: Long, name: String, format: Int)
|
||||
|
||||
internal fun setOptionString(session: Long, name: String, value: String): Int = nativeSetOptionString(session, name, value)
|
||||
|
||||
internal fun requestLogMessages(session: Long, level: String) {
|
||||
checkNotMainThread("MPV log level change")
|
||||
val result = nativeSetLogLevel(session, level)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set log level: error $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 rawHooks = Channel<Hook>(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)
|
||||
|
||||
private class Hook(val name: String, val id: Long)
|
||||
|
||||
/**
|
||||
* Handler for mpv hooks the native side registered (`on_preloaded`). mpv
|
||||
* holds playback until the handler returns; a handler that throws or
|
||||
* overruns [HOOK_TIMEOUT_MS] is abandoned and playback continues. Set it
|
||||
* before loading a file; unset, hooks continue immediately.
|
||||
*/
|
||||
@Volatile var hookHandler: (suspend (name: String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
pumpScope.launch { for (e in rawEvents) events.emit(e) }
|
||||
pumpScope.launch {
|
||||
for (hook in rawHooks) {
|
||||
try {
|
||||
val handler = if (closed) null else hookHandler
|
||||
if (handler != null) {
|
||||
withTimeoutOrNull(HOOK_TIMEOUT_MS) { handler(hook.name) }
|
||||
?: android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler overran; continuing playback")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler failed; continuing playback", e)
|
||||
} finally {
|
||||
// Validated natively against this session, atomically with its
|
||||
// retirement: a continuation that lost the race is dropped, never
|
||||
// delivered to a successor's core.
|
||||
nativeHookContinue(session, hook.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
/**
|
||||
* Runs an mpv command. `loadfile` returns the id of the playlist entry it created — the
|
||||
* `sourceId` carried by that source's start-file / playback-restart / end-file events; every
|
||||
* other command returns null. A command mpv rejects throws [MpvException]: a rejected load
|
||||
* never produces a source, so the caller must not wait for one.
|
||||
*/
|
||||
suspend fun command(vararg args: String): Long? {
|
||||
checkNotClosed()
|
||||
val status = withContext(Dispatchers.IO) { nativeCommand(session, args) }
|
||||
if (status < 0) throw MpvException("Command '${args.firstOrNull() ?: ""}' failed: error $status")
|
||||
return if (status > 0) status else null
|
||||
}
|
||||
|
||||
/** Called on the core's ordered IO writer, without suspending between writes. */
|
||||
fun setLogLevel(level: String) {
|
||||
checkNotClosed()
|
||||
requestLogMessages(session, level)
|
||||
}
|
||||
|
||||
/** Installs both planes and synchronously rebuilds the VO on the core's ordered IO writer. */
|
||||
fun attachSurfaces(surface: Surface, osdSurface: Surface?) {
|
||||
checkNotClosed()
|
||||
checkNotMainThread("MPV surface handoff")
|
||||
val result = nativeAttachSurfaces(session, surface, osdSurface)
|
||||
if (result < 0) throw MpvException("Failed to attach MPV surfaces: error $result")
|
||||
}
|
||||
|
||||
// Property getters
|
||||
|
||||
suspend fun getInt(name: String): Int? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getDouble(name: String): Double? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getFlag(name: String): Boolean? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getString(name: String): String? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(session, name) }
|
||||
}
|
||||
|
||||
// Property setters
|
||||
|
||||
suspend fun setProperty(name: String, value: Int) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Double) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Boolean) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(session, name, value) }
|
||||
}
|
||||
|
||||
// Property observation
|
||||
|
||||
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(session, name, format.nativeValue)
|
||||
return propertyFlow.filter { it.name == name }
|
||||
}
|
||||
|
||||
fun observeFlag(name: String): Flow<Boolean> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(session, name, PropertyFormat.Flag.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Flag>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeInt(name: String): Flow<Long> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(session, name, PropertyFormat.Int64.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Int64>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeDouble(name: String): Flow<Double> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(session, name, PropertyFormat.Double.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Double>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeString(name: String): Flow<String> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(session, name, PropertyFormat.String.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Str>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
/**
|
||||
* Blocks until native teardown finishes. Callers must keep this off the
|
||||
* Android main thread so a slow vendor decoder cannot stall the UI.
|
||||
*/
|
||||
override fun close() {
|
||||
synchronized(instance) {
|
||||
if (closed) return
|
||||
checkNotMainThread("MPV destruction")
|
||||
closed = true
|
||||
hookHandler = null
|
||||
instance.compareAndSet(this, null)
|
||||
}
|
||||
// A no-op natively when a later create() already retired this session;
|
||||
// the successor is never ours to destroy.
|
||||
nativeDestroy(session)
|
||||
// After nativeDestroy no callback can produce for this session: closing
|
||||
// the channels lets each pump drain what is already queued and complete.
|
||||
rawEvents.close()
|
||||
rawHooks.close()
|
||||
rawPropertyChanges.close()
|
||||
rawLogMessages.close()
|
||||
}
|
||||
|
||||
private fun checkNotClosed() {
|
||||
check(!closed) { "MpvPlayer has been closed" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
/** Pre-initialize configuration of one native session; every write names it. */
|
||||
class MpvPlayerConfig internal constructor(private val session: Long) {
|
||||
fun setOption(name: String, value: String) {
|
||||
val result = MpvPlayer.setOptionString(session, name, value)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set option '$name' to '$value': error $result")
|
||||
}
|
||||
}
|
||||
|
||||
fun setLogLevel(level: String) {
|
||||
MpvPlayer.requestLogMessages(session, level)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
sealed interface PropertyChange {
|
||||
val name: String
|
||||
val sourceId: Long?
|
||||
|
||||
data class None(
|
||||
override val name: String,
|
||||
override val sourceId: Long?
|
||||
) : PropertyChange
|
||||
|
||||
data class Flag(
|
||||
override val name: String,
|
||||
val value: Boolean,
|
||||
override val sourceId: Long?
|
||||
) : PropertyChange
|
||||
|
||||
data class Int64(
|
||||
override val name: String,
|
||||
val value: Long,
|
||||
override val sourceId: Long?
|
||||
) : PropertyChange
|
||||
|
||||
data class Double(
|
||||
override val name: String,
|
||||
val value: kotlin.Double,
|
||||
override val sourceId: Long?
|
||||
) : PropertyChange
|
||||
|
||||
data class Str(
|
||||
override val name: String,
|
||||
val value: String,
|
||||
override val sourceId: Long?
|
||||
) : 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,26 +1,20 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- os_media_controls (0.2.4):
|
||||
- Flutter
|
||||
- universal_gamepad (1.5.8):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
universal_gamepad:
|
||||
:path: ".symlinks/plugins/universal_gamepad/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
|
||||
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
|
||||
|
||||
PODFILE CHECKSUM: 0adb115f74a3cde46cf0e15649428c9b4e6975a0
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -797,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 = revision;
|
||||
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
|
||||
revision = 6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -810,7 +810,7 @@
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
6A8A46202EDB320D0057B88C /* MPVKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */;
|
||||
productName = MPVKit;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"identity" : "mpv-build",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"identity" : "mpv-build",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ final class RecordingLifecycleDelegate: MpvPlayerDelegate {
|
||||
private(set) var events: [String] = []
|
||||
private(set) var properties: [String] = []
|
||||
|
||||
func onPropertyChange(name: String, value: Any?) {
|
||||
func onPropertyChange(name: String, value: Any?, sourceId: Int64?) {
|
||||
properties.append(name)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,41 @@ final class MpvPlayerContractTests: XCTestCase {
|
||||
userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
|
||||
)
|
||||
|
||||
func testSharedTransportEmitsSourceQualifiedPayloads() {
|
||||
let plugin = RecordingMpvPlugin(core: nil)
|
||||
plugin.nameToId["time-pos"] = 27
|
||||
var messages: [Any?] = []
|
||||
plugin.eventSink = { messages.append($0) }
|
||||
let sourceId = Int64.max - 7
|
||||
|
||||
plugin.onPropertyChange(name: "time-pos", value: 12.5, sourceId: sourceId)
|
||||
plugin.onPropertyChange(name: "time-pos", value: nil, sourceId: nil)
|
||||
plugin.onEvent(
|
||||
name: "playback-restart",
|
||||
data: ["sourceId": sourceId, "positionSeconds": 12.5]
|
||||
)
|
||||
|
||||
XCTAssertEqual(messages.count, 3)
|
||||
guard
|
||||
let sourcedProperty = messages[0] as? [Any?],
|
||||
let preStartProperty = messages[1] as? [Any?],
|
||||
let lifecycleEvent = messages[2] as? [String: Any],
|
||||
let lifecycleData = lifecycleEvent["data"] as? [String: Any]
|
||||
else {
|
||||
return XCTFail("Expected property triples and a lifecycle event map")
|
||||
}
|
||||
XCTAssertEqual(sourcedProperty.count, 3)
|
||||
XCTAssertEqual(sourcedProperty[0] as? Int, 27)
|
||||
XCTAssertEqual(sourcedProperty[1] as? Double, 12.5)
|
||||
XCTAssertEqual(sourcedProperty[2] as? Int64, sourceId)
|
||||
XCTAssertEqual(preStartProperty.count, 3)
|
||||
XCTAssertNil(preStartProperty[1])
|
||||
XCTAssertNil(preStartProperty[2])
|
||||
XCTAssertEqual(lifecycleEvent["name"] as? String, "playback-restart")
|
||||
XCTAssertEqual(lifecycleData["sourceId"] as? Int64, sourceId)
|
||||
XCTAssertEqual(lifecycleData["positionSeconds"] as? Double, 12.5)
|
||||
}
|
||||
|
||||
func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
|
||||
let core = ControllablePropertyCore()
|
||||
let plugin = RecordingMpvPlugin(core: core)
|
||||
@@ -329,7 +364,7 @@ final class MpvPlayerContractTests: XCTestCase {
|
||||
core.delegate = delegate
|
||||
let enqueueAndDispose = {
|
||||
core.dispatchDelegateEvent(name: "file-loaded", data: nil)
|
||||
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
|
||||
core.dispatchDelegateProperty(name: "time-pos", value: 1.0, sourceId: 7)
|
||||
XCTAssertTrue(core.beginDisposal())
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
|
||||
@@ -15,7 +15,7 @@ platform :ios do
|
||||
|
||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
|
||||
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store BUGS_APPLE_ARCHIVE=build/ios/archive/Runner.xcarchive ./scripts/upload-symbols.sh ios")
|
||||
|
||||
upload_to_app_store(
|
||||
ipa: "../build/ios/ipa/Plezy.ipa",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -86,6 +88,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,6 +234,14 @@ KeyEventResult _handleInputKey({
|
||||
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
|
||||
}
|
||||
|
||||
// A TV back press this field already claimed on KeyDown (its own onBack
|
||||
// below, or the host closing a native session) leaves the matching KeyUp
|
||||
// in flight: swallow it here regardless of onBack, so a field without one
|
||||
// never lets the KeyUp bubble to an ancestor that acts on KeyUp.
|
||||
if (event.logicalKey.isBackKey && PlatformDetector.isTV() && BackKeyUpSuppressor.consumeIfSuppressed(event)) {
|
||||
return finish(KeyEventResult.handled, 'suppressed-back');
|
||||
}
|
||||
|
||||
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
|
||||
@@ -234,7 +249,6 @@ KeyEventResult _handleInputKey({
|
||||
// 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();
|
||||
@@ -787,7 +801,29 @@ class _FocusableTextInputHost extends StatefulWidget {
|
||||
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
enum _NativeTextInputEndReason {
|
||||
dismissed,
|
||||
completed,
|
||||
explicitClose,
|
||||
restart,
|
||||
focusLost,
|
||||
configurationChanged,
|
||||
disposed,
|
||||
}
|
||||
|
||||
/// Kept after dismissal so an IME action already in flight can still complete
|
||||
/// this session, but never a replacement session on the same field.
|
||||
class _NativeTextInputSession {
|
||||
final FocusNode focusNode;
|
||||
_NativeTextInputEndReason? endReason;
|
||||
bool keyboardWasVisible = false;
|
||||
|
||||
_NativeTextInputSession(this.focusNode);
|
||||
|
||||
bool get isActive => endReason == null;
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with WidgetsBindingObserver {
|
||||
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
|
||||
FocusNode? _installedFocusNode;
|
||||
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
||||
@@ -801,10 +837,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
bool _suppressTvKeyboardAutoOpen = false;
|
||||
bool _hasSeenTvKeyboardFocus = false;
|
||||
bool _suppressTvKeyboardForCurrentFocus = false;
|
||||
bool _nativeTextInputActivated = false;
|
||||
_NativeTextInputSession? _nativeTextInputSession;
|
||||
int _nativeTextInputGeneration = 0;
|
||||
ui.FlutterView? _nativeTextInputView;
|
||||
bool _hasSeenNativeTextInputFocus = false;
|
||||
bool _suppressNativeTextInputForCurrentFocus = false;
|
||||
bool _nativeTextInputCompletionHandled = false;
|
||||
|
||||
bool get _nativeTextInputActivated => _nativeTextInputSession?.isActive == true;
|
||||
|
||||
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
||||
|
||||
@@ -815,6 +854,40 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
widget.input.tvTextInputController?._attach(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_nativeTextInputView != null) {
|
||||
final view = View.of(context);
|
||||
if (!identical(view, _nativeTextInputView)) {
|
||||
_nativeTextInputView = view;
|
||||
_nativeTextInputSession?.keyboardWasVisible = view.viewInsets.bottom > 0;
|
||||
_nativeTextInputGeneration++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
final session = _nativeTextInputSession;
|
||||
if (!mounted || session == null || !session.isActive || !session.focusNode.hasFocus) return;
|
||||
final view = View.of(context);
|
||||
if (!identical(view, _nativeTextInputView)) {
|
||||
_nativeTextInputView = view;
|
||||
session.keyboardWasVisible = view.viewInsets.bottom > 0;
|
||||
_nativeTextInputGeneration++;
|
||||
return;
|
||||
}
|
||||
if (view.viewInsets.bottom > 0) {
|
||||
session.keyboardWasVisible = true;
|
||||
} else if (session.keyboardWasVisible) {
|
||||
// Leanback can consume Back and hide without closing the connection or
|
||||
// sending performAction. Zero before any visible metrics is not a hide:
|
||||
// the IME may still be opening, or this may be a hardware keyboard.
|
||||
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -831,13 +904,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_hasSeenTvKeyboardFocus = false;
|
||||
_suppressTvKeyboardForCurrentFocus = false;
|
||||
_nativeTextInputActivated = false;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
|
||||
_nativeTextInputActivated = false;
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior ||
|
||||
oldWidget.input.enabled != widget.input.enabled ||
|
||||
oldWidget.input.controller != widget.input.controller ||
|
||||
oldWidget.input.keyboardType != widget.input.keyboardType ||
|
||||
oldWidget.input.maxLines != widget.input.maxLines) {
|
||||
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
@@ -849,6 +926,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.input.tvTextInputController?._detach(this);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.disposed, rebuild: false);
|
||||
_restoreInstalledHandler();
|
||||
// The keyboard is a navigator route — it must not outlive the field that
|
||||
// opened it (e.g. a form section swapped out while the keyboard is up).
|
||||
@@ -890,11 +968,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
final scope = node.enclosingScope;
|
||||
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
|
||||
final generation = _nativeTextInputGeneration;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || generation != _nativeTextInputGeneration) return;
|
||||
final target = _installedFocusNode;
|
||||
if (target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||
if (!identical(target, node) || target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||
if (!identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||
// Set before requesting focus so the resulting focus-change callback
|
||||
// cannot reopen the keyboard we were just dismissed out of.
|
||||
@@ -908,7 +987,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
||||
if (!focused) {
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
|
||||
return;
|
||||
}
|
||||
if (_suppressNativeTextInputForCurrentFocus) return;
|
||||
@@ -924,47 +1003,109 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
case TvTextInputAutoOpenBehavior.automatic:
|
||||
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenNativeTextInputFocus) {
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
return;
|
||||
}
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
case TvTextInputAutoOpenBehavior.never:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _setNativeTextInputActivated(bool activated) {
|
||||
if (_nativeTextInputActivated == activated) return;
|
||||
if (activated) _nativeTextInputCompletionHandled = false;
|
||||
if (!mounted) {
|
||||
_nativeTextInputActivated = activated;
|
||||
void _beginNativeTextInput() {
|
||||
final node = _installedFocusNode;
|
||||
if (_nativeTextInputActivated || node == null || !node.hasFocus) return;
|
||||
_nativeTextInputGeneration++;
|
||||
_nativeTextInputSession = _NativeTextInputSession(node);
|
||||
if (!PlatformDetector.isAppleTV() && defaultTargetPlatform == TargetPlatform.android) {
|
||||
// Observe the owning view, not MediaQuery: Scaffold removes the bottom
|
||||
// inset before building its body. A focus handoff can keep the same IME
|
||||
// visible, so sample the current view rather than wait for another show.
|
||||
_nativeTextInputView = View.of(context);
|
||||
_nativeTextInputSession!.keyboardWasVisible = _nativeTextInputView!.viewInsets.bottom > 0;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
_syncNativeTextInputFocus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _endNativeTextInput(_NativeTextInputEndReason reason, {bool rebuild = true}) {
|
||||
final session = _nativeTextInputSession;
|
||||
if (session == null) {
|
||||
if (reason == _NativeTextInputEndReason.explicitClose ||
|
||||
reason == _NativeTextInputEndReason.configurationChanged ||
|
||||
reason == _NativeTextInputEndReason.disposed) {
|
||||
_nativeTextInputGeneration++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() => _nativeTextInputActivated = activated);
|
||||
if (session.endReason == reason) return;
|
||||
final wasActive = session.isActive;
|
||||
session.endReason = reason;
|
||||
session.keyboardWasVisible = false;
|
||||
_nativeTextInputGeneration++;
|
||||
if (_nativeTextInputView != null) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_nativeTextInputView = null;
|
||||
}
|
||||
if (reason == _NativeTextInputEndReason.dismissed ||
|
||||
reason == _NativeTextInputEndReason.completed ||
|
||||
reason == _NativeTextInputEndReason.explicitClose ||
|
||||
reason == _NativeTextInputEndReason.restart) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
}
|
||||
_setNativeTextInputFocused(false);
|
||||
if (wasActive && rebuild && mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _scheduleNativeTextInputReactivation() {
|
||||
final generation = _nativeTextInputGeneration;
|
||||
final node = _installedFocusNode;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
generation != _nativeTextInputGeneration ||
|
||||
!identical(node, _installedFocusNode) ||
|
||||
node?.hasFocus != true) {
|
||||
return;
|
||||
}
|
||||
_activateNativeTextInput();
|
||||
});
|
||||
}
|
||||
|
||||
void _activateNativeTextInput() {
|
||||
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
}
|
||||
|
||||
VoidCallback? get _effectiveOnEditingComplete {
|
||||
final input = widget.input;
|
||||
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
|
||||
return _handleNativeEditingComplete;
|
||||
final session = _nativeTextInputSession;
|
||||
return () => _handleNativeEditingComplete(session);
|
||||
}
|
||||
|
||||
void _handleNativeEditingComplete() {
|
||||
if (_nativeTextInputCompletionHandled) return;
|
||||
_nativeTextInputCompletionHandled = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
void _handleNativeEditingComplete(_NativeTextInputSession? session) {
|
||||
if (!mounted ||
|
||||
session == null ||
|
||||
!identical(session, _nativeTextInputSession) ||
|
||||
!session.focusNode.hasFocus ||
|
||||
!widget.input.enabled ||
|
||||
!widget.input._usesNativeTvKeyboard) {
|
||||
return;
|
||||
}
|
||||
// Hide and performAction are separate platform notifications. A hide is
|
||||
// not completion, but must not consume a real Done/Previous delivered for
|
||||
// this same connection before EditableText detaches it at rebuild. Accept
|
||||
// that action once even if metrics won the race; every other terminal
|
||||
// reason (or a successor session) makes the callback stale.
|
||||
if (!session.isActive && session.endReason != _NativeTextInputEndReason.dismissed) return;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.completed);
|
||||
|
||||
final input = widget.input;
|
||||
final callback = input._effectiveOnEditingComplete;
|
||||
@@ -1134,9 +1275,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
/// that must show results instead of the keyboard). Suppresses auto-reopen
|
||||
/// so a field that keeps or regains focus does not relaunch it.
|
||||
void _dismissTvKeyboard() {
|
||||
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
|
||||
}
|
||||
|
||||
// No-op for the Flutter presentation when no overlay is up: setting its
|
||||
@@ -1156,17 +1296,66 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
|
||||
}
|
||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||
final generation = _nativeTextInputGeneration;
|
||||
focusNode.requestFocus();
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
||||
if (!mounted ||
|
||||
generation != _nativeTextInputGeneration ||
|
||||
!identical(focusNode, _installedFocusNode) ||
|
||||
focusNode.hasFocus ||
|
||||
_tvKeyboardOpen ||
|
||||
_tvKeyboardOpenScheduled) {
|
||||
return;
|
||||
}
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 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();
|
||||
final generation = _nativeTextInputGeneration;
|
||||
// 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 &&
|
||||
generation == _nativeTextInputGeneration &&
|
||||
identical(focusNode, _installedFocusNode) &&
|
||||
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');
|
||||
@@ -1198,8 +1387,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
// keyboard while Flutter focus stayed on the field. Restore the
|
||||
// read-only gate so this press navigates Flutter instead of reopening
|
||||
// the input connection.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(
|
||||
event.isTvSelectEvent ? _NativeTextInputEndReason.restart : _NativeTextInputEndReason.dismissed,
|
||||
);
|
||||
activateNativeTextInput = true;
|
||||
if (event.logicalKey.isBackKey) {
|
||||
// This is the Menu press that dismissed UIKit's keyboard. Consume its
|
||||
@@ -1207,30 +1397,28 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.isTvSelectEvent) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
_scheduleNativeTextInputReactivation();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.logicalKey.isBackKey) {
|
||||
// Android: a healthy IME consumes Back to dismiss itself before the
|
||||
// app ever sees it. One arriving here means the keyboard is already
|
||||
// gone (or its key session is broken and MainActivity's repair budget
|
||||
// ran out): close the session and consume the press so it cannot also
|
||||
// pop the route underneath.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
// ran out): close the session and claim the whole press — coordinator
|
||||
// mark so a same-press platform popRoute dedupes, suppressor armed so
|
||||
// the matching KeyUp (delivered to this node with the session closed,
|
||||
// or to an ancestor acting on KeyUp) cannot pop the route underneath.
|
||||
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
|
||||
BackKeyCoordinator.markHandled();
|
||||
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.isTvSelectEvent) {
|
||||
// Android: Select on a field whose keyboard was dismissed re-raises
|
||||
// it (EditText parity). Toggle the connection so the engine issues a
|
||||
// fresh TextInput.show; MainActivity's show-retry covers the
|
||||
// served-view race (#1051/#1079).
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
_endNativeTextInput(_NativeTextInputEndReason.restart);
|
||||
_scheduleNativeTextInputReactivation();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Android arrows fall through deliberately: a healthy visible IME
|
||||
@@ -1271,7 +1459,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
void _restoreInstalledHandler() {
|
||||
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
||||
_setNativeTextInputFocused(false);
|
||||
_nativeTextInputActivated = false;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost, rebuild: false);
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
final node = _installedFocusNode;
|
||||
if (node != null) {
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Yan menyu genişlənmiş qalır və məzmun sahəsi buna uyğunlaşır",
|
||||
"showUnwatchedCount": "Baxılmamış sayını göstər",
|
||||
"showUnwatchedCountDescription": "Seriallarda və mövsümlərdə baxılmamış seriya sayını göstər",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Kartlarda seriya nömrəsini göstər",
|
||||
"showEpisodeNumberOnCardsDescription": "Seriya kartlarında mövsüm və seriya nömrəsini göstər",
|
||||
"showSeasonPostersOnTabs": "Mərhələlərdə mövsüm posterlərini göstər",
|
||||
@@ -273,10 +275,17 @@
|
||||
"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",
|
||||
"autoSkipCreditsDescription": "Titrləri avtomatik ötür və növbəti seriyanı oynat",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Ehtiyat işarələri məcburi et",
|
||||
"forceSkipMarkerFallbackDescription": "Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et",
|
||||
"autoSkipDelay": "Avtomatik ötürmə ləngiməsi",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.",
|
||||
"playbackCancelled": "Oynatma ləğv edildi.",
|
||||
"playbackFailed": "Oynatma başladılarkən xəta.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fayl məlumatı yüklənərkən xəta: ${error}",
|
||||
"errorLoadingSeries": "Serial yüklənərkən xəta",
|
||||
"musicNotSupported": "Musiqi oynatması hələ dəstəklənmir",
|
||||
@@ -855,6 +866,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": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Kitabxananızda yoxdur",
|
||||
"inTheseLibraries": "Bu kitabxanalarda var",
|
||||
"checkingLibrary": "Kitabxananız yoxlanılır...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Hələlik burada heç nə yoxdur",
|
||||
"emptyMessage": "${source} mənbəsindən olan sətirlər burada görünəcək.",
|
||||
"searchHint": "${source} daxilində axtar",
|
||||
@@ -1628,6 +1646,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",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"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"
|
||||
"sessionEnded": "Təşkilatçı seansı bitirdi",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,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",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"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",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr sessiya kukisi yaratmadı",
|
||||
"freshCookieRejected": "Seerr yeni sessiya kukisini rədd etdi",
|
||||
"noUserInformation": "Seerr istifadəçi məlumatlarını qaytarmadı",
|
||||
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi"
|
||||
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Xidmətlər",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Страничната лента остава разгъната и зоната със съдържание се наглася да пасне",
|
||||
"showUnwatchedCount": "Показвай броя негледани",
|
||||
"showUnwatchedCountDescription": "Показвай броя негледани епизоди при сериали и сезони",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Показвай номера на епизода върху картите",
|
||||
"showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите",
|
||||
"showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Пускай следващия епизод автоматично, когато текущият свърши",
|
||||
"playNextCountdown": "Отброяване до следващия епизод",
|
||||
"playNextCountdownImmediate": "Пусни веднага",
|
||||
"autoSkipIntro": "Автоматично прескачане на интро",
|
||||
"autoSkipIntroDescription": "Автоматично прескачай интро маркери след няколко секунди",
|
||||
"autoSkipCredits": "Автоматично прескачане на финални надписи",
|
||||
"autoSkipCreditsDescription": "Автоматично прескачай финалните надписи и пускай следващия епизод",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Принуди резервни маркери",
|
||||
"forceSkipMarkerFallbackDescription": "Използвай шаблони в заглавията на главите дори когато Plex има маркери",
|
||||
"autoSkipDelay": "Забавяне за автоматично прескачане",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
|
||||
"playbackCancelled": "Възпроизвеждането беше отменено.",
|
||||
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
|
||||
"errorLoadingSeries": "Грешка при зареждане на сериала",
|
||||
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
|
||||
@@ -855,6 +866,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": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Не е в твоята библиотека",
|
||||
"inTheseLibraries": "В тези библиотеки",
|
||||
"checkingLibrary": "Проверка на твоята библиотека...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Тук все още няма нищо",
|
||||
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
|
||||
"searchHint": "Търсене в ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} постави на пауза",
|
||||
"participantResumed": "${name} продължи",
|
||||
"participantSeeked": "${name} промени позицията на възпроизвеждане",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} буферира",
|
||||
"participantNeedsUpdate": "${name} е с по-стара версия на приложението — синхронизирането не е налично",
|
||||
"resumingWithout": "Продължаване без ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "Релейният сървър не отговори навреме",
|
||||
"connectionLost": "Връзката се затвори, преди сесията да е готова",
|
||||
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
|
||||
"sessionEnded": "Организаторът прекрати сесията"
|
||||
"sessionEnded": "Организаторът прекрати сесията",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Профил за качество",
|
||||
"rootFolder": "Основна папка",
|
||||
"languageProfile": "Езиков профил",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Заявката е изпратена",
|
||||
"requestFailed": "Заявката се провали: ${error}",
|
||||
"requestsLoadFailed": "Неуспешно зареждане на опциите за заявка",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "В списъка с блокирани",
|
||||
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
|
||||
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
|
||||
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
|
||||
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
|
||||
"noUserInformation": "Seerr не върна информация за потребителя",
|
||||
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
|
||||
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Услуги",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig",
|
||||
"showUnwatchedCount": "Vis antal usete",
|
||||
"showUnwatchedCountDescription": "Vis antal usete episoder på serier og sæsoner",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Vis episodenummer på kort",
|
||||
"showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort",
|
||||
"showSeasonPostersOnTabs": "Vis sæsonplakater på faner",
|
||||
@@ -273,10 +275,17 @@
|
||||
"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",
|
||||
"autoSkipCreditsDescription": "Spring automatisk rulleteksterne over, og afspil næste episode",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tving reservemarkører",
|
||||
"forceSkipMarkerFallbackDescription": "Brug mønstre i kapiteltitler, selv når Plex har markører",
|
||||
"autoSkipDelay": "Forsinkelse ved automatisk spring",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.",
|
||||
"playbackCancelled": "Afspilningen blev annulleret.",
|
||||
"playbackFailed": "Afspilningen kunne ikke startes.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
|
||||
"errorLoadingSeries": "Fejl ved indlæsning af serie",
|
||||
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
|
||||
@@ -855,6 +866,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": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Ikke i dit bibliotek",
|
||||
"inTheseLibraries": "I disse biblioteker",
|
||||
"checkingLibrary": "Tjekker dit bibliotek...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Der er ikke noget her endnu",
|
||||
"emptyMessage": "Indholdsrækker fra ${source} vises her, når de har indhold.",
|
||||
"searchHint": "Søg i ${source}",
|
||||
@@ -1628,6 +1646,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}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"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"
|
||||
"sessionEnded": "Værten afsluttede sessionen",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Kvalitetsprofil",
|
||||
"rootFolder": "Rodmappe",
|
||||
"languageProfile": "Sprogprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Anmodning sendt",
|
||||
"requestFailed": "Anmodning mislykkedes: ${error}",
|
||||
"requestsLoadFailed": "Kunne ikke indlæse anmodningsmuligheder",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"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",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr udstedte ikke en sessionscookie",
|
||||
"freshCookieRejected": "Seerr afviste den nye sessionscookie",
|
||||
"noUserInformation": "Seerr returnerede ikke brugeroplysninger",
|
||||
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen"
|
||||
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an",
|
||||
"showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen",
|
||||
"showUnwatchedCountDescription": "Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Episodennummer auf Karten anzeigen",
|
||||
"showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen",
|
||||
"showSeasonPostersOnTabs": "Staffelposter auf Tabs anzeigen",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Die nächste Episode automatisch starten, wenn die aktuelle endet",
|
||||
"playNextCountdown": "Countdown bis zur nächsten Episode",
|
||||
"playNextCountdownImmediate": "Sofort abspielen",
|
||||
"autoSkipIntro": "Intro automatisch überspringen",
|
||||
"autoSkipIntroDescription": "Intro-Marker nach wenigen Sekunden automatisch überspringen",
|
||||
"autoSkipCredits": "Abspann automatisch überspringen",
|
||||
"autoSkipCreditsDescription": "Abspann automatisch überspringen und nächste Episode abspielen",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Ersatzmarkierungen erzwingen",
|
||||
"forceSkipMarkerFallbackDescription": "Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt",
|
||||
"autoSkipDelay": "Verzögerung für automatisches Überspringen",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.",
|
||||
"playbackCancelled": "Die Wiedergabe wurde abgebrochen.",
|
||||
"playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
|
||||
"errorLoadingSeries": "Fehler beim Laden der Serie",
|
||||
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Voreinstellung gelöscht",
|
||||
"confirmDeletePreset": "Diese Voreinstellung wirklich löschen?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context und gpu-api werden unter Linux ignoriert: eingebettetes Video wird immer über vo=libmpv auf der Videoebene gerendert, und gpu-next (das Compute-Shader wie ArtCNN benötigen) kann nicht eingebettet ausgeführt werden."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Nicht in deiner Mediathek",
|
||||
"inTheseLibraries": "In diesen Mediatheken",
|
||||
"checkingLibrary": "Deine Mediathek wird überprüft …",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Hier ist noch nichts",
|
||||
"emptyMessage": "Zeilen aus ${source} erscheinen hier, sobald sie Inhalte enthalten.",
|
||||
"searchHint": "${source} durchsuchen",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} hat pausiert",
|
||||
"participantResumed": "${name} hat fortgesetzt",
|
||||
"participantSeeked": "${name} hat die Wiedergabeposition geändert",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} puffert",
|
||||
"participantNeedsUpdate": "${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar",
|
||||
"resumingWithout": "Fortfahren ohne ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet",
|
||||
"connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war",
|
||||
"invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet",
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet"
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Qualitätsprofil",
|
||||
"rootFolder": "Stammordner",
|
||||
"languageProfile": "Sprachprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Anfrage gesendet",
|
||||
"requestFailed": "Anfrage fehlgeschlagen: ${error}",
|
||||
"requestsLoadFailed": "Anfrageoptionen konnten nicht geladen werden",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "Auf der Sperrliste",
|
||||
"couldNotReach": "${url} nicht erreichbar: ${error}",
|
||||
"noInstanceAtUrl": "Keine Seerr-Instanz unter ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Gib eine Serveradresse ein, z. B. https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Diese Seerr-Instanz unterstützt Quick Connect nicht. Dafür ist Seerr 3.4 oder neuer erforderlich.",
|
||||
"notInitialized": "Die Ersteinrichtung dieser Seerr-Instanz wurde noch nicht abgeschlossen",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr hat kein Sitzungscookie ausgestellt",
|
||||
"freshCookieRejected": "Seerr hat das neue Sitzungscookie abgelehnt",
|
||||
"noUserInformation": "Seerr hat keine Benutzerinformationen zurückgegeben",
|
||||
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt"
|
||||
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Dienste",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit",
|
||||
"showUnwatchedCount": "Show Unwatched Count",
|
||||
"showUnwatchedCountDescription": "Display unwatched episode count on shows and seasons",
|
||||
"showWatchedIndicators": "Show Watched Indicators",
|
||||
"showWatchedIndicatorsDescription": "Display a checkmark on watched movies, shows, and episodes",
|
||||
"showEpisodeNumberOnCards": "Show Episode Number on Cards",
|
||||
"showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards",
|
||||
"showSeasonPostersOnTabs": "Show Season Posters on Tabs",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Start the next episode automatically when one ends",
|
||||
"playNextCountdown": "Play Next Countdown",
|
||||
"playNextCountdownImmediate": "Play immediately",
|
||||
"autoSkipIntro": "Auto Skip Intro",
|
||||
"autoSkipIntroDescription": "Automatically skip intro markers after a few seconds",
|
||||
"autoSkipCredits": "Auto Skip Credits",
|
||||
"autoSkipCreditsDescription": "Automatically skip credits and play next episode",
|
||||
"skipIntroMode": "Skip Intro",
|
||||
"skipIntroModeOffDescription": "Play intros normally without a skip button",
|
||||
"skipIntroModeButtonDescription": "Show a skip button when an intro starts",
|
||||
"skipIntroModeAutoDescription": "Skip intros automatically after the delay below",
|
||||
"skipCreditsMode": "Skip Credits",
|
||||
"skipCreditsModeOffDescription": "Play credits normally without a skip button",
|
||||
"skipCreditsModeButtonDescription": "Show a skip button when credits start",
|
||||
"skipCreditsModeAutoDescription": "Skip credits automatically and play the next episode",
|
||||
"skipMarkerModeOff": "Off",
|
||||
"skipMarkerModeButton": "Show button",
|
||||
"skipMarkerModeAuto": "Automatic",
|
||||
"forceSkipMarkerFallback": "Force Fallback Markers",
|
||||
"forceSkipMarkerFallbackDescription": "Use chapter title patterns even when Plex has markers",
|
||||
"autoSkipDelay": "Auto Skip Delay",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "The server returned invalid playback information.",
|
||||
"playbackCancelled": "Playback was canceled.",
|
||||
"playbackFailed": "Playback could not be started.",
|
||||
"audioOutputFailed": "The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.",
|
||||
"mediaUnavailable": "This content is no longer available.",
|
||||
"errorLoadingFileInfo": "Error loading file info: ${error}",
|
||||
"errorLoadingSeries": "Error loading series",
|
||||
"musicNotSupported": "Music playback is not yet supported",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Preset deleted",
|
||||
"confirmDeletePreset": "Are you sure you want to delete this preset?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "option=value",
|
||||
"addLine": "Add line",
|
||||
"removeLine": "Remove line",
|
||||
"embeddedVoHint": "vo, gpu-context and gpu-api are ignored on Linux: embedded video always renders through vo=libmpv on the video plane, and gpu-next (which compute shaders like ArtCNN need) cannot run embedded."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Not in your library",
|
||||
"inTheseLibraries": "In these libraries",
|
||||
"checkingLibrary": "Checking your library...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "Couldn't check ${n} server",
|
||||
"other": "Couldn't check ${n} servers"
|
||||
},
|
||||
"emptyTitle": "Nothing here yet",
|
||||
"emptyMessage": "Rows from ${source} will appear here once they have content.",
|
||||
"searchHint": "Search ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} paused",
|
||||
"participantResumed": "${name} resumed",
|
||||
"participantSeeked": "${name} changed the playback position",
|
||||
"participantChangedSpeed": "${name} set the speed to ${speed}",
|
||||
"participantBuffering": "${name} is buffering",
|
||||
"participantNeedsUpdate": "${name} is on an older app version — sync unavailable",
|
||||
"resumingWithout": "Resuming without ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "The relay did not respond in time",
|
||||
"connectionLost": "The connection closed before the session was ready",
|
||||
"invalidRelayResponse": "The relay sent an unexpected response",
|
||||
"sessionEnded": "The host ended the session"
|
||||
"sessionEnded": "The host ended the session",
|
||||
"sessionUnavailable": "Unable to resume this session. Join or create a room to continue."
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Quality profile",
|
||||
"rootFolder": "Root folder",
|
||||
"languageProfile": "Language profile",
|
||||
"tags": "Tags",
|
||||
"noTags": "No tags",
|
||||
"defaultOption": "${name} (Default)",
|
||||
"animeNote": "This series is an anime.",
|
||||
"requestSubmitted": "Request submitted",
|
||||
"requestFailed": "Request failed: ${error}",
|
||||
"requestsLoadFailed": "Couldn't load request options",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "Blocklisted",
|
||||
"couldNotReach": "Could not reach ${url}: ${error}",
|
||||
"noInstanceAtUrl": "No Seerr instance at ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "An authenticating reverse proxy (SSO or HTTP auth) answered instead of Seerr. Plezy cannot sign in through it: let Seerr's /api/v1 path bypass the proxy for this app, or use an address that reaches Seerr directly.",
|
||||
"invalidUrl": "Enter a server address like https://seerr.example.com",
|
||||
"quickConnectUnsupported": "This Seerr instance does not support Quick Connect. It needs Seerr 3.4 or newer.",
|
||||
"notInitialized": "This Seerr instance has not completed first-run setup",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr did not issue a session cookie",
|
||||
"freshCookieRejected": "Seerr rejected the new session cookie",
|
||||
"noUserInformation": "Seerr did not return user information",
|
||||
"sessionRejectedAfterReauth": "The session was rejected after signing in again"
|
||||
"sessionRejectedAfterReauth": "The session was rejected after signing in again",
|
||||
"permissionDenied": "Seerr denied this action: your account no longer has the required permission",
|
||||
"permissionRevoked": "You no longer have permission to request this"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse",
|
||||
"showUnwatchedCount": "Mostrar el número de elementos no vistos",
|
||||
"showUnwatchedCountDescription": "Mostrar el número de episodios no vistos en series y temporadas",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Mostrar número de episodio en las tarjetas",
|
||||
"showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio",
|
||||
"showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Iniciar automáticamente el siguiente episodio cuando termine el actual",
|
||||
"playNextCountdown": "Cuenta atrás para el siguiente episodio",
|
||||
"playNextCountdownImmediate": "Reproducir de inmediato",
|
||||
"autoSkipIntro": "Saltar introducción automáticamente",
|
||||
"autoSkipIntroDescription": "Saltar automáticamente los marcadores de introducción después de unos segundos",
|
||||
"autoSkipCredits": "Saltar créditos automáticamente",
|
||||
"autoSkipCreditsDescription": "Saltar automáticamente los créditos y reproducir el episodio siguiente",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forzar marcadores alternativos",
|
||||
"forceSkipMarkerFallbackDescription": "Usar patrones de títulos de capítulos aunque Plex tenga marcadores",
|
||||
"autoSkipDelay": "Retraso del salto automático",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "El servidor devolvió información de reproducción no válida.",
|
||||
"playbackCancelled": "Se canceló la reproducción.",
|
||||
"playbackFailed": "No se pudo iniciar la reproducción.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Error al cargar la información del archivo: ${error}",
|
||||
"errorLoadingSeries": "Error al cargar la serie",
|
||||
"musicNotSupported": "La reproducción de música aún no es compatible",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Preajuste eliminado",
|
||||
"confirmDeletePreset": "¿Estás seguro de que quieres eliminar este preajuste?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context y gpu-api se ignoran en Linux: el vídeo integrado siempre se renderiza mediante vo=libmpv en el plano de vídeo, y gpu-next (que los shaders de cómputo como ArtCNN necesitan) no puede ejecutarse integrado."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "No está en tu biblioteca",
|
||||
"inTheseLibraries": "En estas bibliotecas",
|
||||
"checkingLibrary": "Comprobando tu biblioteca...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Aquí no hay nada todavía",
|
||||
"emptyMessage": "Las filas de ${source} aparecerán aquí cuando tengan contenido.",
|
||||
"searchHint": "Buscar en ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} pausó",
|
||||
"participantResumed": "${name} reanudó",
|
||||
"participantSeeked": "${name} cambió la posición de reproducción",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} está almacenando en búfer",
|
||||
"participantNeedsUpdate": "${name} usa una versión anterior de la aplicación — sincronización no disponible",
|
||||
"resumingWithout": "Reanudando sin ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "El servidor de retransmisión no respondió a tiempo",
|
||||
"connectionLost": "La conexión se cerró antes de que la sesión estuviera lista",
|
||||
"invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada",
|
||||
"sessionEnded": "El anfitrión finalizó la sesión"
|
||||
"sessionEnded": "El anfitrión finalizó la sesión",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Perfil de calidad",
|
||||
"rootFolder": "Carpeta raíz",
|
||||
"languageProfile": "Perfil de idioma",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Solicitud enviada",
|
||||
"requestFailed": "La solicitud falló: ${error}",
|
||||
"requestsLoadFailed": "No se pudieron cargar las opciones de solicitud",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "En la lista de bloqueo",
|
||||
"couldNotReach": "No se pudo conectar con ${url}: ${error}",
|
||||
"noInstanceAtUrl": "No hay ninguna instancia de Seerr en ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Introduce una dirección de servidor como https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Esta instancia de Seerr no admite Quick Connect. Necesita Seerr 3.4 o más reciente.",
|
||||
"notInitialized": "Esta instancia de Seerr no ha completado la configuración inicial",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr no proporcionó una cookie de sesión",
|
||||
"freshCookieRejected": "Seerr rechazó la nueva cookie de sesión",
|
||||
"noUserInformation": "Seerr no devolvió información del usuario",
|
||||
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión"
|
||||
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servicios",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte",
|
||||
"showUnwatchedCount": "Afficher le nombre d’éléments non vus",
|
||||
"showUnwatchedCountDescription": "Afficher le nombre d’épisodes non vus pour les séries et les saisons",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Afficher le numéro de l’épisode sur les cartes",
|
||||
"showEpisodeNumberOnCardsDescription": "Afficher les numéros de saison et d’épisode sur les cartes d’épisode",
|
||||
"showSeasonPostersOnTabs": "Afficher les affiches de saison sur les onglets",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Lancer automatiquement l'épisode suivant lorsqu'un épisode se termine",
|
||||
"playNextCountdown": "Compte à rebours avant l'épisode suivant",
|
||||
"playNextCountdownImmediate": "Lire immédiatement",
|
||||
"autoSkipIntro": "Passer automatiquement l’introduction",
|
||||
"autoSkipIntroDescription": "Passer automatiquement les marqueurs d’introduction après quelques secondes",
|
||||
"autoSkipCredits": "Passer automatiquement le générique",
|
||||
"autoSkipCreditsDescription": "Passer automatiquement le générique et lire l’épisode suivant",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forcer les marqueurs de secours",
|
||||
"forceSkipMarkerFallbackDescription": "Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs",
|
||||
"autoSkipDelay": "Délai avant le saut automatique",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Le serveur a renvoyé des informations de lecture non valides.",
|
||||
"playbackCancelled": "La lecture a été annulée.",
|
||||
"playbackFailed": "Impossible de démarrer la lecture.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier : ${error}",
|
||||
"errorLoadingSeries": "Erreur lors du chargement de la série",
|
||||
"musicNotSupported": "La lecture de musique n'est pas encore prise en charge",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Préréglage supprimé",
|
||||
"confirmDeletePreset": "Êtes-vous sûr de vouloir supprimer ce préréglage ?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context et gpu-api sont ignorés sous Linux : la vidéo intégrée est toujours rendue via vo=libmpv sur le plan vidéo, et gpu-next (dont les shaders de calcul comme ArtCNN ont besoin) ne peut pas fonctionner en mode intégré."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Absent de votre bibliothèque",
|
||||
"inTheseLibraries": "Dans ces bibliothèques",
|
||||
"checkingLibrary": "Vérification de votre bibliothèque...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Rien ici pour l'instant",
|
||||
"emptyMessage": "Les lignes de ${source} apparaîtront ici dès qu’elles contiendront des éléments.",
|
||||
"searchHint": "Rechercher dans ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} a mis en pause",
|
||||
"participantResumed": "${name} a repris",
|
||||
"participantSeeked": "${name} a changé la position de lecture",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "La lecture de ${name} est en cours de mise en mémoire tampon",
|
||||
"participantNeedsUpdate": "${name} utilise une ancienne version de l’app — synchronisation indisponible",
|
||||
"resumingWithout": "Reprise sans ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "Le serveur relais n’a pas répondu à temps",
|
||||
"connectionLost": "La connexion s’est fermée avant que la session ne soit prête",
|
||||
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
|
||||
"sessionEnded": "L’hôte a mis fin à la session"
|
||||
"sessionEnded": "L’hôte a mis fin à la session",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Profil de qualité",
|
||||
"rootFolder": "Dossier racine",
|
||||
"languageProfile": "Profil de langue",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Demande envoyée",
|
||||
"requestFailed": "Échec de la demande : ${error}",
|
||||
"requestsLoadFailed": "Impossible de charger les options de demande",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "Sur la liste de blocage",
|
||||
"couldNotReach": "Impossible de joindre ${url} : ${error}",
|
||||
"noInstanceAtUrl": "Aucune instance Seerr à l’adresse ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Saisissez une adresse de serveur comme https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Cette instance Seerr ne prend pas en charge Quick Connect. Elle nécessite Seerr 3.4 ou version ultérieure.",
|
||||
"notInitialized": "La configuration initiale de cette instance Seerr n’est pas terminée",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr n’a pas fourni de cookie de session",
|
||||
"freshCookieRejected": "Seerr a refusé le nouveau cookie de session",
|
||||
"noUserInformation": "Seerr n’a renvoyé aucune information sur l’utilisateur",
|
||||
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion"
|
||||
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Az oldalsáv kibontva marad, a tartalom területe igazodik hozzá",
|
||||
"showUnwatchedCount": "Nem látott elemek számának megjelenítése",
|
||||
"showUnwatchedCountDescription": "Megjeleníti a még nem látott epizódok számát a sorozatoknál és évadoknál",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Epizódszám megjelenítése a kártyákon",
|
||||
"showEpisodeNumberOnCardsDescription": "Megjeleníti az évad- és epizódszámot az epizódkártyákon",
|
||||
"showSeasonPostersOnTabs": "Évadborítók megjelenítése a füleken",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "A következő epizód automatikus elindítása, amikor az aktuális véget ér",
|
||||
"playNextCountdown": "Visszaszámlálás a következő epizódig",
|
||||
"playNextCountdownImmediate": "Azonnali lejátszás",
|
||||
"autoSkipIntro": "Intró automatikus átugrása",
|
||||
"autoSkipIntroDescription": "Az intrójelölők automatikus átugrása néhány másodperc után",
|
||||
"autoSkipCredits": "Stáblista automatikus átugrása",
|
||||
"autoSkipCreditsDescription": "A stáblista automatikus átugrása és a következő epizód lejátszása",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tartalék jelölők kényszerítése",
|
||||
"forceSkipMarkerFallbackDescription": "Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel",
|
||||
"autoSkipDelay": "Automatikus átugrás késleltetése",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "A szerver érvénytelen lejátszási adatokat küldött.",
|
||||
"playbackCancelled": "A lejátszás megszakítva.",
|
||||
"playbackFailed": "Nem sikerült elindítani a lejátszást.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Hiba a fájlinformációk betöltésekor: ${error}",
|
||||
"errorLoadingSeries": "Hiba a sorozat betöltésekor",
|
||||
"musicNotSupported": "A zenelejátszás még nem támogatott",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Előbeállítás törölve",
|
||||
"confirmDeletePreset": "Biztosan törölni szeretnéd ezt az előbeállítást?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# megjegyzés",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "A vo, gpu-context és gpu-api beállítások Linuxon figyelmen kívül maradnak: a beágyazott videó mindig a vo=libmpv-n keresztül jelenik meg a videósíkon, a gpu-next (amelyre az ArtCNN-hez hasonló compute shadereknek szükségük van) pedig nem futhat beágyazva."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Nincs a könyvtáradban",
|
||||
"inTheseLibraries": "Ezekben a könyvtárakban",
|
||||
"checkingLibrary": "Könyvtár ellenőrzése...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Még nincs itt semmi",
|
||||
"emptyMessage": "A(z) ${source} forrásból származó sorok itt fognak megjelenni, amint van tartalmuk.",
|
||||
"searchHint": "Keresés itt: ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} szüneteltette a lejátszást",
|
||||
"participantResumed": "${name} folytatta a lejátszást",
|
||||
"participantSeeked": "${name} módosította a lejátszási pozíciót",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} pufferel",
|
||||
"participantNeedsUpdate": "${name} régebbi alkalmazásverziót használ — a szinkronizálás nem érhető el",
|
||||
"resumingWithout": "Folytatás a következő nélkül: ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "A relészerver nem válaszolt időben",
|
||||
"connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna",
|
||||
"invalidRelayResponse": "A relészerver váratlan választ küldött",
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet"
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Minőségi profil",
|
||||
"rootFolder": "Gyökérmappa",
|
||||
"languageProfile": "Nyelvi profil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Igénylés elküldve",
|
||||
"requestFailed": "Az igénylés nem sikerült: ${error}",
|
||||
"requestsLoadFailed": "Nem sikerült betölteni az igénylési opciókat",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "Tiltólistán",
|
||||
"couldNotReach": "Nem sikerült elérni ezt: ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nem található Seerr-példány ezen a címen: ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Adj meg egy szervercímet, például: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Ez a Seerr-példány nem támogatja a Quick Connectet. Seerr 3.4 vagy újabb verzió szükséges.",
|
||||
"notInitialized": "Ennek a Seerr-példánynak a kezdeti beállítása még nem fejeződött be",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "A Seerr nem adott ki munkamenet-sütit",
|
||||
"freshCookieRejected": "A Seerr elutasította az új munkamenet-sütit",
|
||||
"noUserInformation": "A Seerr nem adott vissza felhasználói adatokat",
|
||||
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították"
|
||||
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Szolgáltatások",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta",
|
||||
"showUnwatchedCount": "Mostra il numero di episodi non visti",
|
||||
"showUnwatchedCountDescription": "Mostra il numero di episodi non visti per serie e stagioni",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Mostra il numero dell'episodio sulle schede",
|
||||
"showEpisodeNumberOnCardsDescription": "Mostra il numero della stagione e dell'episodio sulle schede degli episodi",
|
||||
"showSeasonPostersOnTabs": "Mostra i poster delle stagioni nelle schede",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Avvia automaticamente l'episodio successivo quando termina quello in riproduzione",
|
||||
"playNextCountdown": "Conto alla rovescia del successivo",
|
||||
"playNextCountdownImmediate": "Riproduci subito",
|
||||
"autoSkipIntro": "Salta automaticamente la sigla iniziale",
|
||||
"autoSkipIntroDescription": "Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi",
|
||||
"autoSkipCredits": "Salta automaticamente i titoli di coda",
|
||||
"autoSkipCreditsDescription": "Salta automaticamente i titoli di coda e riproduce l'episodio successivo",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forza i marcatori di ripiego",
|
||||
"forceSkipMarkerFallbackDescription": "Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori",
|
||||
"autoSkipDelay": "Ritardo del salto automatico",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.",
|
||||
"playbackCancelled": "Riproduzione annullata.",
|
||||
"playbackFailed": "Impossibile avviare la riproduzione.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Errore durante il caricamento delle informazioni sul file: ${error}",
|
||||
"errorLoadingSeries": "Errore durante il caricamento della serie",
|
||||
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Preset eliminato",
|
||||
"confirmDeletePreset": "Sei sicuro di voler eliminare questo preset?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context e gpu-api vengono ignorati su Linux: il video incorporato viene sempre renderizzato tramite vo=libmpv sul piano video e gpu-next (che gli shader di calcolo come ArtCNN richiedono) non può essere eseguito in modalità incorporata."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Non è nella tua libreria",
|
||||
"inTheseLibraries": "In queste librerie",
|
||||
"checkingLibrary": "Ricerca nella tua libreria...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Ancora niente qui",
|
||||
"emptyMessage": "Le sezioni di ${source} appariranno qui quando saranno disponibili dei contenuti.",
|
||||
"searchHint": "Cerca su ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} ha messo in pausa",
|
||||
"participantResumed": "${name} ha ripreso",
|
||||
"participantSeeked": "${name} ha cambiato la posizione di riproduzione",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} è in buffering",
|
||||
"participantNeedsUpdate": "${name} usa una versione precedente dell'app — sincronizzazione non disponibile",
|
||||
"resumingWithout": "Ripresa senza ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "Il server relay non ha risposto in tempo",
|
||||
"connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta",
|
||||
"invalidRelayResponse": "Il server relay ha inviato una risposta imprevista",
|
||||
"sessionEnded": "L’host ha terminato la sessione"
|
||||
"sessionEnded": "L’host ha terminato la sessione",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Profilo di qualità",
|
||||
"rootFolder": "Cartella radice",
|
||||
"languageProfile": "Profilo della lingua",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Richiesta inviata",
|
||||
"requestFailed": "Richiesta non riuscita: ${error}",
|
||||
"requestsLoadFailed": "Impossibile caricare le opzioni di richiesta",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "Nella lista di blocco",
|
||||
"couldNotReach": "Impossibile raggiungere ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nessuna istanza Seerr all'indirizzo ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Inserisci un indirizzo del server come https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Questa istanza Seerr non supporta Quick Connect. È necessaria la versione 3.4 o successiva di Seerr.",
|
||||
"notInitialized": "Questa istanza Seerr non ha completato la configurazione iniziale",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr non ha generato un cookie di sessione",
|
||||
"freshCookieRejected": "Seerr ha rifiutato il nuovo cookie di sessione",
|
||||
"noUserInformation": "Seerr non ha restituito le informazioni sull'utente",
|
||||
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso"
|
||||
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servizi",
|
||||
|
||||
+32
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "サイドバーを展開したままにし、コンテンツ領域を幅に合わせて調整します",
|
||||
"showUnwatchedCount": "未視聴数を表示",
|
||||
"showUnwatchedCountDescription": "番組とシーズンに未視聴エピソード数を表示",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "カードにエピソード番号を表示",
|
||||
"showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します",
|
||||
"showSeasonPostersOnTabs": "タブにシーズンポスターを表示",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "エピソードが終了すると、次のエピソードを自動的に再生します",
|
||||
"playNextCountdown": "次回再生のカウントダウン",
|
||||
"playNextCountdownImmediate": "すぐに再生",
|
||||
"autoSkipIntro": "イントロを自動スキップ",
|
||||
"autoSkipIntroDescription": "数秒後にイントロマーカーを自動的にスキップ",
|
||||
"autoSkipCredits": "クレジットを自動スキップ",
|
||||
"autoSkipCreditsDescription": "クレジットを自動的にスキップして次のエピソードを再生",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "フォールバックマーカーを強制",
|
||||
"forceSkipMarkerFallbackDescription": "Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します",
|
||||
"autoSkipDelay": "自動スキップの遅延",
|
||||
@@ -766,6 +775,8 @@
|
||||
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
|
||||
"playbackCancelled": "再生がキャンセルされました。",
|
||||
"playbackFailed": "再生を開始できませんでした。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
|
||||
"errorLoadingSeries": "シリーズの読み込みエラー",
|
||||
"musicNotSupported": "音楽の再生はまだサポートされていません",
|
||||
@@ -851,6 +862,9 @@
|
||||
"presetDeleted": "プリセットを削除しました",
|
||||
"confirmDeletePreset": "このプリセットを削除してもよろしいですか?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "Linux では vo、gpu-context、gpu-api は無視されます。埋め込み動画は常にビデオプレーン上で vo=libmpv を通してレンダリングされ、gpu-next(ArtCNN のようなコンピュートシェーダーに必要)は埋め込みでは実行できません。"
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1245,6 +1259,9 @@
|
||||
"notInLibrary": "ライブラリにありません",
|
||||
"inTheseLibraries": "これらのライブラリにあります",
|
||||
"checkingLibrary": "ライブラリを確認中…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "まだ何もありません",
|
||||
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
|
||||
"searchHint": "${source}を検索",
|
||||
@@ -1618,6 +1635,7 @@
|
||||
"participantPaused": "${name}が一時停止しました",
|
||||
"participantResumed": "${name}が再開しました",
|
||||
"participantSeeked": "${name}が再生位置を変更しました",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name}がバッファリング中",
|
||||
"participantNeedsUpdate": "${name}は古いバージョンのアプリを使用しているため、同期できません",
|
||||
"resumingWithout": "${name}抜きで再開",
|
||||
@@ -1633,7 +1651,8 @@
|
||||
"timedOut": "リレーサーバーが時間内に応答しませんでした",
|
||||
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
|
||||
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
|
||||
"sessionEnded": "ホストがセッションを終了しました"
|
||||
"sessionEnded": "ホストがセッションを終了しました",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2064,6 +2083,10 @@
|
||||
"qualityProfile": "画質プロファイル",
|
||||
"rootFolder": "ルートフォルダ",
|
||||
"languageProfile": "言語プロファイル",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "リクエストを送信しました",
|
||||
"requestFailed": "リクエストに失敗しました: ${error}",
|
||||
"requestsLoadFailed": "リクエストオプションを読み込めませんでした",
|
||||
@@ -2075,6 +2098,7 @@
|
||||
"statusBlocklisted": "ブロックリスト登録済み",
|
||||
"couldNotReach": "${url}に接続できませんでした: ${error}",
|
||||
"noInstanceAtUrl": "${url}にSeerrインスタンスがありません(HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "https://seerr.example.comのようなサーバーアドレスを入力してください",
|
||||
"quickConnectUnsupported": "このSeerrインスタンスはQuick Connectに対応していません。Seerr 3.4以降が必要です。",
|
||||
"notInitialized": "このSeerrインスタンスでは初回セットアップが完了していません",
|
||||
@@ -2084,7 +2108,9 @@
|
||||
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
|
||||
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
|
||||
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
|
||||
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
|
||||
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "サービス",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Жүйелік мәзір ашық күйінде қалады",
|
||||
"showUnwatchedCount": "Көрілмегендер санын көрсету",
|
||||
"showUnwatchedCountDescription": "Сериалдар мен маусымдарда көрілмеген бөлімдер санын көрсету",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Карточкаларда бөлім нөмірін көрсету",
|
||||
"showEpisodeNumberOnCardsDescription": "Бөлім карточкаларында маусым мен бөлім нөмірін көрсету",
|
||||
"showSeasonPostersOnTabs": "Қойындыларда маусым постерлерін көрсету",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау",
|
||||
"playNextCountdown": "Келесіні ойнату санағы",
|
||||
"playNextCountdownImmediate": "Дереу ойнату",
|
||||
"autoSkipIntro": "Киріс бөлімді (Intro) автоматты өткізу",
|
||||
"autoSkipIntroDescription": "Бірнеше секундтан кейін киріс белгілерін автоматты өткізу",
|
||||
"autoSkipCredits": "Титрлерді автоматты өткізу",
|
||||
"autoSkipCreditsDescription": "Титрлерді автоматты өткізіп, келесі бөлімді ойнату",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Қосалқы белгілерді мәжбүрлеу",
|
||||
"forceSkipMarkerFallbackDescription": "Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану",
|
||||
"autoSkipDelay": "Автоматты өткізу кідірісі",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
|
||||
"playbackCancelled": "Ойнатудан бас тартылды.",
|
||||
"playbackFailed": "Ойнатуды іске қосу қатесі.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
|
||||
"errorLoadingSeries": "Сериалды жүктеу қатесі",
|
||||
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Баптау өшірілді",
|
||||
"confirmDeletePreset": "Осы баптауды өшіргіңіз келетініне сенімдісіз бе?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# түсініктеме",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context және gpu-api Linux-те еленбейді: ендірілген бейне әрқашан бейне жазықтығында vo=libmpv арқылы көрсетіледі, ал gpu-next (ArtCNN сияқты compute шейдерлеріне қажет) ендірілген режимде жұмыс істей алмайды."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Кітапханаңызда жоқ",
|
||||
"inTheseLibraries": "Осы кітапханаларда бар",
|
||||
"checkingLibrary": "Кітапхана тексерілуде...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
|
||||
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
|
||||
"searchHint": "${source} ішінен іздеу",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} кідіртті",
|
||||
"participantResumed": "${name} жалғастырды",
|
||||
"participantSeeked": "${name} уақытты өзгертті",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} буферлеуде",
|
||||
"participantNeedsUpdate": "${name} ескі нұсқада",
|
||||
"resumingWithout": "${name} ескерусіз жалғастырылуда",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "Реле сервері уақытында жауап бермеді",
|
||||
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
|
||||
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Сапа профилі",
|
||||
"rootFolder": "Түпкі қапшық",
|
||||
"languageProfile": "Тіл профилі",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Сұрау жіберілді",
|
||||
"requestFailed": "Сұрау қатесі: ${error}",
|
||||
"requestsLoadFailed": "Параметрлерді жүктеу мүмкін болмады",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "Бұғаттау тізімінде",
|
||||
"couldNotReach": "${url} мекенжайына қосылу мүмкін болмады: ${error}",
|
||||
"noInstanceAtUrl": "${url} мекенжайында Seerr данасы жоқ (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Сервер мекенжайын енгізіңіз, мысалы: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Бұл Seerr данасы Жылдам қосылуды қолдамайды. Оған Seerr 3.4 немесе одан жаңарақ нұсқа қажет.",
|
||||
"notInitialized": "Бұл Seerr данасының бастапқы баптауы аяқталмаған",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
|
||||
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
|
||||
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
|
||||
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
|
||||
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Қызметтер",
|
||||
|
||||
+32
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
|
||||
"showUnwatchedCount": "미시청 수 표시",
|
||||
"showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "카드에 에피소드 번호 표시",
|
||||
"showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시",
|
||||
"showSeasonPostersOnTabs": "탭에 시즌 포스터 표시",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "에피소드가 끝나면 다음 에피소드를 자동으로 재생",
|
||||
"playNextCountdown": "다음 재생 카운트다운",
|
||||
"playNextCountdownImmediate": "즉시 재생",
|
||||
"autoSkipIntro": "자동으로 오프닝 건너뛰기",
|
||||
"autoSkipIntroDescription": "몇 초 후 오프닝을 자동으로 건너뛰기",
|
||||
"autoSkipCredits": "자동으로 엔딩 건너뛰기",
|
||||
"autoSkipCreditsDescription": "엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "대체 마커 강제 사용",
|
||||
"forceSkipMarkerFallbackDescription": "Plex에 마커가 있어도 챕터 제목 패턴 사용",
|
||||
"autoSkipDelay": "자동 건너뛰기 지연",
|
||||
@@ -766,6 +775,8 @@
|
||||
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
|
||||
"playbackCancelled": "재생이 취소되었습니다.",
|
||||
"playbackFailed": "재생을 시작할 수 없습니다.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
|
||||
"errorLoadingSeries": "시리즈 로딩 중 오류",
|
||||
"musicNotSupported": "음악 재생 미지원",
|
||||
@@ -851,6 +862,9 @@
|
||||
"presetDeleted": "프리셋이 삭제되었습니다",
|
||||
"confirmDeletePreset": "이 프리셋을 삭제하시겠습니까?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "Linux에서는 vo, gpu-context, gpu-api가 무시됩니다. 내장 동영상은 항상 비디오 평면에서 vo=libmpv로 렌더링되며, gpu-next(ArtCNN 같은 컴퓨트 셰이더에 필요)는 내장 방식으로 실행할 수 없습니다."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1245,6 +1259,9 @@
|
||||
"notInLibrary": "라이브러리에 없음",
|
||||
"inTheseLibraries": "이 라이브러리에 있음",
|
||||
"checkingLibrary": "라이브러리 확인 중...",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "아직 아무것도 없습니다",
|
||||
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
|
||||
"searchHint": "${source}에서 검색",
|
||||
@@ -1618,6 +1635,7 @@
|
||||
"participantPaused": "${name}님이 일시정지했습니다",
|
||||
"participantResumed": "${name}님이 재생했습니다",
|
||||
"participantSeeked": "${name}님이 재생 위치를 변경했습니다",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name}님이 버퍼링 중입니다",
|
||||
"participantNeedsUpdate": "${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다",
|
||||
"resumingWithout": "${name}님 없이 재생을 재개합니다",
|
||||
@@ -1633,7 +1651,8 @@
|
||||
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
|
||||
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
|
||||
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
|
||||
"sessionEnded": "호스트가 세션을 종료했습니다"
|
||||
"sessionEnded": "호스트가 세션을 종료했습니다",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2064,6 +2083,10 @@
|
||||
"qualityProfile": "화질 프로파일",
|
||||
"rootFolder": "루트 폴더",
|
||||
"languageProfile": "언어 프로파일",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "요청을 제출했습니다",
|
||||
"requestFailed": "요청 실패: ${error}",
|
||||
"requestsLoadFailed": "요청 옵션을 불러올 수 없습니다",
|
||||
@@ -2075,6 +2098,7 @@
|
||||
"statusBlocklisted": "차단 목록에 있음",
|
||||
"couldNotReach": "${url}에 연결할 수 없습니다: ${error}",
|
||||
"noInstanceAtUrl": "${url}에 Seerr 인스턴스가 없습니다(HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "https://seerr.example.com과 같은 서버 주소를 입력하세요",
|
||||
"quickConnectUnsupported": "이 Seerr 인스턴스는 Quick Connect를 지원하지 않습니다. Seerr 3.4 이상이 필요합니다.",
|
||||
"notInitialized": "이 Seerr 인스턴스는 최초 실행 설정을 완료하지 않았습니다",
|
||||
@@ -2084,7 +2108,9 @@
|
||||
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
|
||||
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
|
||||
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
|
||||
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
|
||||
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "서비스",
|
||||
|
||||
+33
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidefeltet forblir utvidet og innholdsområdet tilpasser seg",
|
||||
"showUnwatchedCount": "Vis antall usette",
|
||||
"showUnwatchedCountDescription": "Vis antall usette episoder på serier og sesonger",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Vis episodenummer på kort",
|
||||
"showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort",
|
||||
"showSeasonPostersOnTabs": "Vis sesongplakater på faner",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Start neste episode automatisk når en episode er ferdig",
|
||||
"playNextCountdown": "Nedtelling for neste episode",
|
||||
"playNextCountdownImmediate": "Spill umiddelbart",
|
||||
"autoSkipIntro": "Hopp over intro automatisk",
|
||||
"autoSkipIntroDescription": "Hopp automatisk over intromarkører etter noen sekunder",
|
||||
"autoSkipCredits": "Hopp over rulletekst automatisk",
|
||||
"autoSkipCreditsDescription": "Hopp automatisk over rulletekst og spill neste episode",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tving reservemarkører",
|
||||
"forceSkipMarkerFallbackDescription": "Bruk mønstre i kapiteltitler selv når Plex har markører",
|
||||
"autoSkipDelay": "Forsinkelse for automatisk hopp",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.",
|
||||
"playbackCancelled": "Avspillingen ble avbrutt.",
|
||||
"playbackFailed": "Kunne ikke starte avspillingen.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
|
||||
"errorLoadingSeries": "Feil ved lasting av serie",
|
||||
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
|
||||
@@ -855,6 +866,9 @@
|
||||
"presetDeleted": "Forhåndsinnstilling slettet",
|
||||
"confirmDeletePreset": "Er du sikker på at du vil slette denne forhåndsinnstillingen?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# kommentar",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context og gpu-api ignoreres på Linux: innebygd video renderes alltid via vo=libmpv på videoplanet, og gpu-next (som compute-shadere som ArtCNN trenger) kan ikke kjøre innebygd."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1251,6 +1265,10 @@
|
||||
"notInLibrary": "Ikke i biblioteket ditt",
|
||||
"inTheseLibraries": "I disse bibliotekene",
|
||||
"checkingLibrary": "Sjekker biblioteket ditt...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Ingenting her ennå",
|
||||
"emptyMessage": "Rader fra ${source} vises her når de har innhold.",
|
||||
"searchHint": "Søk i ${source}",
|
||||
@@ -1628,6 +1646,7 @@
|
||||
"participantPaused": "${name} satte avspillingen på pause",
|
||||
"participantResumed": "${name} startet avspillingen igjen",
|
||||
"participantSeeked": "${name} endret avspillingsposisjonen",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buffrer",
|
||||
"participantNeedsUpdate": "${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig",
|
||||
"resumingWithout": "Fortsetter uten ${name}",
|
||||
@@ -1643,7 +1662,8 @@
|
||||
"timedOut": "Reléserveren svarte ikke i tide",
|
||||
"connectionLost": "Tilkoblingen ble lukket før økten var klar",
|
||||
"invalidRelayResponse": "Reléserveren sendte et uventet svar",
|
||||
"sessionEnded": "Verten avsluttet økten"
|
||||
"sessionEnded": "Verten avsluttet økten",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2074,6 +2094,10 @@
|
||||
"qualityProfile": "Kvalitetsprofil",
|
||||
"rootFolder": "Rotmappe",
|
||||
"languageProfile": "Språkprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Forespørsel sendt",
|
||||
"requestFailed": "Forespørsel mislyktes: ${error}",
|
||||
"requestsLoadFailed": "Kunne ikke laste forespørselsalternativer",
|
||||
@@ -2085,6 +2109,7 @@
|
||||
"statusBlocklisted": "På blokkeringslisten",
|
||||
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Skriv inn en serveradresse som https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Denne Seerr-instansen støtter ikke Quick Connect. Den krever Seerr 3.4 eller nyere.",
|
||||
"notInitialized": "Denne Seerr-instansen har ikke fullført førstegangsoppsettet",
|
||||
@@ -2094,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel",
|
||||
"freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen",
|
||||
"noUserInformation": "Seerr returnerte ingen brukerinformasjon",
|
||||
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging"
|
||||
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user