Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub d3218107c9 build(deps): bump softprops/action-gh-release from 3.0.2 to 3.0.3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 12:23:55 +00:00
552 changed files with 9956 additions and 44762 deletions
+20 -25
View File
@@ -414,7 +414,9 @@ jobs:
- arch: arm64
runner: windows-11-arm
flutter_setup: git
native_cache_path: build/windows/arm64/_deps
native_cache_path: |
build/windows/arm64/_deps
build/windows/arm64/mpv-dev-arm64
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -425,7 +427,12 @@ 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', 'mpv-build.lock.json') }}
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
- name: Setup Flutter
if: matrix.flutter_setup == 'action'
@@ -578,10 +585,10 @@ jobs:
- name: Read version from pubspec
id: version
shell: bash
shell: pwsh
run: |
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
echo "version=${VERSION%%+*}" >> "$GITHUB_OUTPUT"
$v = (Select-String -Path pubspec.yaml -Pattern '^version:\s*(\S+)').Matches[0].Groups[1].Value -replace '\+.*'
echo "version=$v" >> $env:GITHUB_OUTPUT
- name: Build installer and portables
run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
@@ -718,26 +725,17 @@ jobs:
exit 1
- name: Cache libmpv prefix
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
# 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
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
shell: bash
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
run: bash linux/packaging/build-libmpv.sh
- name: Install fpm
shell: bash
@@ -874,18 +872,15 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
sparse-checkout: |
pubspec.yaml
scripts/pubspec_version.py
sparse-checkout: pubspec.yaml
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Read version from pubspec.yaml
id: version
run: |
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
BUILD_NUMBER=${VERSION#*+}
VERSION=${VERSION%%+*}
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
@@ -1063,7 +1058,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
files: ${{ steps.release-files.outputs.files }}
draft: true
+20 -23
View File
@@ -252,6 +252,9 @@ 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
@@ -401,9 +404,7 @@ jobs:
- name: Verify tvOS project wiring
if: matrix.platform == 'tvOS'
run: |
ruby tvos/scripts/test_wire_mpv.rb
ruby tvos/scripts/test_wire_top_shelf.rb
run: ruby tvos/scripts/test_wire_mpv.rb
- name: Select Apple test destination
env:
@@ -644,10 +645,10 @@ jobs:
cache: true
pub-cache: false
# 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.
# The packaging deps, minus libmpv: this job builds it from source below,
# because the distro's is a different version with different windowing
# backends, and a package smoke-built against it cannot show that
# build-libmpv.sh still works or that the bundle it produces is coherent.
- name: Install packaging dependencies
run: |
sudo apt-get update
@@ -661,29 +662,25 @@ 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: the lock names the asset and its
# checksum, so editing the lock is what invalidates the cache.
- name: Cache libmpv prefix
# Keyed the same way build.yml keys it, so editing the script or its pinned
# inputs is what invalidates the cache - and this branch's whole point is
# that those edits get exercised somewhere.
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
# 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
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
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
run: bash linux/packaging/build-libmpv.sh
# 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
# The windowing backends the runner depends on, read off the library the
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
# in software; X11 and VDPAU are gone with the texture path.
- name: Check the built libmpv's backends
run: |
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
echo "== $LIB =="
+6 -6
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.19.1"
sha256 "7d3ffd0efccf4db5faf063e8ecc03e897ee30d08e9fa3b007d127c63cbf187e2"
version "2.18.0"
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
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_steps do
run "/usr/bin/xattr",
args: ["-cr", "{{appdir}}/Plezy.app"],
sudo: false
postflight do
system_command "/usr/bin/xattr",
args: ["-cr", "#{appdir}/Plezy.app"],
sudo: false
end
uninstall quit: "com.edde746.plezy"
+16 -46
View File
@@ -30,47 +30,22 @@ 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) |
<details>
<summary>Install with a package manager</summary>
Package managers:
### 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>
- [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
```
## Features
@@ -135,11 +110,6 @@ sudo moss it plezy
### <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]
@@ -235,4 +205,4 @@ Plezy is licensed under [GPL-3.0](LICENSE).
- Built with [Flutter](https://flutter.dev)
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
- Playback powered by [mpv](https://mpv.io) 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)
- 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)
+93 -31
View File
@@ -58,31 +58,95 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 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 mpvVersion = "v1.2.2"
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
// Dev-only escape hatch: point the build at a locally built libmpv AAR
// (libmpv-android's build-local.sh output) to test fork patches before a
// release is pinned. The URL + sha256 above stay authoritative otherwise.
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
val media3Version = "1.11.0"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
val mpvFfmpegDevelopmentDir = layout.buildDirectory.dir("libmpv-ffmpeg-development").get().asFile
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()
}
}
// 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(":libmpv:extractLibmpvNative")
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val libraries = listOf("avcodec", "avutil", "swresample")
inputs.dir(libmpvNativeJniDir)
inputs.file(aar)
inputs.property("ffmpegVersion", mpvFfmpegVersion)
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
@@ -152,12 +216,15 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
)
project.copy {
from(libmpvNativeJniDir) {
from(zipTree(aar)) {
include(
"*/libavcodec.so",
"*/libavutil.so",
"*/libswresample.so"
"jni/*/libavcodec.so",
"jni/*/libavutil.so",
"jni/*/libswresample.so"
)
eachFile {
path = path.removePrefix("jni/")
}
}
includeEmptyDirs = false
into(nativeDir)
@@ -168,11 +235,11 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
}.filterNot(File::isFile)
if (missing.isNotEmpty()) {
throw GradleException(
"the :libmpv prebuilt tree is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
)
}
File(staging, ".manifest").writeText(
"ffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
)
sourceArchive.delete()
extractedSource.deleteRecursively()
@@ -282,7 +349,7 @@ android {
defaultConfig {
applicationId = "com.edde746.plezy"
minSdk = 25 // Fire OS 6.x (API 25); :libmpv shares the same floor
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@@ -381,9 +448,8 @@ android {
packaging {
jniLibs {
// pickFirst only suppresses the duplicate libc++ merge error; the
// 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.
// sourceSets rule below makes libmpv's newer runtime win for
// std::from_chars<float>, while older native consumers remain ABI-compatible.
pickFirsts.add("lib/*/libc++_shared.so")
}
}
@@ -391,10 +457,8 @@ android {
sourceSets {
getByName("main") {
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
// 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)
// order cannot accidentally select the older libc++ copy.
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
}
}
@@ -450,18 +514,16 @@ tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative")
}
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(prepareMpvFfmpegDevelopment)
dependsOn(downloadLibmpv, extractMpvLibcxx, 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(":libmpv:extractLibmpvNative")
dependsOn(extractMpvLibcxx)
}
dependencies {
// 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(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Android TV Watch Next integration
@@ -1,84 +0,0 @@
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"
}
}
@@ -1,354 +0,0 @@
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
}
}
@@ -1,146 +0,0 @@
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,10 +4,4 @@
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>
@@ -1,18 +0,0 @@
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))
}
}
+3
View File
@@ -6,6 +6,9 @@
<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,12 +28,6 @@ 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"
@@ -156,7 +150,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
}
}
internal data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
private 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://")) {
@@ -174,7 +168,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
return Source(uri, grantRead = true, fileName = file.name)
}
internal fun buildIntent(
private fun buildIntent(
source: Source,
packageName: String?,
startPositionMs: Long,
@@ -187,9 +181,6 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
if (startPosition > 0) {
putExtra(API_MX_RESULT_POSITION, startPosition)
putExtra(API_VIMU_SEEK_POSITION, startPosition)
putExtra(API_VLC_FROM_START, false)
} else {
putExtra(API_VLC_FROM_START, true)
}
putExtra(API_MX_RETURN_RESULT, true)
putExtra(API_MX_SECURE_URI, true)
@@ -31,7 +31,6 @@ 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
@@ -97,12 +96,9 @@ 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
@@ -597,9 +593,6 @@ class MainActivity : FlutterActivity() {
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
assistiveTechnology?.release()
assistiveTechnology = null
assistiveTechnologyChannel = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -788,19 +781,6 @@ 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,7 +163,10 @@ internal fun mpvSpdifCodecs(
@Suppress("DEPRECATION")
@OptIn(UnstableApi::class)
internal fun supportedMpvSpdifCodecs(context: Context): String {
val audioAttributes = movieMedia3AudioAttributes()
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val capabilities = try {
AudioCapabilities.getCapabilities(context, audioAttributes, null)
} catch (error: Exception) {
@@ -298,31 +301,6 @@ 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`)
@@ -344,7 +322,8 @@ 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
@@ -392,7 +371,8 @@ 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)
@@ -400,9 +380,8 @@ private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int):
.setSampleRate(sampleRate)
.build()
private fun movieMedia3AudioAttributes(): AudioAttributes = AudioAttributes.Builder()
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
private fun movieAudioAttributes(): android.media.AudioAttributes = movieMedia3AudioAttributes().getPlatformAudioAttributes()
.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 && supportsDtsHdIecCarrier(activity)) return true
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
val audioAttributes = buildMovieAudioAttributes()
return try {
AudioCapabilities
@@ -45,11 +45,8 @@ import java.util.concurrent.atomic.AtomicInteger
internal class IecCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/**
* 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 the current route can bitstream the carrier tuple. Evaluated per format. */
private val carrierRouteAvailable: () -> Boolean,
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
private val directOutputBlocked: (Format) -> Boolean,
private val log: ((String, String, String) -> Unit)? = null
@@ -129,7 +126,7 @@ internal class IecCarrierSink(
if (!isCarrierRateFamily(format.sampleRate)) return false
if (playbackParameters.speed != 1f) return false
if (directOutputBlocked(format)) return false
return carrierRouteAvailable(format)
return carrierRouteAvailable()
}
/**
@@ -175,14 +172,14 @@ internal class IecCarrierSink(
override fun supportsFormat(format: Format): Boolean = when {
shouldUseCarrier(format) -> true
isTrueHd(format) -> false
isDtsHd(format) && carrierRouteAvailable(format) -> false
isDtsHd(format) && carrierRouteAvailable() -> false
else -> defaultSink.supportsFormat(format)
}
override fun getFormatSupport(format: Format): Int = when {
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
isDtsHd(format) && carrierRouteAvailable(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
else -> defaultSink.getFormatSupport(format)
}
@@ -207,15 +207,12 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
return IecCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { format -> carrierRouteAvailableFor(context, format) },
carrierRouteAvailable = { supportsIecCarrier(context) },
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,83 +35,6 @@ 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.
@@ -134,5 +57,4 @@ 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,24 +1,14 @@
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 dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
import dev.jdtech.mpv.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
}
@@ -30,16 +20,16 @@ internal class MpvEndFileDiagnostics {
}
fun onEndFile(event: MpvEvent.EndFile): Map<String, Any>? {
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
}
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 }
}
errorMessage = null
return data.takeIf { it.isNotEmpty() }
return data
}
}
@@ -1,54 +0,0 @@
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 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.
// How long a Dart `dispose` waits for the native teardown before being
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
// session's surface/audio release); small against the alternative, which
// is wedging every subsequent playback session behind a hung teardown.
private val disposeWatchdogMs = 6_000L
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
@@ -206,11 +206,6 @@ 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
@@ -269,7 +264,7 @@ open class MpvPlayerPlugin(
}
gen = ++sessionGeneration
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding, subtitleRenderScale, logLevel).apply {
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
@@ -361,10 +356,10 @@ open class MpvPlayerPlugin(
result.success(null)
return@runOnMain
}
// 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.
// A hung native teardown must not wedge the Dart-side release chain:
// answer after the watchdog even if the teardown thread is stuck, so
// the next session can start on a fresh core. The stuck core leaks its
// resources until the process ends — recoverable, unlike the wedge.
val completed = AtomicBoolean(false)
fun completeOnce(reason: String) {
if (completed.compareAndSet(false, true)) {
@@ -474,42 +469,25 @@ open class MpvPlayerPlugin(
result.error("NOT_INITIALIZED", "Player not initialized", null)
return
}
// `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)
}
)
core.command(args.toTypedArray()) { success ->
if (success) {
result.success(null)
} else {
result.error("COMMAND_FAILED", "mpv command failed", args)
}
}
}
private fun handleSetLogLevel(call: MethodCall, result: MethodChannel.Result) {
val level = call.argument<Any>("level") as? String
if (level == null) {
result.error("INVALID_ARGS", "Missing or invalid 'level'", null)
if (call.argument<String>("level") == null) {
result.error("INVALID_ARGS", "Missing 'level'", null)
return
}
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)
}
}
}
result.error(
"UNSUPPORTED",
"Runtime mpv log level changes are not supported on Android",
null
)
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
@@ -630,12 +608,8 @@ 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, sourceId)
channels.emitProperty(propId, value)
}
override fun onEvent(name: String, data: Map<String, Any>?) {
@@ -1,41 +0,0 @@
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()
}
}
@@ -1,86 +0,0 @@
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
}
}
@@ -1,104 +0,0 @@
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,33 +35,6 @@ 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,10 +62,6 @@ 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,9 +2,5 @@ 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,7 +7,6 @@ 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 {
@@ -64,13 +63,8 @@ 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, renderScale: Float = 1f): SurfaceView = SurfaceView(activity).apply {
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
@@ -80,13 +74,6 @@ 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 {
-14
View File
@@ -13,17 +13,3 @@ 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,7 +2,6 @@
#define ANDROID_LOG_INFO 4
#define ANDROID_LOG_WARN 5
#define ANDROID_LOG_ERROR 6
#ifdef __cplusplus
extern "C" {
-47
View File
@@ -1,6 +1,5 @@
#pragma once
#include <cstdarg>
#include <cstdint>
#include <cstring>
#include <string>
@@ -15,10 +14,6 @@ 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*;
@@ -33,53 +28,11 @@ 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) {
@@ -1,700 +0,0 @@
#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;
}
@@ -1,59 +0,0 @@
#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,10 +2,8 @@ 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
@@ -32,40 +30,6 @@ 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,20 +80,6 @@ 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
@@ -317,5 +303,6 @@ 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,8 +236,7 @@ class ExoPlayerPluginTest {
fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val exoCore = ExoPlayerCore(activity)
val loads = ConcurrentLinkedQueue<List<String>>()
val mpvCore = MpvPlayerCore(activity, true, { _, _ -> Unit }, recordingLoads(loads))
val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit }
val plugin = ExoPlayerPlugin()
val sink = RecordingEventSink()
plugin.onListen(null, sink)
@@ -274,30 +273,15 @@ 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)
// 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(true, getField(plugin, "usingMpvFallback"))
assertEquals(false, getField(plugin, "fallbackInProgress"))
assertNull(getField(plugin, "pendingOpen"))
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
val disposeResult = RecordingResult()
@@ -659,9 +643,7 @@ class ExoPlayerPluginTest {
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
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.
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
core.setPrivateField("desiredPaused", false)
core.setPrivateField("cachedPaused", false)
core.setPrivateField("resumeBlockedByPublicPause", false)
@@ -682,10 +664,11 @@ class ExoPlayerPluginTest {
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
// 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])
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"))
assertFalse(awaitPauseWriteCount(writes, 1))
core.dispose()
}
@@ -694,10 +677,10 @@ class ExoPlayerPluginTest {
fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
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 core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
core.setPrivateField("desiredPaused", true)
core.setPrivateField("cachedPaused", true)
core.setPrivateField("resumeBlockedByPublicPause", true)
val plugin = reusedFallbackPlugin(activity, core)
val result = RecordingResult()
@@ -712,10 +695,9 @@ class ExoPlayerPluginTest {
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
// 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])
assertEquals(false, core.getPrivateField("desiredPaused"))
assertEquals(false, core.getPrivateField("cachedPaused"))
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
assertFalse(awaitPauseWriteCount(writes, 1))
assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
core.dispose()
@@ -973,14 +955,6 @@ 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,20 +126,6 @@ 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,91 +80,6 @@ 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)))
@@ -1,43 +0,0 @@
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,22 +49,6 @@ 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
@@ -774,53 +758,15 @@ class MpvPlayerPluginTest {
}
@Test
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)
fun setLogLevelReportsUnsupported() {
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)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(1, result.completionCount)
MpvPlayerPlugin().onMethodCall(
MethodCall("setLogLevel", mapOf("level" to "warn")),
result
)
assertEquals("UNSUPPORTED", result.errorCode)
assertNull(result.successValue)
}
@@ -832,11 +778,10 @@ class MpvPlayerPluginTest {
assertEquals(
mapOf(
"sourceId" to 73L,
"reason" to 4,
"message" to "Invalid data found when processing input"
),
diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 73L))
diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error))
)
}
@@ -846,10 +791,9 @@ class MpvPlayerPluginTest {
diagnostics.onLogMessage(LogMessage("ffmpeg", LogLevel.Error, "old failure"))
diagnostics.onStartFile()
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)))
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)))
}
@Test
@@ -861,7 +805,6 @@ class MpvPlayerPluginTest {
plugin.onEvent(
"end-file",
mapOf(
"sourceId" to 92L,
"reason" to 4,
"message" to "Failed to open stream"
)
@@ -872,7 +815,6 @@ class MpvPlayerPluginTest {
"type" to "event",
"name" to "end-file",
"data" to mapOf(
"sourceId" to 92L,
"reason" to 4,
"message" to "Failed to open stream"
)
@@ -881,52 +823,6 @@ 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")
@@ -1094,39 +990,6 @@ 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
@@ -1238,11 +1101,9 @@ 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
@@ -1,32 +0,0 @@
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,7 +3,6 @@ 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
@@ -11,6 +10,7 @@ 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,7 +21,8 @@ import org.robolectric.Shadows.shadowOf
@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
+1 -1
View File
@@ -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 mpv-build tarball's newer copy with top
// is NOT what ships: the app packages the libmpv AAR's newer copy with top
// merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
ndkVersion = "29.0.14206865"
-292
View File
@@ -1,292 +0,0 @@
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")
}
-18
View File
@@ -1,18 +0,0 @@
# 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(...);
}
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -1,45 +0,0 @@
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 )
-163
View File
@@ -1,163 +0,0 @@
#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;
}
-13
View File
@@ -1,13 +0,0 @@
#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);
-47
View File
@@ -1,47 +0,0 @@
#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;
};
@@ -1,15 +0,0 @@
# 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`.
@@ -1,67 +0,0 @@
/*
* 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
@@ -1,760 +0,0 @@
/* 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
@@ -1,211 +0,0 @@
/* 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
@@ -1,247 +0,0 @@
/* 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
-71
View File
@@ -1,71 +0,0 @@
#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;
}
-30
View File
@@ -1,30 +0,0 @@
#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;
-13
View File
@@ -1,13 +0,0 @@
#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);
}
}
-24
View File
@@ -1,24 +0,0 @@
#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)
-240
View File
@@ -1,240 +0,0 @@
#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);
}
-122
View File
@@ -1,122 +0,0 @@
#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);
}
-99
View File
@@ -1,99 +0,0 @@
#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();
}
-126
View File
@@ -1,126 +0,0 @@
#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
@@ -1,13 +0,0 @@
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 }
}
}
@@ -1,15 +0,0 @@
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 }
}
}
@@ -1,7 +0,0 @@
package com.edde746.plezy.libmpv
data class LogMessage(
val prefix: String,
val level: LogLevel,
val text: String
)
@@ -1,18 +0,0 @@
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 }
}
}
@@ -1,32 +0,0 @@
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
}
}
}
@@ -1,3 +0,0 @@
package com.edde746.plezy.libmpv
class MpvException(message: String) : RuntimeException(message)
@@ -1,431 +0,0 @@
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" }
}
}
@@ -1,15 +0,0 @@
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)
}
}
@@ -1,35 +0,0 @@
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
}
@@ -1,9 +0,0 @@
package com.edde746.plezy.libmpv
enum class PropertyFormat(internal val nativeValue: Int) {
None(0),
String(1),
Flag(3),
Int64(4),
Double(5)
}
-1
View File
@@ -25,4 +25,3 @@ plugins {
include(":app")
include(":libass")
include(":libmpv")
+6
View File
@@ -1,20 +1,26 @@
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
+5 -5
View File
@@ -283,7 +283,7 @@
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -797,12 +797,12 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */ = {
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/edde746/mpv-build";
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee;
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -810,7 +810,7 @@
/* Begin XCSwiftPackageProductDependency section */
6A8A46202EDB320D0057B88C /* MPVKit */ = {
isa = XCSwiftPackageProductDependency;
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */;
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = MPVKit;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
@@ -28,11 +28,11 @@
}
},
{
"identity" : "mpv-build",
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
}
},
{
@@ -28,11 +28,11 @@
}
},
{
"identity" : "mpv-build",
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
}
},
{
+2 -37
View File
@@ -54,7 +54,7 @@ final class RecordingLifecycleDelegate: MpvPlayerDelegate {
private(set) var events: [String] = []
private(set) var properties: [String] = []
func onPropertyChange(name: String, value: Any?, sourceId: Int64?) {
func onPropertyChange(name: String, value: Any?) {
properties.append(name)
}
@@ -108,41 +108,6 @@ 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)
@@ -364,7 +329,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, sourceId: 7)
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
XCTAssertTrue(core.beginDisposal())
}
if Thread.isMainThread {
+1 -1
View File
@@ -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 BUGS_APPLE_ARCHIVE=build/ios/archive/Runner.xcarchive ./scripts/upload-symbols.sh ios")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
upload_to_app_store(
ipa: "../build/ios/ipa/Plezy.ipa",
+35 -15
View File
@@ -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,23 +20,24 @@ 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 and asks
/// [PlexHomeService] to reload (or fetch) it.
/// `plex_home_users_{connectionId}` SharedPreferences slot so
/// [PlexHomeService] picks it up on cold start.
class ConnectionBootstrap {
ConnectionBootstrap({
required this.storage,
required this.connectionRegistry,
required this.serverRegistry,
required this.profileRegistry,
required this.plexHome,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
}) : _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
final StorageService storage;
final ConnectionRegistry connectionRegistry;
final ServerRegistry serverRegistry;
final ProfileRegistry profileRegistry;
final PlexHomeService plexHome;
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
@@ -62,9 +63,6 @@ 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');
@@ -149,12 +147,9 @@ class ConnectionBootstrap {
/// profile. Plex users are never persisted as local Plezy profiles.
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
if (copied) {
await plexHome.reloadFromStorage();
} else {
await plexHome.refresh(account);
}
final hydratedUsers = plexHome.current[account.id] ?? const [];
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
users ??= await _fetchAndCachePlexHomeUsers(account);
final hydratedUsers = users;
if (hydratedUsers.isEmpty) return false;
final legacyActiveUuid = storage.getCurrentUserUUID();
@@ -201,6 +196,31 @@ class ConnectionBootstrap {
}
}
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
if (raw == null || raw.isEmpty) return null;
try {
return decodePlexHomeUsersCache(raw);
} catch (e, st) {
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
return null;
}
}
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
try {
final users = await _plexHomeUserFetcher(account.accountToken);
if (users.isNotEmpty) {
await storage.savePlexHomeUsersCache(account.id, encodePlexHomeUsersCache(users));
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
}
return users;
} catch (e, st) {
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
return const [];
}
}
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
if (storage.prefs.getString('home_users_cache') == null) return;
var target = account;
@@ -1,61 +0,0 @@
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),
);
}
}
-14
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../services/device_performance.dart';
import '../theme/mono_tokens.dart';
import '../utils/platform_detector.dart';
class FocusTheme {
FocusTheme._();
@@ -25,19 +24,6 @@ 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(
+46 -234
View File
@@ -1,7 +1,5 @@
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';
@@ -88,11 +86,6 @@ 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) {
@@ -234,14 +227,6 @@ 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
@@ -249,6 +234,7 @@ 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();
@@ -801,29 +787,7 @@ class _FocusableTextInputHost extends StatefulWidget {
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
}
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 {
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
@@ -837,13 +801,10 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false;
_NativeTextInputSession? _nativeTextInputSession;
int _nativeTextInputGeneration = 0;
ui.FlutterView? _nativeTextInputView;
bool _nativeTextInputActivated = false;
bool _hasSeenNativeTextInputFocus = false;
bool _suppressNativeTextInputForCurrentFocus = false;
bool get _nativeTextInputActivated => _nativeTextInputSession?.isActive == true;
bool _nativeTextInputCompletionHandled = false;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@@ -854,40 +815,6 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
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);
@@ -904,17 +831,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
_tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = false;
_suppressTvKeyboardForCurrentFocus = false;
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
_nativeTextInputActivated = false;
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
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);
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
_nativeTextInputActivated = false;
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
@@ -926,7 +849,6 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
@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).
@@ -968,12 +890,11 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
final scope = node.enclosingScope;
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
final generation = _nativeTextInputGeneration;
_setNativeTextInputActivated(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || generation != _nativeTextInputGeneration) return;
if (!mounted) return;
final target = _installedFocusNode;
if (!identical(target, node) || target == null || target.hasFocus || !target.canRequestFocus) return;
if (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.
@@ -987,7 +908,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
if (!focused) {
_suppressNativeTextInputForCurrentFocus = false;
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
_setNativeTextInputActivated(false);
return;
}
if (_suppressNativeTextInputForCurrentFocus) return;
@@ -1003,109 +924,47 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
case TvTextInputAutoOpenBehavior.automatic:
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
_hasSeenNativeTextInputFocus = true;
_beginNativeTextInput();
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenNativeTextInputFocus) {
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = true;
return;
}
_beginNativeTextInput();
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.never:
return;
}
}
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++;
}
void _setNativeTextInputActivated(bool activated) {
if (_nativeTextInputActivated == activated) return;
if (activated) _nativeTextInputCompletionHandled = false;
if (!mounted) {
_nativeTextInputActivated = activated;
return;
}
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();
});
setState(() => _nativeTextInputActivated = activated);
}
void _activateNativeTextInput() {
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = false;
_beginNativeTextInput();
_setNativeTextInputActivated(true);
}
VoidCallback? get _effectiveOnEditingComplete {
final input = widget.input;
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
final session = _nativeTextInputSession;
return () => _handleNativeEditingComplete(session);
return _handleNativeEditingComplete;
}
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);
void _handleNativeEditingComplete() {
if (_nativeTextInputCompletionHandled) return;
_nativeTextInputCompletionHandled = true;
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
final input = widget.input;
final callback = input._effectiveOnEditingComplete;
@@ -1275,8 +1134,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
/// 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) {
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
}
// No-op for the Flutter presentation when no overlay is up: setting its
@@ -1296,66 +1156,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
_suppressNativeTextInputForCurrentFocus = true;
_tvKeyboardOpenScheduled = false;
if (widget.input._usesNativeTvKeyboard) {
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
_setNativeTextInputActivated(false);
}
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
final generation = _nativeTextInputGeneration;
focusNode.requestFocus();
scheduleMicrotask(() {
if (!mounted ||
generation != _nativeTextInputGeneration ||
!identical(focusNode, _installedFocusNode) ||
focusNode.hasFocus ||
_tvKeyboardOpen ||
_tvKeyboardOpenScheduled) {
return;
}
if (!mounted || 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');
@@ -1387,9 +1198,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
// keyboard while Flutter focus stayed on the field. Restore the
// read-only gate so this press navigates Flutter instead of reopening
// the input connection.
_endNativeTextInput(
event.isTvSelectEvent ? _NativeTextInputEndReason.restart : _NativeTextInputEndReason.dismissed,
);
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
activateNativeTextInput = true;
if (event.logicalKey.isBackKey) {
// This is the Menu press that dismissed UIKit's keyboard. Consume its
@@ -1397,28 +1207,30 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
return KeyEventResult.handled;
}
if (event.isTvSelectEvent) {
_scheduleNativeTextInputReactivation();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
return KeyEventResult.handled;
}
} else if (event.logicalKey.isBackKey) {
// Android: a healthy IME consumes Back to dismiss itself before the
// app ever sees it. One arriving here means the keyboard is already
// gone (or its key session is broken and MainActivity's repair budget
// ran out): close the session and 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();
// ran out): close the session and consume the press so it cannot also
// pop the route underneath.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
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).
_endNativeTextInput(_NativeTextInputEndReason.restart);
_scheduleNativeTextInputReactivation();
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
return KeyEventResult.handled;
}
// Android arrows fall through deliberately: a healthy visible IME
@@ -1459,7 +1271,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false);
_endNativeTextInput(_NativeTextInputEndReason.focusLost, rebuild: false);
_nativeTextInputActivated = false;
_suppressNativeTextInputForCurrentFocus = false;
final node = _installedFocusNode;
if (node != null) {
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Bir seriya bitdikdə növbətisini avtomatik başlat",
"playNextCountdown": "Növbəti seriya geri sayımı",
"playNextCountdownImmediate": "Dərhal oynat",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"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",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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",
@@ -1646,7 +1628,6 @@
"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",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Təşkilatçı seansı bitirdi"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi"
},
"services": {
"title": "Xidmətlər",
+6 -33
View File
@@ -169,8 +169,6 @@
"alwaysKeepSidebarOpenDescription": "Страничната лента остава разгъната и зоната със съдържание се наглася да пасне",
"showUnwatchedCount": "Показвай броя негледани",
"showUnwatchedCountDescription": "Показвай броя негледани епизоди при сериали и сезони",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Показвай номера на епизода върху картите",
"showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите",
"showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Пускай следващия епизод автоматично, когато текущият свърши",
"playNextCountdown": "Отброяване до следващия епизод",
"playNextCountdownImmediate": "Пусни веднага",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"autoSkipIntro": "Автоматично прескачане на интро",
"autoSkipIntroDescription": "Автоматично прескачай интро маркери след няколко секунди",
"autoSkipCredits": "Автоматично прескачане на финални надписи",
"autoSkipCreditsDescription": "Автоматично прескачай финалните надписи и пускай следващия епизод",
"forceSkipMarkerFallback": "Принуди резервни маркери",
"forceSkipMarkerFallbackDescription": "Използвай шаблони в заглавията на главите дори когато Plex има маркери",
"autoSkipDelay": "Забавяне за автоматично прескачане",
@@ -779,8 +770,6 @@
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
"playbackCancelled": "Възпроизвеждането беше отменено.",
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"notInLibrary": "Не е в твоята библиотека",
"inTheseLibraries": "В тези библиотеки",
"checkingLibrary": "Проверка на твоята библиотека...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Тук все още няма нищо",
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
"searchHint": "Търсене в ${source}",
@@ -1646,7 +1628,6 @@
"participantPaused": "${name} постави на пауза",
"participantResumed": "${name} продължи",
"participantSeeked": "${name} промени позицията на възпроизвеждане",
"participantChangedSpeed": "",
"participantBuffering": "${name} буферира",
"participantNeedsUpdate": "${name} е с по-стара версия на приложението — синхронизирането не е налично",
"resumingWithout": "Продължаване без ${name}",
@@ -1662,8 +1643,7 @@
"timedOut": "Релейният сървър не отговори навреме",
"connectionLost": "Връзката се затвори, преди сесията да е готова",
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
"sessionEnded": "Организаторът прекрати сесията",
"sessionUnavailable": ""
"sessionEnded": "Организаторът прекрати сесията"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"qualityProfile": "Профил за качество",
"rootFolder": "Основна папка",
"languageProfile": "Езиков профил",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Заявката е изпратена",
"requestFailed": "Заявката се провали: ${error}",
"requestsLoadFailed": "Неуспешно зареждане на опциите за заявка",
@@ -2109,7 +2085,6 @@
"statusBlocklisted": "В списъка с блокирани",
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
@@ -2119,9 +2094,7 @@
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
"noUserInformation": "Seerr не върна информация за потребителя",
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
},
"services": {
"title": "Услуги",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Start automatisk næste afsnit, når et afsnit slutter",
"playNextCountdown": "Nedtælling til næste afsnit",
"playNextCountdownImmediate": "Afspil med det samme",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"forceSkipMarkerFallback": "Tving reservemarkører",
"forceSkipMarkerFallbackDescription": "Brug mønstre i kapiteltitler, selv når Plex har markører",
"autoSkipDelay": "Forsinkelse ved automatisk spring",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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}",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Værten afsluttede sessionen"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"qualityProfile": "Kvalitetsprofil",
"rootFolder": "Rodmappe",
"languageProfile": "Sprogprofil",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Anmodning sendt",
"requestFailed": "Anmodning mislykkedes: ${error}",
"requestsLoadFailed": "Kunne ikke indlæse anmodningsmuligheder",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen"
},
"services": {
"title": "Tjenester",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Die nächste Episode automatisch starten, wenn die aktuelle endet",
"playNextCountdown": "Countdown bis zur nächsten Episode",
"playNextCountdownImmediate": "Sofort abspielen",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"forceSkipMarkerFallback": "Ersatzmarkierungen erzwingen",
"forceSkipMarkerFallbackDescription": "Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt",
"autoSkipDelay": "Verzögerung für automatisches Überspringen",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Der Host hat die Sitzung beendet"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"qualityProfile": "Qualitätsprofil",
"rootFolder": "Stammordner",
"languageProfile": "Sprachprofil",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Anfrage gesendet",
"requestFailed": "Anfrage fehlgeschlagen: ${error}",
"requestsLoadFailed": "Anfrageoptionen konnten nicht geladen werden",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt"
},
"services": {
"title": "Dienste",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Start the next episode automatically when one ends",
"playNextCountdown": "Play Next Countdown",
"playNextCountdownImmediate": "Play immediately",
"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",
"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",
"forceSkipMarkerFallback": "Force Fallback Markers",
"forceSkipMarkerFallbackDescription": "Use chapter title patterns even when Plex has markers",
"autoSkipDelay": "Auto Skip Delay",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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}",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": "Unable to resume this session. Join or create a room to continue."
"sessionEnded": "The host ended the session"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "Seerr denied this action: your account no longer has the required permission",
"permissionRevoked": "You no longer have permission to request this"
"sessionRejectedAfterReauth": "The session was rejected after signing in again"
},
"services": {
"title": "Services",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Iniciar automáticamente el siguiente episodio cuando termine el actual",
"playNextCountdown": "Cuenta atrás para el siguiente episodio",
"playNextCountdownImmediate": "Reproducir de inmediato",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"forceSkipMarkerFallback": "Forzar marcadores alternativos",
"forceSkipMarkerFallbackDescription": "Usar patrones de títulos de capítulos aunque Plex tenga marcadores",
"autoSkipDelay": "Retraso del salto automático",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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}",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "El anfitrión finalizó la sesión"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión"
},
"services": {
"title": "Servicios",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Lancer automatiquement l'épisode suivant lorsqu'un épisode se termine",
"playNextCountdown": "Compte à rebours avant l'épisode suivant",
"playNextCountdownImmediate": "Lire immédiatement",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"autoSkipIntro": "Passer automatiquement lintroduction",
"autoSkipIntroDescription": "Passer automatiquement les marqueurs dintroduction après quelques secondes",
"autoSkipCredits": "Passer automatiquement le générique",
"autoSkipCreditsDescription": "Passer automatiquement le générique et lire l’épisode suivant",
"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",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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 quelles contiendront des éléments.",
"searchHint": "Rechercher dans ${source}",
@@ -1646,7 +1628,6 @@
"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 lapp — synchronisation indisponible",
"resumingWithout": "Reprise sans ${name}",
@@ -1662,8 +1643,7 @@
"timedOut": "Le serveur relais na pas répondu à temps",
"connectionLost": "La connexion sest fermée avant que la session ne soit prête",
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
"sessionEnded": "Lhôte a mis fin à la session",
"sessionUnavailable": ""
"sessionEnded": "Lhôte a mis fin à la session"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"statusBlocklisted": "Sur la liste de blocage",
"couldNotReach": "Impossible de joindre ${url} : ${error}",
"noInstanceAtUrl": "Aucune instance Seerr à ladresse ${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 nest pas terminée",
@@ -2119,9 +2094,7 @@
"noSessionCookie": "Seerr na pas fourni de cookie de session",
"freshCookieRejected": "Seerr a refusé le nouveau cookie de session",
"noUserInformation": "Seerr na renvoyé aucune information sur lutilisateur",
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion"
},
"services": {
"title": "Services",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"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",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"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",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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}",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "A házigazda befejezte a munkamenetet"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították"
},
"services": {
"title": "Szolgáltatások",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Avvia automaticamente l'episodio successivo quando termina quello in riproduzione",
"playNextCountdown": "Conto alla rovescia del successivo",
"playNextCountdownImmediate": "Riproduci subito",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"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",
@@ -779,8 +770,6 @@
"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",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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}",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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": "Lhost ha terminato la sessione",
"sessionUnavailable": ""
"sessionEnded": "Lhost ha terminato la sessione"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso"
},
"services": {
"title": "Servizi",
+6 -32
View File
@@ -169,8 +169,6 @@
"alwaysKeepSidebarOpenDescription": "サイドバーを展開したままにし、コンテンツ領域を幅に合わせて調整します",
"showUnwatchedCount": "未視聴数を表示",
"showUnwatchedCountDescription": "番組とシーズンに未視聴エピソード数を表示",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "カードにエピソード番号を表示",
"showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します",
"showSeasonPostersOnTabs": "タブにシーズンポスターを表示",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "エピソードが終了すると、次のエピソードを自動的に再生します",
"playNextCountdown": "次回再生のカウントダウン",
"playNextCountdownImmediate": "すぐに再生",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"autoSkipIntro": "イントロを自動スキップ",
"autoSkipIntroDescription": "数秒後にイントロマーカーを自動的にスキップ",
"autoSkipCredits": "クレジットを自動スキップ",
"autoSkipCreditsDescription": "クレジットを自動的にスキップして次のエピソードを再生",
"forceSkipMarkerFallback": "フォールバックマーカーを強制",
"forceSkipMarkerFallbackDescription": "Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します",
"autoSkipDelay": "自動スキップの遅延",
@@ -775,8 +766,6 @@
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
"playbackCancelled": "再生がキャンセルされました。",
"playbackFailed": "再生を開始できませんでした。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
"musicNotSupported": "音楽の再生はまだサポートされていません",
@@ -862,9 +851,6 @@
"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": {
@@ -1259,9 +1245,6 @@
"notInLibrary": "ライブラリにありません",
"inTheseLibraries": "これらのライブラリにあります",
"checkingLibrary": "ライブラリを確認中…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "まだ何もありません",
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
"searchHint": "${source}を検索",
@@ -1635,7 +1618,6 @@
"participantPaused": "${name}が一時停止しました",
"participantResumed": "${name}が再開しました",
"participantSeeked": "${name}が再生位置を変更しました",
"participantChangedSpeed": "",
"participantBuffering": "${name}がバッファリング中",
"participantNeedsUpdate": "${name}は古いバージョンのアプリを使用しているため、同期できません",
"resumingWithout": "${name}抜きで再開",
@@ -1651,8 +1633,7 @@
"timedOut": "リレーサーバーが時間内に応答しませんでした",
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
"sessionEnded": "ホストがセッションを終了しました",
"sessionUnavailable": ""
"sessionEnded": "ホストがセッションを終了しました"
}
},
"downloads": {
@@ -2083,10 +2064,6 @@
"qualityProfile": "画質プロファイル",
"rootFolder": "ルートフォルダ",
"languageProfile": "言語プロファイル",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "リクエストを送信しました",
"requestFailed": "リクエストに失敗しました: ${error}",
"requestsLoadFailed": "リクエストオプションを読み込めませんでした",
@@ -2098,7 +2075,6 @@
"statusBlocklisted": "ブロックリスト登録済み",
"couldNotReach": "${url}に接続できませんでした: ${error}",
"noInstanceAtUrl": "${url}にSeerrインスタンスがありません(HTTP ${status}",
"behindAuthProxy": "",
"invalidUrl": "https://seerr.example.comのようなサーバーアドレスを入力してください",
"quickConnectUnsupported": "このSeerrインスタンスはQuick Connectに対応していません。Seerr 3.4以降が必要です。",
"notInitialized": "このSeerrインスタンスでは初回セットアップが完了していません",
@@ -2108,9 +2084,7 @@
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
},
"services": {
"title": "サービス",
+6 -33
View File
@@ -169,8 +169,6 @@
"alwaysKeepSidebarOpenDescription": "Жүйелік мәзір ашық күйінде қалады",
"showUnwatchedCount": "Көрілмегендер санын көрсету",
"showUnwatchedCountDescription": "Сериалдар мен маусымдарда көрілмеген бөлімдер санын көрсету",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Карточкаларда бөлім нөмірін көрсету",
"showEpisodeNumberOnCardsDescription": "Бөлім карточкаларында маусым мен бөлім нөмірін көрсету",
"showSeasonPostersOnTabs": "Қойындыларда маусым постерлерін көрсету",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау",
"playNextCountdown": "Келесіні ойнату санағы",
"playNextCountdownImmediate": "Дереу ойнату",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"autoSkipIntro": "Киріс бөлімді (Intro) автоматты өткізу",
"autoSkipIntroDescription": "Бірнеше секундтан кейін киріс белгілерін автоматты өткізу",
"autoSkipCredits": "Титрлерді автоматты өткізу",
"autoSkipCreditsDescription": "Титрлерді автоматты өткізіп, келесі бөлімді ойнату",
"forceSkipMarkerFallback": "Қосалқы белгілерді мәжбүрлеу",
"forceSkipMarkerFallbackDescription": "Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану",
"autoSkipDelay": "Автоматты өткізу кідірісі",
@@ -779,8 +770,6 @@
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
"playbackCancelled": "Ойнатудан бас тартылды.",
"playbackFailed": "Ойнатуды іске қосу қатесі.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
"errorLoadingSeries": "Сериалды жүктеу қатесі",
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"notInLibrary": "Кітапханаңызда жоқ",
"inTheseLibraries": "Осы кітапханаларда бар",
"checkingLibrary": "Кітапхана тексерілуде...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
"searchHint": "${source} ішінен іздеу",
@@ -1646,7 +1628,6 @@
"participantPaused": "${name} кідіртті",
"participantResumed": "${name} жалғастырды",
"participantSeeked": "${name} уақытты өзгертті",
"participantChangedSpeed": "",
"participantBuffering": "${name} буферлеуде",
"participantNeedsUpdate": "${name} ескі нұсқада",
"resumingWithout": "${name} ескерусіз жалғастырылуда",
@@ -1662,8 +1643,7 @@
"timedOut": "Реле сервері уақытында жауап бермеді",
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
"sessionUnavailable": ""
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"qualityProfile": "Сапа профилі",
"rootFolder": "Түпкі қапшық",
"languageProfile": "Тіл профилі",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Сұрау жіберілді",
"requestFailed": "Сұрау қатесі: ${error}",
"requestsLoadFailed": "Параметрлерді жүктеу мүмкін болмады",
@@ -2109,7 +2085,6 @@
"statusBlocklisted": "Бұғаттау тізімінде",
"couldNotReach": "${url} мекенжайына қосылу мүмкін болмады: ${error}",
"noInstanceAtUrl": "${url} мекенжайында Seerr данасы жоқ (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Сервер мекенжайын енгізіңіз, мысалы: https://seerr.example.com",
"quickConnectUnsupported": "Бұл Seerr данасы Жылдам қосылуды қолдамайды. Оған Seerr 3.4 немесе одан жаңарақ нұсқа қажет.",
"notInitialized": "Бұл Seerr данасының бастапқы баптауы аяқталмаған",
@@ -2119,9 +2094,7 @@
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
},
"services": {
"title": "Қызметтер",
+6 -32
View File
@@ -169,8 +169,6 @@
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
"showUnwatchedCount": "미시청 수 표시",
"showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "카드에 에피소드 번호 표시",
"showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시",
"showSeasonPostersOnTabs": "탭에 시즌 포스터 표시",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "에피소드가 끝나면 다음 에피소드를 자동으로 재생",
"playNextCountdown": "다음 재생 카운트다운",
"playNextCountdownImmediate": "즉시 재생",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"autoSkipIntro": "자동으로 오프닝 건너뛰기",
"autoSkipIntroDescription": "몇 초 후 오프닝을 자동으로 건너뛰기",
"autoSkipCredits": "자동으로 엔딩 건너뛰기",
"autoSkipCreditsDescription": "엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생",
"forceSkipMarkerFallback": "대체 마커 강제 사용",
"forceSkipMarkerFallbackDescription": "Plex에 마커가 있어도 챕터 제목 패턴 사용",
"autoSkipDelay": "자동 건너뛰기 지연",
@@ -775,8 +766,6 @@
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
"playbackCancelled": "재생이 취소되었습니다.",
"playbackFailed": "재생을 시작할 수 없습니다.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
"musicNotSupported": "음악 재생 미지원",
@@ -862,9 +851,6 @@
"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": {
@@ -1259,9 +1245,6 @@
"notInLibrary": "라이브러리에 없음",
"inTheseLibraries": "이 라이브러리에 있음",
"checkingLibrary": "라이브러리 확인 중...",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "아직 아무것도 없습니다",
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
"searchHint": "${source}에서 검색",
@@ -1635,7 +1618,6 @@
"participantPaused": "${name}님이 일시정지했습니다",
"participantResumed": "${name}님이 재생했습니다",
"participantSeeked": "${name}님이 재생 위치를 변경했습니다",
"participantChangedSpeed": "",
"participantBuffering": "${name}님이 버퍼링 중입니다",
"participantNeedsUpdate": "${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다",
"resumingWithout": "${name}님 없이 재생을 재개합니다",
@@ -1651,8 +1633,7 @@
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
"sessionEnded": "호스트가 세션을 종료했습니다",
"sessionUnavailable": ""
"sessionEnded": "호스트가 세션을 종료했습니다"
}
},
"downloads": {
@@ -2083,10 +2064,6 @@
"qualityProfile": "화질 프로파일",
"rootFolder": "루트 폴더",
"languageProfile": "언어 프로파일",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "요청을 제출했습니다",
"requestFailed": "요청 실패: ${error}",
"requestsLoadFailed": "요청 옵션을 불러올 수 없습니다",
@@ -2098,7 +2075,6 @@
"statusBlocklisted": "차단 목록에 있음",
"couldNotReach": "${url}에 연결할 수 없습니다: ${error}",
"noInstanceAtUrl": "${url}에 Seerr 인스턴스가 없습니다(HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "https://seerr.example.com과 같은 서버 주소를 입력하세요",
"quickConnectUnsupported": "이 Seerr 인스턴스는 Quick Connect를 지원하지 않습니다. Seerr 3.4 이상이 필요합니다.",
"notInitialized": "이 Seerr 인스턴스는 최초 실행 설정을 완료하지 않았습니다",
@@ -2108,9 +2084,7 @@
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
},
"services": {
"title": "서비스",
+6 -33
View File
@@ -169,8 +169,6 @@
"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",
@@ -275,17 +273,10 @@
"autoPlayNextEpisodeDescription": "Start neste episode automatisk når en episode er ferdig",
"playNextCountdown": "Nedtelling for neste episode",
"playNextCountdownImmediate": "Spill umiddelbart",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"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",
"forceSkipMarkerFallback": "Tving reservemarkører",
"forceSkipMarkerFallbackDescription": "Bruk mønstre i kapiteltitler selv når Plex har markører",
"autoSkipDelay": "Forsinkelse for automatisk hopp",
@@ -779,8 +770,6 @@
"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å",
@@ -866,9 +855,6 @@
"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": {
@@ -1265,10 +1251,6 @@
"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}",
@@ -1646,7 +1628,6 @@
"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}",
@@ -1662,8 +1643,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Verten avsluttet økten"
}
},
"downloads": {
@@ -2094,10 +2074,6 @@
"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",
@@ -2109,7 +2085,6 @@
"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",
@@ -2119,9 +2094,7 @@
"noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel",
"freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen",
"noUserInformation": "Seerr returnerte ingen brukerinformasjon",
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging"
},
"services": {
"title": "Tjenester",

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