Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6ee0861e | ||
|
|
986e769ac0 | ||
|
|
b61fc9e661 | ||
|
|
fa8da33ec8 | ||
|
|
44599549e1 | ||
|
|
e46b3dab7c | ||
|
|
e67bf996c5 | ||
|
|
66cc6fc192 | ||
|
|
691d10b3c6 | ||
|
|
42b9bf7fb8 | ||
|
|
2efd300a5f | ||
|
|
8b1ac156f3 | ||
|
|
be8fa15733 | ||
|
|
fa054153ae | ||
|
|
a230d51598 | ||
|
|
e4f3888694 | ||
|
|
2087dff32d | ||
|
|
d112ade601 | ||
|
|
6301d5124e | ||
|
|
13acd41b09 | ||
|
|
0003d95c0a | ||
|
|
4d2aa8f9a2 | ||
|
|
deadf712a8 | ||
|
|
ef91156827 | ||
|
|
42b6710f04 | ||
|
|
7e583ef05a | ||
|
|
2132c23fab | ||
|
|
c4be378219 | ||
|
|
f967b9b2f2 | ||
|
|
eafacff3be | ||
|
|
33d845dcdd | ||
|
|
0134d9dbc0 | ||
|
|
4fd9034e8e | ||
|
|
51beb5ac4b | ||
|
|
b43cd81102 | ||
|
|
5112d71820 | ||
|
|
ab477c971d | ||
|
|
59f44d9ae1 | ||
|
|
de39e15e36 | ||
|
|
7b1b0d766b | ||
|
|
960adf42f2 | ||
|
|
d181bd34ae | ||
|
|
b27cab4510 | ||
|
|
17c877c973 | ||
|
|
1373c299df | ||
|
|
7e84df6385 | ||
|
|
e8e67df379 | ||
|
|
c70f88cee0 | ||
|
|
54efd4fe73 | ||
|
|
deaa06826c | ||
|
|
d371bfed3a | ||
|
|
aeb06c2c9e | ||
|
|
9a9b89082b | ||
|
|
c79ff68a7b | ||
|
|
7e1f18f93f | ||
|
|
5bd742c8b8 | ||
|
|
5c37c72a08 | ||
|
|
22c1ad56d3 | ||
|
|
3b3a816de0 | ||
|
|
3fc229cd6c | ||
|
|
b5186624b8 | ||
|
|
0c809491c7 | ||
|
|
5860079b43 | ||
|
|
ce03857588 | ||
|
|
200d896bea | ||
|
|
8fe7e4bcfd | ||
|
|
d2b5039bb3 | ||
|
|
12b8c7e7ef | ||
|
|
e9988165df | ||
|
|
582d419618 | ||
|
|
72d15b96a2 | ||
|
|
d9e31fd1a4 |
+24
-19
@@ -414,9 +414,7 @@ jobs:
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
flutter_setup: git
|
||||
native_cache_path: |
|
||||
build/windows/arm64/_deps
|
||||
build/windows/arm64/mpv-dev-arm64
|
||||
native_cache_path: build/windows/arm64/_deps
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -427,12 +425,7 @@ jobs:
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ${{ matrix.native_cache_path }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: choco install 7zip -y
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt', 'mpv-build.lock.json') }}
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
@@ -585,10 +578,10 @@ jobs:
|
||||
|
||||
- name: Read version from pubspec
|
||||
id: version
|
||||
shell: pwsh
|
||||
shell: bash
|
||||
run: |
|
||||
$v = (Select-String -Path pubspec.yaml -Pattern '^version:\s*(\S+)').Matches[0].Groups[1].Value -replace '\+.*'
|
||||
echo "version=$v" >> $env:GITHUB_OUTPUT
|
||||
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
|
||||
echo "version=${VERSION%%+*}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build installer and portables
|
||||
run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
|
||||
@@ -725,17 +718,26 @@ jobs:
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Cache libmpv build
|
||||
- name: Cache libmpv prefix
|
||||
id: libmpv-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
# The prebuilt prefix from our unified mpv-build repo: the script reads
|
||||
# the asset and expected SHA-256 from the lock and verifies the bytes
|
||||
# before a single one is extracted. The tarball is a self-relocating
|
||||
# prefix tree (lib/ or lib/<triplet>/ with the libmpv.so* chain,
|
||||
# lib/libshaderc_shared.so*, include/, and pkgconfig/ dirs with
|
||||
# ${pcfiledir}-relative roots), so the downstream PKG_CONFIG_PATH,
|
||||
# bundle-copy, and find steps are unchanged.
|
||||
- name: Fetch libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: bash linux/packaging/build-libmpv.sh
|
||||
run: |
|
||||
command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y --no-install-recommends zstd; }
|
||||
python3 scripts/fetch_linux_libmpv.py --dest libmpv-prefix
|
||||
|
||||
- name: Install fpm
|
||||
shell: bash
|
||||
@@ -872,15 +874,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
sparse-checkout: pubspec.yaml
|
||||
sparse-checkout: |
|
||||
pubspec.yaml
|
||||
scripts/pubspec_version.py
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read version from pubspec.yaml
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
|
||||
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
|
||||
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
|
||||
BUILD_NUMBER=${VERSION#*+}
|
||||
VERSION=${VERSION%%+*}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
+23
-20
@@ -252,9 +252,6 @@ jobs:
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition and build plan
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
name: Linux native reliability (${{ matrix.sanitizer }})
|
||||
runs-on: ubuntu-latest
|
||||
@@ -404,7 +401,9 @@ jobs:
|
||||
|
||||
- name: Verify tvOS project wiring
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: ruby tvos/scripts/test_wire_mpv.rb
|
||||
run: |
|
||||
ruby tvos/scripts/test_wire_mpv.rb
|
||||
ruby tvos/scripts/test_wire_top_shelf.rb
|
||||
|
||||
- name: Select Apple test destination
|
||||
env:
|
||||
@@ -645,10 +644,10 @@ jobs:
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
# The packaging deps, minus libmpv: this job builds it from source below,
|
||||
# because the distro's is a different version with different windowing
|
||||
# backends, and a package smoke-built against it cannot show that
|
||||
# build-libmpv.sh still works or that the bundle it produces is coherent.
|
||||
# The packaging deps, minus libmpv: that arrives prebuilt from mpv-build
|
||||
# below. The dev packages still matter — they install the runtime
|
||||
# libraries the pinned libmpv links (ass, pulse, pipewire, ...), which
|
||||
# bundle-libs.sh resolves off the host into the bundle.
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -662,25 +661,29 @@ jobs:
|
||||
liblua5.2-dev rpm libarchive-tools imagemagick ruby-dev build-essential
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
|
||||
# Keyed the same way build.yml keys it, so editing the script or its pinned
|
||||
# inputs is what invalidates the cache - and this branch's whole point is
|
||||
# that those edits get exercised somewhere.
|
||||
- name: Cache libmpv build
|
||||
# Keyed the same way build.yml keys it: the lock names the asset and its
|
||||
# checksum, so editing the lock is what invalidates the cache.
|
||||
- name: Cache libmpv prefix
|
||||
id: libmpv-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
# Same contract as build.yml: the script reads mpv-build.lock.json,
|
||||
# downloads the linux asset for this arch, verifies its SHA-256, and
|
||||
# extracts the prefix tree.
|
||||
- name: Fetch libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
run: bash linux/packaging/build-libmpv.sh
|
||||
run: |
|
||||
command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y --no-install-recommends zstd; }
|
||||
python3 scripts/fetch_linux_libmpv.py --dest libmpv-prefix
|
||||
|
||||
# The windowing backends the runner depends on, read off the library the
|
||||
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
|
||||
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
|
||||
# in software; X11 and VDPAU are gone with the texture path.
|
||||
- name: Check the built libmpv's backends
|
||||
# The windowing backends the runner depends on, read off the pinned
|
||||
# library just extracted. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY,
|
||||
# so a libmpv without Wayland cannot find the VAAPI device and quietly
|
||||
# decodes in software; X11 and VDPAU are gone with the texture path.
|
||||
- name: Check the pinned libmpv's backends
|
||||
run: |
|
||||
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
|
||||
echo "== $LIB =="
|
||||
|
||||
@@ -205,4 +205,4 @@ Plezy is licensed under [GPL-3.0](LICENSE).
|
||||
|
||||
- Built with [Flutter](https://flutter.dev)
|
||||
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
|
||||
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
|
||||
- Playback powered by [mpv](https://mpv.io) via our [mpv-build](https://github.com/edde746/mpv-build) pipeline (started as a fork of [MPVKit](https://github.com/mpvkit/MPVKit); the Android Kotlin/JNI glue descends from [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), and [libass-android](https://github.com/peerless2012/libass-android)
|
||||
|
||||
@@ -58,95 +58,31 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.2.2"
|
||||
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
// Dev-only escape hatch: point the build at a locally built libmpv AAR
|
||||
// (libmpv-android's build-local.sh output) to test fork patches before a
|
||||
// release is pinned. The URL + sha256 above stay authoritative otherwise.
|
||||
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
|
||||
// The in-project :libmpv module owns the mpv-build pin (repo-root
|
||||
// mpv-build.lock.json assets + checksums, plus the plezy.localMpvDir/
|
||||
// PLEZY_LOCAL_MPV_DIR escape hatch) and extracts the per-ABI tarballs'
|
||||
// prebuilt native libraries. This file reads two of its output trees
|
||||
// back: FFmpeg .so files for the Media3 adapter link step, and the libc++
|
||||
// runtime packaged at PROJECT scope below.
|
||||
val libmpvBuildDir = project(":libmpv").layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val libmpvNativeJniDir = File(libmpvBuildDir, "native/jni")
|
||||
val libmpvLibcxxJniDir = File(libmpvBuildDir, "libcxx/jni")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvDir, ".manifest")
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
inputs.property("localOverride", localMpvAar ?: "")
|
||||
localMpvAar?.let { inputs.file(it) }
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
if (localMpvAar != null) {
|
||||
File(localMpvAar).copyTo(stagedAar, overwrite = true)
|
||||
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
|
||||
} else {
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
}
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
||||
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
||||
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val outDir = File(mpvDir, "libcxx")
|
||||
inputs.file(aar)
|
||||
outputs.dir(outDir)
|
||||
doLast {
|
||||
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
||||
outDir.mkdirs()
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"unzip",
|
||||
"-q",
|
||||
"-o",
|
||||
aar.absolutePath,
|
||||
"jni/*/libc++_shared.so",
|
||||
"-d",
|
||||
outDir.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
val mpvFfmpegDevelopmentDir = layout.buildDirectory.dir("libmpv-ffmpeg-development").get().asFile
|
||||
|
||||
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
|
||||
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
|
||||
// build-only; the APK contains one FFmpeg implementation for both players.
|
||||
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
dependsOn(":libmpv:extractLibmpvNative")
|
||||
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
|
||||
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val libraries = listOf("avcodec", "avutil", "swresample")
|
||||
inputs.file(aar)
|
||||
inputs.dir(libmpvNativeJniDir)
|
||||
inputs.property("ffmpegVersion", mpvFfmpegVersion)
|
||||
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
|
||||
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
|
||||
@@ -216,15 +152,12 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
)
|
||||
|
||||
project.copy {
|
||||
from(zipTree(aar)) {
|
||||
from(libmpvNativeJniDir) {
|
||||
include(
|
||||
"jni/*/libavcodec.so",
|
||||
"jni/*/libavutil.so",
|
||||
"jni/*/libswresample.so"
|
||||
"*/libavcodec.so",
|
||||
"*/libavutil.so",
|
||||
"*/libswresample.so"
|
||||
)
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
into(nativeDir)
|
||||
@@ -235,11 +168,11 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
}.filterNot(File::isFile)
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
"the :libmpv prebuilt tree is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
)
|
||||
}
|
||||
File(staging, ".manifest").writeText(
|
||||
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
"ffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
)
|
||||
sourceArchive.delete()
|
||||
extractedSource.deleteRecursively()
|
||||
@@ -349,7 +282,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
minSdk = 25 // Fire OS 6.x (API 25); :libmpv shares the same floor
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@@ -448,8 +381,9 @@ android {
|
||||
packaging {
|
||||
jniLibs {
|
||||
// pickFirst only suppresses the duplicate libc++ merge error; the
|
||||
// sourceSets rule below makes libmpv's newer runtime win for
|
||||
// std::from_chars<float>, while older native consumers remain ABI-compatible.
|
||||
// sourceSets rule below makes the runtime :libmpv extracts from the
|
||||
// mpv-build tarballs win for std::from_chars<float>, while older
|
||||
// native consumers remain ABI-compatible.
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
}
|
||||
@@ -457,8 +391,10 @@ android {
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
|
||||
// order cannot accidentally select the older libc++ copy.
|
||||
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
|
||||
// order cannot accidentally select an older libc++ copy. The directory
|
||||
// is :libmpv's extractLibmpvNative output (the tarballs' 16 KB-capable
|
||||
// libc++), wired below via the JniLibFolders dependency.
|
||||
jniLibs.srcDir(libmpvLibcxxJniDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,16 +450,18 @@ tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative")
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
|
||||
dependsOn(prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted libmpv libc++ directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractMpvLibcxx)
|
||||
dependsOn(":libmpv:extractLibmpvNative")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
// mpv Kotlin API + JNI glue live in-project; the prebuilt libmpv/FFmpeg .so
|
||||
// set rides along from the module's extracted mpv-build tarballs.
|
||||
implementation(project(":libmpv"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
private const val API_VLC_RESULT_POSITION = "extra_position"
|
||||
private const val API_VLC_RESULT_DURATION = "extra_duration"
|
||||
|
||||
// Honored by VLC and the native Zidoo player (com.android.gallery3d /
|
||||
// com.zidoo.player). Without it, a launch with no resume point lets the
|
||||
// player consult its own bookmark store, which on Zidoo collides across
|
||||
// Plex items because every part URL ends in the same `file.<ext>` (#2223).
|
||||
private const val API_VLC_FROM_START = "from_start"
|
||||
|
||||
private const val API_VIMU_TITLE = "forcename"
|
||||
private const val API_VIMU_SEEK_POSITION = "startfrom"
|
||||
private const val API_VIMU_RESUME = "forceresume"
|
||||
@@ -150,7 +156,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
internal data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
|
||||
private fun resolveSource(filePath: String): Source {
|
||||
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||
@@ -168,7 +174,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
return Source(uri, grantRead = true, fileName = file.name)
|
||||
}
|
||||
|
||||
private fun buildIntent(
|
||||
internal fun buildIntent(
|
||||
source: Source,
|
||||
packageName: String?,
|
||||
startPositionMs: Long,
|
||||
@@ -181,6 +187,9 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
if (startPosition > 0) {
|
||||
putExtra(API_MX_RESULT_POSITION, startPosition)
|
||||
putExtra(API_VIMU_SEEK_POSITION, startPosition)
|
||||
putExtra(API_VLC_FROM_START, false)
|
||||
} else {
|
||||
putExtra(API_VLC_FROM_START, true)
|
||||
}
|
||||
putExtra(API_MX_RETURN_RESULT, true)
|
||||
putExtra(API_MX_SECURE_URI, true)
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.edde746.plezy.car.CarRestrictionsMonitor
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.shared.AssistiveTechnologyMonitor
|
||||
import com.edde746.plezy.shared.DeviceQuirks
|
||||
import com.edde746.plezy.shared.MediaCodecQuery
|
||||
import com.edde746.plezy.shared.ThemeHelper
|
||||
@@ -96,9 +97,12 @@ class MainActivity : FlutterActivity() {
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
||||
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
|
||||
private val ASSISTIVE_TECHNOLOGY_CHANNEL = "com.plezy/assistive_technology"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var carRestrictions: CarRestrictionsMonitor? = null
|
||||
private var carRestrictionsChannel: MethodChannel? = null
|
||||
private var assistiveTechnology: AssistiveTechnologyMonitor? = null
|
||||
private var assistiveTechnologyChannel: MethodChannel? = null
|
||||
private var nativeTextInputFocused = false
|
||||
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
|
||||
private var imeShowAttempts = 0
|
||||
@@ -593,6 +597,9 @@ class MainActivity : FlutterActivity() {
|
||||
carRestrictions?.release()
|
||||
carRestrictions = null
|
||||
carRestrictionsChannel = null
|
||||
assistiveTechnology?.release()
|
||||
assistiveTechnology = null
|
||||
assistiveTechnologyChannel = null
|
||||
activityStarted = false
|
||||
flutterSurfaceReconnectPending = false
|
||||
flutterTextureView = null
|
||||
@@ -781,6 +788,19 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val assistiveChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, ASSISTIVE_TECHNOLOGY_CHANNEL)
|
||||
assistiveTechnologyChannel = assistiveChannel
|
||||
val assistiveMonitor = assistiveTechnology ?: AssistiveTechnologyMonitor(applicationContext).also {
|
||||
assistiveTechnology = it
|
||||
}
|
||||
assistiveMonitor.start { runOnUiThread { assistiveTechnologyChannel?.invokeMethod("onChanged", null) } }
|
||||
assistiveChannel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getSignals" -> result.success(assistiveMonitor.signals())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
handleDeviceAdjustmentCall(call.method, call.arguments, result)
|
||||
}
|
||||
|
||||
@@ -322,8 +322,7 @@ internal fun iecRouteSupported(
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
|
||||
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
|
||||
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
|
||||
@@ -371,8 +370,7 @@ private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCou
|
||||
}
|
||||
|
||||
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat =
|
||||
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
|
||||
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(encoding)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import dev.jdtech.mpv.EndFileReason
|
||||
import dev.jdtech.mpv.LogLevel
|
||||
import dev.jdtech.mpv.LogMessage
|
||||
import dev.jdtech.mpv.MpvEvent
|
||||
import com.edde746.plezy.libmpv.EndFileReason
|
||||
import com.edde746.plezy.libmpv.LogLevel
|
||||
import com.edde746.plezy.libmpv.LogMessage
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
|
||||
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
|
||||
internal class MpvEndFileDiagnostics {
|
||||
|
||||
@@ -17,12 +17,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import com.edde746.plezy.exoplayer.DoviBridge
|
||||
import com.edde746.plezy.libmpv.*
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import dev.jdtech.mpv.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -537,7 +537,6 @@ class MpvPlayerCore private constructor(
|
||||
delegate?.onEvent("file-loaded", null)
|
||||
}
|
||||
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
|
||||
/**
|
||||
* Reports whether an enabled accessibility service can consume the app's semantics tree.
|
||||
*
|
||||
* Flutter compiles a semantics tree for every frame while [AccessibilityManager.isEnabled], which
|
||||
* Android sets for any bound service. On TV that is routinely a utility that never reads app
|
||||
* content (a launcher's foreground-app hook, a key remapper), so Dart gates the tree on this
|
||||
* verdict. The verdict errs towards keeping the tree:
|
||||
*
|
||||
* - touch exploration is a screen reader, full stop;
|
||||
* - an empty enabled list while accessibility is on means a [android.app.UiAutomation] client
|
||||
* (instrumentation, Maestro), which is not listed but reads the tree;
|
||||
* - on API 33+ a service flagged `isAccessibilityTool`, or one giving spoken, braille, audible or
|
||||
* visual feedback, consumes; a non-tool with only generic/haptic feedback does not;
|
||||
* - below 33 only a haptic-only service is treated as not consuming, since `feedbackGeneric` is
|
||||
* what Switch Access-style tools declare.
|
||||
*/
|
||||
class AssistiveTechnologyMonitor(context: Context) {
|
||||
private val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
private var onChanged: (() -> Unit)? = null
|
||||
private val stateListener = AccessibilityManager.AccessibilityStateChangeListener { onChanged?.invoke() }
|
||||
private val touchExplorationListener =
|
||||
AccessibilityManager.TouchExplorationStateChangeListener { onChanged?.invoke() }
|
||||
private var servicesListener: AccessibilityManager.AccessibilityServicesStateChangeListener? = null
|
||||
|
||||
fun start(onChanged: () -> Unit) {
|
||||
if (this.onChanged != null) return
|
||||
this.onChanged = onChanged
|
||||
manager.addAccessibilityStateChangeListener(stateListener)
|
||||
manager.addTouchExplorationStateChangeListener(touchExplorationListener)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val listener = AccessibilityManager.AccessibilityServicesStateChangeListener { onChanged() }
|
||||
servicesListener = listener
|
||||
manager.addAccessibilityServicesStateChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (onChanged == null) return
|
||||
onChanged = null
|
||||
manager.removeAccessibilityStateChangeListener(stateListener)
|
||||
manager.removeTouchExplorationStateChangeListener(touchExplorationListener)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
servicesListener?.let { manager.removeAccessibilityServicesStateChangeListener(it) }
|
||||
servicesListener = null
|
||||
}
|
||||
}
|
||||
|
||||
fun signals(): Map<String, Any> {
|
||||
val services = manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||
return mapOf(
|
||||
"accessibilityEnabled" to manager.isEnabled,
|
||||
"touchExplorationEnabled" to manager.isTouchExplorationEnabled,
|
||||
"enabledServiceCount" to services.size,
|
||||
"consumesSemantics" to consumesSemantics(services)
|
||||
)
|
||||
}
|
||||
|
||||
private fun consumesSemantics(services: List<AccessibilityServiceInfo>): Boolean {
|
||||
if (manager.isTouchExplorationEnabled) return true
|
||||
if (services.isEmpty()) return true
|
||||
return services.any { info -> consumesSemantics(info) }
|
||||
}
|
||||
|
||||
private fun consumesSemantics(info: AccessibilityServiceInfo): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (info.isAccessibilityTool) return true
|
||||
return info.feedbackType and READER_FEEDBACK != 0
|
||||
}
|
||||
return info.feedbackType and AccessibilityServiceInfo.FEEDBACK_HAPTIC.inv() != 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READER_FEEDBACK =
|
||||
AccessibilityServiceInfo.FEEDBACK_SPOKEN or
|
||||
AccessibilityServiceInfo.FEEDBACK_BRAILLE or
|
||||
AccessibilityServiceInfo.FEEDBACK_AUDIBLE or
|
||||
AccessibilityServiceInfo.FEEDBACK_VISUAL
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,8 @@ add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
|
||||
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
|
||||
|
||||
add_executable(mpv_utf8_convert_test mpv_utf8_convert_test.cpp)
|
||||
target_compile_features(mpv_utf8_convert_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME mpv_utf8_convert_test COMMAND mpv_utf8_convert_test)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "../../../../libmpv/src/main/cpp/utf8_convert.h"
|
||||
|
||||
namespace {
|
||||
|
||||
using plezy::utf8::FromUtf16;
|
||||
using plezy::utf8::ToUtf16;
|
||||
|
||||
bool check(bool condition, const char* message) {
|
||||
if (!condition) std::fprintf(stderr, "%s\n", message);
|
||||
return condition;
|
||||
}
|
||||
|
||||
bool roundTripsAsciiBmpAndSupplementary() {
|
||||
// "a" U+00E9 U+4E2D U+1F3AC (clapper board) — 1/2/3/4-byte sequences.
|
||||
const std::string utf8 = "a\xC3\xA9\xE4\xB8\xAD\xF0\x9F\x8E\xAC";
|
||||
const std::u16string utf16 = ToUtf16(utf8.c_str());
|
||||
return check(utf16 == u"a\u00E9\u4E2D\U0001F3AC", "UTF-8 -> UTF-16 mismatch") &&
|
||||
check(FromUtf16(utf16.data(), utf16.size()) == utf8, "UTF-16 -> UTF-8 mismatch");
|
||||
}
|
||||
|
||||
bool doesNotEmitModifiedUtf8() {
|
||||
// JNI's modified UTF-8 would encode U+1F3AC as a 6-byte CESU-8 surrogate
|
||||
// pair; mpv/open() need the real 4-byte form.
|
||||
const std::u16string clapper = u"\U0001F3AC";
|
||||
return check(FromUtf16(clapper.data(), clapper.size()) == "\xF0\x9F\x8E\xAC", "supplementary char not 4 bytes") &&
|
||||
check(
|
||||
ToUtf16("\xED\xA0\xBC\xED\xBE\xAC") == u"\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD",
|
||||
"CESU-8 surrogate bytes accepted as UTF-8");
|
||||
}
|
||||
|
||||
bool replacesMalformedBytesOneAtATime() {
|
||||
return check(ToUtf16("ok\xFF\xFEz") == u"ok\uFFFD\uFFFDz", "stray bytes not replaced individually") &&
|
||||
check(ToUtf16("\xC0\x80") == u"\uFFFD\uFFFD", "overlong NUL accepted") &&
|
||||
check(ToUtf16("\xE2\x82") == u"\uFFFD\uFFFD", "truncated sequence not replaced") &&
|
||||
check(ToUtf16("\xF4\x90\x80\x80") == u"\uFFFD\uFFFD\uFFFD\uFFFD", "code point above U+10FFFF accepted") &&
|
||||
check(ToUtf16("\xE0\x80\xAF") == u"\uFFFD\uFFFD\uFFFD", "overlong 3-byte form accepted");
|
||||
}
|
||||
|
||||
bool replacesLoneSurrogates() {
|
||||
const std::u16string lone = u"x\xD83Cy\xDFACz";
|
||||
return check(FromUtf16(lone.data(), lone.size()) == "x\xEF\xBF\xBDy\xEF\xBF\xBDz", "lone surrogates not replaced");
|
||||
}
|
||||
|
||||
bool handlesNullAndEmpty() {
|
||||
return check(ToUtf16(nullptr).empty(), "NULL input not empty") &&
|
||||
check(ToUtf16("").empty(), "empty input not empty") &&
|
||||
check(FromUtf16(nullptr, 0).empty(), "NULL UTF-16 not empty");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const bool ok = roundTripsAsciiBmpAndSupplementary() && doesNotEmitModifiedUtf8() &&
|
||||
replacesMalformedBytesOneAtATime() && replacesLoneSurrogates() && handlesNullAndEmpty();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.edde746.plezy
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -30,6 +32,40 @@ class ExternalPlayerChannelTest {
|
||||
assertEquals(false, result["playbackError"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshLaunchTellsPlayerToStartFromBeginning() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
val channel = ExternalPlayerChannel(activity)
|
||||
val source = ExternalPlayerChannel.Source(
|
||||
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
|
||||
grantRead = false,
|
||||
fileName = "file.mkv"
|
||||
)
|
||||
|
||||
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 0L, title = "Episode")
|
||||
|
||||
assertTrue(intent.getBooleanExtra("from_start", false))
|
||||
assertFalse(intent.hasExtra("position"))
|
||||
assertFalse(intent.hasExtra("startfrom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeLaunchPassesPositionAndDisablesFromStart() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
val channel = ExternalPlayerChannel(activity)
|
||||
val source = ExternalPlayerChannel.Source(
|
||||
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
|
||||
grantRead = false,
|
||||
fileName = "file.mkv"
|
||||
)
|
||||
|
||||
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 90_000L, title = null)
|
||||
|
||||
assertFalse(intent.getBooleanExtra("from_start", true))
|
||||
assertEquals(90_000, intent.getIntExtra("position", -1))
|
||||
assertEquals(90_000, intent.getIntExtra("startfrom", -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activityDestroyCompletesPendingChannelCall() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
|
||||
@@ -303,6 +303,5 @@ class AudioOutputPolicyTest {
|
||||
|
||||
private val allShapes = MpvIecShape.values().toSet()
|
||||
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String =
|
||||
mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String = mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.edde746.plezy.shared
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.time.Duration
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
@@ -10,7 +11,6 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Teardown ordering for frame-rate matching (#2172): restoring the display
|
||||
@@ -21,8 +21,7 @@ import java.time.Duration
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FrameRateManagerRestoreTest {
|
||||
|
||||
private fun buildManager(activity: Activity): FrameRateManager =
|
||||
FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
private fun buildManager(activity: Activity): FrameRateManager = FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
|
||||
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ android {
|
||||
compileSdk = 36
|
||||
// Matches the app's latest stable NDK so every project-owned native library
|
||||
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
|
||||
// is NOT what ships: the app packages the libmpv AAR's newer copy with top
|
||||
// is NOT what ships: the app packages the mpv-build tarball's newer copy with top
|
||||
// merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
fun verifySha256(file: File, expected: String, identity: String) {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") {
|
||||
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
if (actual != expected) {
|
||||
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteDirectory(staging: File, destination: File) {
|
||||
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
||||
val hadDestination = destination.exists()
|
||||
try {
|
||||
if (hadDestination) {
|
||||
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
try {
|
||||
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (promotionFailure: Exception) {
|
||||
if (hadDestination && backup.exists()) {
|
||||
try {
|
||||
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (restoreFailure: Exception) {
|
||||
promotionFailure.addSuppressed(restoreFailure)
|
||||
}
|
||||
}
|
||||
throw promotionFailure
|
||||
}
|
||||
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
||||
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
||||
}
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// mpv Kotlin API + JNI glue built in-project (imported from the libmpv-android
|
||||
// fork, commit e60c3ba); the external dependency is reduced to prebuilt native
|
||||
// trees carried by the mpv-build per-ABI tarballs pinned below.
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
// Single source for the pinned native artifacts: the repo-root
|
||||
// mpv-build.lock.json (github.com/edde746/mpv-build release assets + sha256
|
||||
// checksums). This module downloads and extracts them; app/build.gradle.kts
|
||||
// reads FFmpeg .so files and the libc++ runtime back out of the extracted
|
||||
// trees.
|
||||
val mpvAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val mpvLockFile = rootProject.file("../mpv-build.lock.json")
|
||||
val mpvLockAndroid = run {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lock = groovy.json.JsonSlurper().parse(mpvLockFile) as Map<String, Any?>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
((lock["artifacts"] as? Map<String, Any?>)?.get("android") as? Map<String, Any?>)
|
||||
?: throw GradleException("$mpvLockFile has no artifacts.android section")
|
||||
}
|
||||
val mpvKey = mpvLockAndroid["key"] as? String
|
||||
?: throw GradleException("$mpvLockFile artifacts.android.key is missing")
|
||||
val mpvAssetBase = mpvLockAndroid["assetBase"] as? String
|
||||
?: throw GradleException("$mpvLockFile artifacts.android.assetBase is missing")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val mpvAssets = (mpvLockAndroid["assets"] as? Map<String, Map<String, String>>).let { assets ->
|
||||
val missing = mpvAbis.filter { assets?.get(it)?.get("asset").isNullOrEmpty() || assets?.get(it)?.get("checksum").isNullOrEmpty() }
|
||||
if (assets == null || missing.isNotEmpty()) {
|
||||
throw GradleException("$mpvLockFile artifacts.android.assets lacks asset+checksum for: ${missing.joinToString()}")
|
||||
}
|
||||
assets
|
||||
}
|
||||
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvArchivesDir = File(mpvDir, "archives")
|
||||
val mpvNativeDir = File(mpvDir, "native")
|
||||
val mpvLibcxxDir = File(mpvDir, "libcxx")
|
||||
|
||||
// Downloaded archives are renamed to a key-independent name so a lock bump
|
||||
// (new key) changes task inputs, not the output file set.
|
||||
fun stagedArchiveName(abi: String) = "libmpv-android-$abi.tar.gz"
|
||||
|
||||
// Dev-only escape hatch: point the build at a directory of locally built
|
||||
// mpv-build android tarballs (platforms/android output, one
|
||||
// libmpv-android-<key>-<abi>.tar.gz per ABI) to test changes before a lock
|
||||
// bump. Checksums are skipped for local archives; the lock stays
|
||||
// authoritative otherwise.
|
||||
val localMpvDir: String? = (project.findProperty("plezy.localMpvDir") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_DIR")
|
||||
|
||||
fun localArchive(abi: String): File {
|
||||
val dir = File(localMpvDir!!)
|
||||
val exact = File(dir, mpvAssets.getValue(abi).getValue("asset"))
|
||||
if (exact.isFile) return exact
|
||||
val pattern = Regex("libmpv-android-.+-${Regex.escape(abi)}\\.tar\\.gz")
|
||||
val matches = dir.listFiles()?.filter { it.isFile && pattern.matches(it.name) }.orEmpty()
|
||||
return matches.singleOrNull() ?: throw GradleException(
|
||||
"PLEZY_LOCAL_MPV_DIR=$localMpvDir must contain exactly one libmpv-android-*-$abi.tar.gz, found ${matches.size}"
|
||||
)
|
||||
}
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val manifest = File(mpvArchivesDir, ".manifest")
|
||||
inputs.file(mpvLockFile)
|
||||
inputs.property("localOverride", localMpvDir ?: "")
|
||||
if (localMpvDir != null) mpvAbis.forEach { abi -> inputs.file(localArchive(abi)) }
|
||||
outputs.files(mpvAbis.map { File(mpvArchivesDir, stagedArchiveName(it)) } + manifest)
|
||||
doLast {
|
||||
mpvDir.mkdirs()
|
||||
val staging = File(mpvDir, "archives.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val manifestText = StringBuilder()
|
||||
mpvAbis.forEach { abi ->
|
||||
val staged = File(staging, stagedArchiveName(abi))
|
||||
if (localMpvDir != null) {
|
||||
val source = localArchive(abi)
|
||||
source.copyTo(staged, overwrite = true)
|
||||
manifestText.append("$abi=local:${source.absolutePath}\n")
|
||||
} else {
|
||||
val asset = mpvAssets.getValue(abi).getValue("asset")
|
||||
val checksum = mpvAssets.getValue(abi).getValue("checksum")
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", "$mpvAssetBase/$asset", "-o", staged.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $asset (mpv-build $mpvKey)", error)
|
||||
}
|
||||
verifySha256(staged, checksum, "$asset (mpv-build $mpvKey)")
|
||||
manifestText.append("$abi=$asset sha256=$checksum\n")
|
||||
}
|
||||
}
|
||||
File(staging, ".manifest").writeText(manifestText.toString())
|
||||
promoteDirectory(staging, mpvArchivesDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each tarball is a native tree: lib/*.so (libmpv, seven FFmpeg libraries,
|
||||
// libc++_shared) + include/mpv/*.h. The .so files land in the per-ABI jniLibs
|
||||
// layout under native/jni, the headers under native/include for the CMake
|
||||
// glue build, and libc++ in a separate tree that the app packages at PROJECT
|
||||
// scope so the tarball's 16 KB-capable runtime deterministically wins the
|
||||
// merge (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
val extractLibmpvNative = tasks.register("extractLibmpvNative") {
|
||||
dependsOn(downloadLibmpv)
|
||||
inputs.files(mpvAbis.map { File(mpvArchivesDir, stagedArchiveName(it)) })
|
||||
outputs.dir(mpvNativeDir)
|
||||
outputs.dir(mpvLibcxxDir)
|
||||
doLast {
|
||||
val nativeStaging = File(mpvDir, "native.staging-${UUID.randomUUID()}")
|
||||
val libcxxStaging = File(mpvDir, "libcxx.staging-${UUID.randomUUID()}")
|
||||
val unpackRoot = File(mpvDir, "unpack-${UUID.randomUUID()}")
|
||||
try {
|
||||
mpvAbis.forEach { abi ->
|
||||
val unpack = File(unpackRoot, abi).apply { mkdirs() }
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"tar",
|
||||
"-xzf",
|
||||
File(mpvArchivesDir, stagedArchiveName(abi)).absolutePath,
|
||||
"-C",
|
||||
unpack.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
val jniDir = File(nativeStaging, "jni/$abi")
|
||||
File(unpack, "lib").listFiles()?.filter { it.isFile && it.name.endsWith(".so") }?.forEach { so ->
|
||||
val target = if (so.name == "libc++_shared.so") {
|
||||
File(libcxxStaging, "jni/$abi/${so.name}")
|
||||
} else {
|
||||
File(jniDir, so.name)
|
||||
}
|
||||
target.parentFile.mkdirs()
|
||||
Files.move(so.toPath(), target.toPath())
|
||||
}
|
||||
// Headers are identical across ABIs; keep the first archive's copy.
|
||||
val include = File(unpack, "include")
|
||||
val includeTarget = File(nativeStaging, "include")
|
||||
if (!includeTarget.exists() && include.isDirectory) {
|
||||
include.copyRecursively(includeTarget)
|
||||
}
|
||||
}
|
||||
val missing = buildList {
|
||||
mpvAbis.forEach { abi ->
|
||||
if (!File(nativeStaging, "jni/$abi/libmpv.so").isFile) add("native/jni/$abi/libmpv.so")
|
||||
if (!File(nativeStaging, "jni/$abi/libavcodec.so").isFile) add("native/jni/$abi/libavcodec.so")
|
||||
if (!File(libcxxStaging, "jni/$abi/libc++_shared.so").isFile) add("libcxx/jni/$abi/libc++_shared.so")
|
||||
}
|
||||
if (!File(nativeStaging, "include/mpv/client.h").isFile) add("native/include/mpv/client.h")
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"mpv-build $mpvKey android archives are missing expected entries: ${missing.joinToString()}"
|
||||
)
|
||||
}
|
||||
promoteDirectory(nativeStaging, mpvNativeDir)
|
||||
promoteDirectory(libcxxStaging, mpvLibcxxDir)
|
||||
} finally {
|
||||
nativeStaging.deleteRecursively()
|
||||
libcxxStaging.deleteRecursively()
|
||||
unpackRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy.libmpv"
|
||||
compileSdk = 36
|
||||
// Matches the app's latest stable NDK so every project-owned native library
|
||||
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
|
||||
// is NOT what ships: the app packages the mpv-build tarball's newer copy with
|
||||
// top merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
defaultConfig {
|
||||
// Fire OS 6.x (API 25), same floor as the app. The fork declared 26, but
|
||||
// nothing in this API or glue uses anything above 25.
|
||||
minSdk = 25
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DANDROID_STL=c++_shared",
|
||||
"-DMPV_PREBUILT_ROOT=${mpvNativeDir.absolutePath}"
|
||||
)
|
||||
cFlags += "-Werror"
|
||||
cppFlags += "-std=c++11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// Prebuilt libmpv + FFmpeg .so files extracted from the mpv-build tarballs;
|
||||
// the glue libplayer.so comes from the CMake build above.
|
||||
jniLibs.srcDir(File(mpvNativeDir, "jni"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
// The imported libmpv.so/libavcodec.so must exist before CMake links the glue.
|
||||
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
||||
dependsOn(extractLibmpvNative)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted prebuilt directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractLibmpvNative)
|
||||
}
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(extractLibmpvNative)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Same version the app pins; MpvPlayer's public flows compile against it.
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# JNI exports bind by name (Java_com_edde746_plezy_libmpv_MpvPlayer_native*); keep the names stable.
|
||||
-keepclasseswithmembernames class com.edde746.plezy.libmpv.* {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# jni_utils.cpp caches MpvPlayer with FindClass and resolves these static callbacks
|
||||
# with GetStaticMethodID on the native event thread. R8 sees no reference to the
|
||||
# class name or the member names, so both must stay alive and un-renamed.
|
||||
-keep class com.edde746.plezy.libmpv.MpvPlayer {
|
||||
public static void onPropertyChanged(...);
|
||||
public static void onEvent(int);
|
||||
public static void onEndFile(int);
|
||||
public static void onLogMessage(java.lang.String, int, java.lang.String);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project("libmpv")
|
||||
|
||||
# The JNI glue this module compiles; MpvPlayer loads it with
|
||||
# System.loadLibrary("player") after libmpv.so itself.
|
||||
add_library(
|
||||
player
|
||||
SHARED
|
||||
main.cpp render.cpp log.cpp jni_utils.cpp property.cpp event.cpp
|
||||
)
|
||||
|
||||
# Prebuilt libraries extracted from the pinned mpv-build per-ABI tarballs by the
|
||||
# extractLibmpvNative Gradle task; MPV_PREBUILT_ROOT points at its output.
|
||||
add_library(
|
||||
mpv
|
||||
SHARED
|
||||
IMPORTED
|
||||
)
|
||||
|
||||
set_target_properties(
|
||||
mpv
|
||||
PROPERTIES IMPORTED_LOCATION
|
||||
${MPV_PREBUILT_ROOT}/jni/${ANDROID_ABI}/libmpv.so
|
||||
)
|
||||
|
||||
# av_jni_set_java_vm / av_jni_set_android_app_ctx (main.cpp) live in FFmpeg's
|
||||
# libavcodec, which the same tarballs package.
|
||||
add_library(
|
||||
avcodec
|
||||
SHARED
|
||||
IMPORTED
|
||||
)
|
||||
|
||||
set_target_properties(
|
||||
avcodec
|
||||
PROPERTIES IMPORTED_LOCATION
|
||||
${MPV_PREBUILT_ROOT}/jni/${ANDROID_ABI}/libavcodec.so
|
||||
)
|
||||
|
||||
# mpv public headers ride in the extracted tarballs next to libmpv.so; the
|
||||
# vendored tree only carries FFmpeg's libavcodec/jni.h (see include/README.md).
|
||||
include_directories( ${MPV_PREBUILT_ROOT}/include ${CMAKE_CURRENT_SOURCE_DIR}/include )
|
||||
|
||||
target_link_libraries( player mpv avcodec log )
|
||||
@@ -0,0 +1,99 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop) {
|
||||
jstring jprop = new_java_string(env, prop->name);
|
||||
jstring jvalue = NULL;
|
||||
switch (prop->format) {
|
||||
case MPV_FORMAT_NONE:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_S, jprop);
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sb, jprop, *(int*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_INT64:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sl, jprop, *(int64_t*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_DOUBLE:
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sd, jprop, *(double*)prop->data);
|
||||
break;
|
||||
case MPV_FORMAT_STRING:
|
||||
jvalue = new_java_string(env, *(const char**)prop->data);
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SS, jprop, jvalue);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (jprop) env->DeleteLocalRef(jprop);
|
||||
if (jvalue) env->DeleteLocalRef(jvalue);
|
||||
}
|
||||
|
||||
static void sendEventToJava(JNIEnv* env, int event) {
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEvent, event);
|
||||
}
|
||||
|
||||
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
|
||||
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
|
||||
int reason = end_file ? end_file->reason : -1;
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason);
|
||||
}
|
||||
|
||||
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
|
||||
jstring jprefix = new_java_string(env, msg->prefix);
|
||||
jstring jtext = new_java_string(env, msg->text);
|
||||
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, jprefix, (jint)msg->log_level, jtext);
|
||||
|
||||
if (jprefix) env->DeleteLocalRef(jprefix);
|
||||
if (jtext) env->DeleteLocalRef(jtext);
|
||||
}
|
||||
|
||||
void* event_thread(void* arg) {
|
||||
JNIEnv* env = NULL;
|
||||
acquire_jni_env(g_vm, &env);
|
||||
if (!env) die("failed to acquire java env");
|
||||
|
||||
while (true) {
|
||||
mpv_event* mp_event;
|
||||
mpv_event_property* mp_property;
|
||||
mpv_event_log_message* msg;
|
||||
|
||||
mp_event = mpv_wait_event(g_mpv, -1.0);
|
||||
|
||||
if (g_event_thread_request_exit) break;
|
||||
|
||||
if (mp_event->event_id == MPV_EVENT_NONE) continue;
|
||||
|
||||
switch (mp_event->event_id) {
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
msg = (mpv_event_log_message*)mp_event->data;
|
||||
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
|
||||
sendLogMessageToJava(env, msg);
|
||||
break;
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
mp_property = (mpv_event_property*)mp_event->data;
|
||||
sendPropertyUpdateToJava(env, mp_property);
|
||||
break;
|
||||
case MPV_EVENT_END_FILE:
|
||||
sendEndFileToJava(env, mp_event);
|
||||
break;
|
||||
case MPV_EVENT_START_FILE:
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id);
|
||||
break;
|
||||
default:
|
||||
// Nothing on the Kotlin side consumes the remaining ids (MpvEvent.fromId).
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
g_vm->DetachCurrentThread();
|
||||
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void* event_thread(void* arg);
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
extern JavaVM* g_vm;
|
||||
extern mpv_handle* g_mpv;
|
||||
extern std::atomic<bool> g_event_thread_request_exit;
|
||||
@@ -0,0 +1,15 @@
|
||||
# Vendored native headers
|
||||
|
||||
Build-time headers for the JNI glue in this module; nothing here ships in the APK.
|
||||
Each file carries its own upstream license text — none is modified.
|
||||
|
||||
- `libavcodec/jni.h` — FFmpeg n8.0.1 (https://github.com/FFmpeg/FFmpeg, tag `n8.0.1`,
|
||||
commit `894da5ca7d742e4429ffb2af534fcda0103ef593`), copied unmodified. Declares
|
||||
`av_jni_set_java_vm` / `av_jni_set_android_app_ctx`, which `main.cpp` calls into the
|
||||
`libavcodec.so` packaged by the pinned mpv-build tarballs (FFmpeg 8.0.1 — the
|
||||
version `app/build.gradle.kts` also pins for the Media3 adapter headers).
|
||||
|
||||
The mpv public headers (`mpv/client.h`, `mpv/render.h`, `mpv/render_gl.h`,
|
||||
`mpv/stream_cb.h`) are no longer vendored: each mpv-build per-ABI tarball carries
|
||||
`include/mpv/*.h` matching its `libmpv.so`, and `extractLibmpvNative` places them
|
||||
under `native/include`, which CMake reads via `MPV_PREBUILT_ROOT`.
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* JNI public API functions
|
||||
*
|
||||
* Copyright (c) 2015-2016 Matthieu Bouron <matthieu.bouron stupeflix.com>
|
||||
*
|
||||
* This file is part of FFmpeg.
|
||||
*
|
||||
* FFmpeg is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*
|
||||
* FFmpeg is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public
|
||||
* License along with FFmpeg; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef AVCODEC_JNI_H
|
||||
#define AVCODEC_JNI_H
|
||||
|
||||
/*
|
||||
* Manually set a Java virtual machine which will be used to retrieve the JNI
|
||||
* environment. Once a Java VM is set it cannot be changed afterwards, meaning
|
||||
* you can call multiple times av_jni_set_java_vm with the same Java VM pointer
|
||||
* however it will error out if you try to set a different Java VM.
|
||||
*
|
||||
* @param vm Java virtual machine
|
||||
* @param log_ctx context used for logging, can be NULL
|
||||
* @return 0 on success, < 0 otherwise
|
||||
*/
|
||||
int av_jni_set_java_vm(void *vm, void *log_ctx);
|
||||
|
||||
/*
|
||||
* Get the Java virtual machine which has been set with av_jni_set_java_vm.
|
||||
*
|
||||
* @param vm Java virtual machine
|
||||
* @return a pointer to the Java virtual machine
|
||||
*/
|
||||
void *av_jni_get_java_vm(void *log_ctx);
|
||||
|
||||
/*
|
||||
* Set the Android application context which will be used to retrieve the Android
|
||||
* content resolver to handle content uris.
|
||||
*
|
||||
* This function is only available on Android.
|
||||
*
|
||||
* @param app_ctx global JNI reference to the Android application context
|
||||
* @return 0 on success, < 0 otherwise
|
||||
*/
|
||||
int av_jni_set_android_app_ctx(void *app_ctx, void *log_ctx);
|
||||
|
||||
/*
|
||||
* Get the Android application context that has been set with
|
||||
* av_jni_set_android_app_ctx.
|
||||
*
|
||||
* This function is only available on Android.
|
||||
*
|
||||
* @return a pointer the the Android application context
|
||||
*/
|
||||
void *av_jni_get_android_app_ctx(void);
|
||||
|
||||
#endif /* AVCODEC_JNI_H */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
||||
/* Copyright (C) 2018 the mpv developers
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef MPV_CLIENT_API_RENDER_H_
|
||||
#define MPV_CLIENT_API_RENDER_H_
|
||||
|
||||
#include "client.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Overview
|
||||
* --------
|
||||
*
|
||||
* This API can be used to make mpv render using supported graphic APIs (such
|
||||
* as OpenGL). It can be used to handle video display.
|
||||
*
|
||||
* The renderer needs to be created with mpv_render_context_create() before
|
||||
* you start playback (or otherwise cause a VO to be created). Then (with most
|
||||
* backends) mpv_render_context_render() can be used to explicitly render the
|
||||
* current video frame. Use mpv_render_context_set_update_callback() to get
|
||||
* notified when there is a new frame to draw.
|
||||
*
|
||||
* Preferably rendering should be done in a separate thread. If you call
|
||||
* normal libmpv API functions on the renderer thread, deadlocks can result
|
||||
* (these are made non-fatal with timeouts, but user experience will obviously
|
||||
* suffer). See "Threading" section below.
|
||||
*
|
||||
* You can output and embed video without this API by setting the mpv "wid"
|
||||
* option to a native window handle (see "Embedding the video window" section
|
||||
* in the client.h header). In general, using the render API is recommended,
|
||||
* because window embedding can cause various issues, especially with GUI
|
||||
* toolkits and certain platforms.
|
||||
*
|
||||
* Supported backends
|
||||
* ------------------
|
||||
*
|
||||
* OpenGL: via MPV_RENDER_API_TYPE_OPENGL, see render_gl.h header.
|
||||
* Software: via MPV_RENDER_API_TYPE_SW, see section "Software renderer"
|
||||
*
|
||||
* Threading
|
||||
* ---------
|
||||
*
|
||||
* You are recommended to do rendering on a separate thread than normal libmpv
|
||||
* use.
|
||||
*
|
||||
* The mpv_render_* functions can be called from any thread, under the
|
||||
* following conditions:
|
||||
* - only one of the mpv_render_* functions can be called at the same time
|
||||
* (unless they belong to different mpv cores created by mpv_create())
|
||||
* - never can be called from within the callbacks set with
|
||||
* mpv_set_wakeup_callback() or mpv_render_context_set_update_callback()
|
||||
* - if the OpenGL backend is used, for all functions the OpenGL context
|
||||
* must be "current" in the calling thread, and it must be the same OpenGL
|
||||
* context as the mpv_render_context was created with. Otherwise, undefined
|
||||
* behavior will occur.
|
||||
* - the thread does not call libmpv API functions other than the mpv_render_*
|
||||
* functions, except APIs which are declared as safe (see below). Likewise,
|
||||
* there must be no lock or wait dependency from the render thread to a
|
||||
* thread using other libmpv functions. Basically, the situation that your
|
||||
* render thread waits for a "not safe" libmpv API function to return must
|
||||
* not happen. If you ignore this requirement, deadlocks can happen, which
|
||||
* are made non-fatal with timeouts; then playback quality will be degraded,
|
||||
* and the message
|
||||
* mpv_render_context_render() not being called or stuck.
|
||||
* is logged. If you set MPV_RENDER_PARAM_ADVANCED_CONTROL, you promise that
|
||||
* this won't happen, and must absolutely guarantee it, or a real deadlock
|
||||
* will freeze the mpv core thread forever.
|
||||
*
|
||||
* libmpv functions which are safe to call from a render thread are:
|
||||
* - functions marked with "Safe to be called from mpv render API threads."
|
||||
* - client.h functions which don't have an explicit or implicit mpv_handle
|
||||
* parameter
|
||||
* - mpv_render_* functions; but only for the same mpv_render_context pointer.
|
||||
* If the pointer is different, mpv_render_context_free() is not safe. (The
|
||||
* reason is that if MPV_RENDER_PARAM_ADVANCED_CONTROL is set, it may have
|
||||
* to process still queued requests from the core, which it can do only for
|
||||
* the current context, while requests for other contexts would deadlock.
|
||||
* Also, it may have to wait and block for the core to terminate the video
|
||||
* chain to make sure no resources are used after context destruction.)
|
||||
* - if the mpv_handle parameter refers to a different mpv core than the one
|
||||
* you're rendering for (very obscure, but allowed)
|
||||
*
|
||||
* Note about old libmpv version:
|
||||
*
|
||||
* Before API version 1.105 (basically in mpv 0.29.x), simply enabling
|
||||
* MPV_RENDER_PARAM_ADVANCED_CONTROL could cause deadlock issues. This can
|
||||
* be worked around by setting the "vd-lavc-dr" option to "no".
|
||||
* In addition, you were required to call all mpv_render*() API functions
|
||||
* from the same thread on which mpv_render_context_create() was originally
|
||||
* run (for the same the mpv_render_context). Not honoring it led to UB
|
||||
* (deadlocks, use of invalid mp_thread handles), even if you moved your GL
|
||||
* context to a different thread correctly.
|
||||
* These problems were addressed in API version 1.105 (mpv 0.30.0).
|
||||
*
|
||||
* Context and handle lifecycle
|
||||
* ----------------------------
|
||||
*
|
||||
* Video initialization will fail if the render context was not initialized yet
|
||||
* (with mpv_render_context_create()), or it will revert to a VO that creates
|
||||
* its own window.
|
||||
*
|
||||
* Currently, there can be only 1 mpv_render_context at a time per mpv core.
|
||||
*
|
||||
* Calling mpv_render_context_free() while a VO is using the render context is
|
||||
* active will disable video.
|
||||
*
|
||||
* You must free the context with mpv_render_context_free() before the mpv core
|
||||
* is destroyed. If this doesn't happen, undefined behavior will result.
|
||||
*
|
||||
* Software renderer
|
||||
* -----------------
|
||||
*
|
||||
* MPV_RENDER_API_TYPE_SW provides an extremely simple (but slow) renderer to
|
||||
* memory surfaces. You probably don't want to use this. Use other render API
|
||||
* types, or other methods of video embedding.
|
||||
*
|
||||
* Use mpv_render_context_create() with MPV_RENDER_PARAM_API_TYPE set to
|
||||
* MPV_RENDER_API_TYPE_SW.
|
||||
*
|
||||
* Call mpv_render_context_render() with various MPV_RENDER_PARAM_SW_* fields
|
||||
* to render the video frame to an in-memory surface. The following fields are
|
||||
* required: MPV_RENDER_PARAM_SW_SIZE, MPV_RENDER_PARAM_SW_FORMAT,
|
||||
* MPV_RENDER_PARAM_SW_STRIDE, MPV_RENDER_PARAM_SW_POINTER.
|
||||
*
|
||||
* This method of rendering is very slow, because everything, including color
|
||||
* conversion, scaling, and OSD rendering, is done on the CPU, single-threaded.
|
||||
* In particular, large video or display sizes, as well as presence of OSD or
|
||||
* subtitles can make it too slow for realtime. As with other software rendering
|
||||
* VOs, setting "sw-fast" may help. Enabling or disabling zimg may help,
|
||||
* depending on the platform.
|
||||
*
|
||||
* In addition, certain multimedia job creation measures like HDR may not work
|
||||
* properly, and will have to be manually handled by for example inserting
|
||||
* filters.
|
||||
*
|
||||
* This API is not really suitable to extract individual frames from video etc.
|
||||
* (basically non-playback uses) - there are better libraries for this. It can
|
||||
* be used this way, but it may be clunky and tricky.
|
||||
*
|
||||
* Further notes:
|
||||
* - MPV_RENDER_PARAM_FLIP_Y is currently ignored (unsupported)
|
||||
* - MPV_RENDER_PARAM_DEPTH is ignored (meaningless)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Opaque context, returned by mpv_render_context_create().
|
||||
*/
|
||||
typedef struct mpv_render_context mpv_render_context;
|
||||
|
||||
/**
|
||||
* Parameters for mpv_render_param (which is used in a few places such as
|
||||
* mpv_render_context_create().
|
||||
*
|
||||
* Also see mpv_render_param for conventions and how to use it.
|
||||
*/
|
||||
typedef enum mpv_render_param_type {
|
||||
/**
|
||||
* Not a valid value, but also used to terminate a params array. Its value
|
||||
* is always guaranteed to be 0 (even if the ABI changes in the future).
|
||||
*/
|
||||
MPV_RENDER_PARAM_INVALID = 0,
|
||||
/**
|
||||
* The render API to use. Valid for mpv_render_context_create().
|
||||
*
|
||||
* Type: char*
|
||||
*
|
||||
* Defined APIs:
|
||||
*
|
||||
* MPV_RENDER_API_TYPE_OPENGL:
|
||||
* OpenGL desktop 2.1 or later (preferably core profile compatible to
|
||||
* OpenGL 3.2), or OpenGLES 2.0 or later.
|
||||
* Providing MPV_RENDER_PARAM_OPENGL_INIT_PARAMS is required.
|
||||
* It is expected that an OpenGL context is valid and "current" when
|
||||
* calling mpv_render_* functions (unless specified otherwise). It
|
||||
* must be the same context for the same mpv_render_context.
|
||||
*/
|
||||
MPV_RENDER_PARAM_API_TYPE = 1,
|
||||
/**
|
||||
* Required parameters for initializing the OpenGL renderer. Valid for
|
||||
* mpv_render_context_create().
|
||||
* Type: mpv_opengl_init_params*
|
||||
*/
|
||||
MPV_RENDER_PARAM_OPENGL_INIT_PARAMS = 2,
|
||||
/**
|
||||
* Describes a GL render target. Valid for mpv_render_context_render().
|
||||
* Type: mpv_opengl_fbo*
|
||||
*/
|
||||
MPV_RENDER_PARAM_OPENGL_FBO = 3,
|
||||
/**
|
||||
* Control flipped rendering. Valid for mpv_render_context_render().
|
||||
* Type: int*
|
||||
* If the value is set to 0, render normally. Otherwise, render it flipped,
|
||||
* which is needed e.g. when rendering to an OpenGL default framebuffer
|
||||
* (which has a flipped coordinate system).
|
||||
*/
|
||||
MPV_RENDER_PARAM_FLIP_Y = 4,
|
||||
/**
|
||||
* Control surface depth. Valid for mpv_render_context_render().
|
||||
* Type: int*
|
||||
* This implies the depth of the surface passed to the render function in
|
||||
* bits per channel. If omitted or set to 0, the renderer will assume 8.
|
||||
* Typically used to control dithering.
|
||||
*/
|
||||
MPV_RENDER_PARAM_DEPTH = 5,
|
||||
/**
|
||||
* ICC profile blob. Valid for mpv_render_context_set_parameter().
|
||||
* Type: mpv_byte_array*
|
||||
* Set an ICC profile for use with the "icc-profile-auto" option. (If the
|
||||
* option is not enabled, the ICC data will not be used.)
|
||||
*/
|
||||
MPV_RENDER_PARAM_ICC_PROFILE = 6,
|
||||
/**
|
||||
* Deprecated
|
||||
* Ambient light in lux. Valid for mpv_render_context_set_parameter().
|
||||
* Type: int*
|
||||
* This can be used for automatic gamma correction.
|
||||
*/
|
||||
MPV_RENDER_PARAM_AMBIENT_LIGHT = 7,
|
||||
/**
|
||||
* X11 Display, sometimes used for hwdec. Valid for
|
||||
* mpv_render_context_create(). The Display must stay valid for the lifetime
|
||||
* of the mpv_render_context.
|
||||
* Type: Display*
|
||||
*/
|
||||
MPV_RENDER_PARAM_X11_DISPLAY = 8,
|
||||
/**
|
||||
* Wayland display, sometimes used for hwdec. Valid for
|
||||
* mpv_render_context_create(). The wl_display must stay valid for the
|
||||
* lifetime of the mpv_render_context.
|
||||
* Type: struct wl_display*
|
||||
*/
|
||||
MPV_RENDER_PARAM_WL_DISPLAY = 9,
|
||||
/**
|
||||
* Better control about rendering and enabling some advanced features. Valid
|
||||
* for mpv_render_context_create().
|
||||
*
|
||||
* This conflates multiple requirements the API user promises to abide if
|
||||
* this option is enabled:
|
||||
*
|
||||
* - The API user's render thread, which is calling the mpv_render_*()
|
||||
* functions, never waits for the core. Otherwise deadlocks can happen.
|
||||
* See "Threading" section.
|
||||
* - The callback set with mpv_render_context_set_update_callback() can now
|
||||
* be called even if there is no new frame. The API user should call the
|
||||
* mpv_render_context_update() function, and interpret the return value
|
||||
* for whether a new frame should be rendered.
|
||||
* - Correct functionality is impossible if the update callback is not set,
|
||||
* or not set soon enough after mpv_render_context_create() (the core can
|
||||
* block while waiting for you to call mpv_render_context_update(), and
|
||||
* if the update callback is not correctly set, it will deadlock, or
|
||||
* block for too long).
|
||||
*
|
||||
* In general, setting this option will enable the following features (and
|
||||
* possibly more):
|
||||
*
|
||||
* - "Direct rendering", which means the player decodes directly to a
|
||||
* texture, which saves a copy per video frame ("vd-lavc-dr" option
|
||||
* needs to be enabled, and the rendering backend as well as the
|
||||
* underlying GPU API/driver needs to have support for it).
|
||||
* - Rendering screenshots with the GPU API if supported by the backend
|
||||
* (instead of using a suboptimal software fallback via libswscale).
|
||||
*
|
||||
* Warning: do not just add this without reading the "Threading" section
|
||||
* above, and then wondering that deadlocks happen. The
|
||||
* requirements are tricky. But also note that even if advanced
|
||||
* control is disabled, not adhering to the rules will lead to
|
||||
* playback problems. Enabling advanced controls simply makes
|
||||
* violating these rules fatal.
|
||||
*
|
||||
* Type: int*: 0 for disable (default), 1 for enable
|
||||
*/
|
||||
MPV_RENDER_PARAM_ADVANCED_CONTROL = 10,
|
||||
/**
|
||||
* Return information about the next frame to render. Valid for
|
||||
* mpv_render_context_get_info().
|
||||
*
|
||||
* Type: mpv_render_frame_info*
|
||||
*
|
||||
* It strictly returns information about the _next_ frame. The implication
|
||||
* is that e.g. mpv_render_context_update()'s return value will have
|
||||
* MPV_RENDER_UPDATE_FRAME set, and the user is supposed to call
|
||||
* mpv_render_context_render(). If there is no next frame, then the
|
||||
* return value will have is_valid set to 0.
|
||||
*/
|
||||
MPV_RENDER_PARAM_NEXT_FRAME_INFO = 11,
|
||||
/**
|
||||
* Enable or disable video timing. Valid for mpv_render_context_render().
|
||||
*
|
||||
* Type: int*: 0 for disable, 1 for enable (default)
|
||||
*
|
||||
* When video is timed to audio, the player attempts to render video a bit
|
||||
* ahead, and then do a blocking wait until the target display time is
|
||||
* reached. This blocks mpv_render_context_render() for up to the amount
|
||||
* specified with the "video-timing-offset" global option. You can set
|
||||
* this parameter to 0 to disable this kind of waiting. If you do, it's
|
||||
* recommended to use the target time value in mpv_render_frame_info to
|
||||
* wait yourself, or to set the "video-timing-offset" to 0 instead.
|
||||
*
|
||||
* Disabling this without doing anything in addition will result in A/V sync
|
||||
* being slightly off.
|
||||
*/
|
||||
MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 12,
|
||||
/**
|
||||
* Use to skip rendering in mpv_render_context_render().
|
||||
*
|
||||
* Type: int*: 0 for rendering (default), 1 for skipping
|
||||
*
|
||||
* If this is set, you don't need to pass a target surface to the render
|
||||
* function (and if you do, it's completely ignored). This can still call
|
||||
* into the lower level APIs (i.e. if you use OpenGL, the OpenGL context
|
||||
* must be set).
|
||||
*
|
||||
* Be aware that the render API will consider this frame as having been
|
||||
* rendered. All other normal rules also apply, for example about whether
|
||||
* you have to call mpv_render_context_report_swap(). It also does timing
|
||||
* in the same way.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SKIP_RENDERING = 13,
|
||||
/**
|
||||
* Deprecated. Not supported. Use MPV_RENDER_PARAM_DRM_DISPLAY_V2 instead.
|
||||
* Type : struct mpv_opengl_drm_params*
|
||||
*/
|
||||
MPV_RENDER_PARAM_DRM_DISPLAY = 14,
|
||||
/**
|
||||
* DRM draw surface size, contains draw surface dimensions.
|
||||
* Valid for mpv_render_context_create().
|
||||
* Type : struct mpv_opengl_drm_draw_surface_size*
|
||||
*/
|
||||
MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE = 15,
|
||||
/**
|
||||
* DRM display, contains drm display handles.
|
||||
* Valid for mpv_render_context_create().
|
||||
* Type : struct mpv_opengl_drm_params_v2*
|
||||
*/
|
||||
MPV_RENDER_PARAM_DRM_DISPLAY_V2 = 16,
|
||||
/**
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface size, mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: int[2] (e.g.: int s[2] = {w, h}; param.data = &s[0];)
|
||||
*
|
||||
* The video frame is transformed as with other VOs. Typically, this means
|
||||
* the video gets scaled and black bars are added if the video size or
|
||||
* aspect ratio mismatches with the target size.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_SIZE = 17,
|
||||
/**
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface pixel format,
|
||||
* mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: char* (e.g.: char *f = "rgb0"; param.data = f;)
|
||||
*
|
||||
* Valid values are:
|
||||
* "rgb0", "bgr0", "0bgr", "0rgb"
|
||||
* 4 bytes per pixel RGB, 1 byte (8 bit) per component, component bytes
|
||||
* with increasing address from left to right (e.g. "rgb0" has r at
|
||||
* address 0), the "0" component contains uninitialized garbage (often
|
||||
* the value 0, but not necessarily; the bad naming is inherited from
|
||||
* FFmpeg)
|
||||
* Pixel alignment size: 4 bytes
|
||||
* "rgb24"
|
||||
* 3 bytes per pixel RGB. This is strongly discouraged because it is
|
||||
* very slow.
|
||||
* Pixel alignment size: 1 bytes
|
||||
* other
|
||||
* The API may accept other pixel formats, using mpv internal format
|
||||
* names, as long as it's internally marked as RGB, has exactly 1
|
||||
* plane, and is supported as conversion output. It is not a good idea
|
||||
* to rely on any of these. Their semantics and handling could change.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_FORMAT = 18,
|
||||
/**
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface bytes per line,
|
||||
* mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: size_t*
|
||||
*
|
||||
* This is the number of bytes between a pixel (x, y) and (x, y + 1) on the
|
||||
* target surface. It must be a multiple of the pixel size, and have space
|
||||
* for the surface width as specified by MPV_RENDER_PARAM_SW_SIZE.
|
||||
*
|
||||
* Both stride and pointer value should be a multiple of 64 to facilitate
|
||||
* fast SIMD operation. Lower alignment might trigger slower code paths,
|
||||
* and in the worst case, will copy the entire target frame. If mpv is built
|
||||
* with zimg (and zimg is not disabled), the performance impact might be
|
||||
* less.
|
||||
* In either cases, the pointer and stride must be aligned at least to the
|
||||
* pixel alignment size. Otherwise, crashes and undefined behavior is
|
||||
* possible on platforms which do not support unaligned accesses (either
|
||||
* through normal memory access or aligned SIMD memory access instructions).
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_STRIDE = 19,
|
||||
/*
|
||||
* MPV_RENDER_API_TYPE_SW only: rendering target surface pixel data pointer,
|
||||
* mandatory.
|
||||
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
|
||||
* Type: void*
|
||||
*
|
||||
* This points to the first pixel at the left/top corner (0, 0). In
|
||||
* particular, each line y starts at (pointer + stride * y). Upon rendering,
|
||||
* all data between pointer and (pointer + stride * h) is overwritten.
|
||||
* Whether the padding between (w, y) and (0, y + 1) is overwritten is left
|
||||
* unspecified (it should not be, but unfortunately some scaler backends
|
||||
* will do it anyway). It is assumed that even the padding after the last
|
||||
* line (starting at bytepos(w, h) until (pointer + stride * h)) is
|
||||
* writable.
|
||||
*
|
||||
* See MPV_RENDER_PARAM_SW_STRIDE for alignment requirements.
|
||||
*/
|
||||
MPV_RENDER_PARAM_SW_POINTER = 20,
|
||||
} mpv_render_param_type;
|
||||
|
||||
/**
|
||||
* For backwards compatibility with the old naming of
|
||||
* MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE
|
||||
*/
|
||||
#define MPV_RENDER_PARAM_DRM_OSD_SIZE MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE
|
||||
|
||||
/**
|
||||
* Used to pass arbitrary parameters to some mpv_render_* functions. The
|
||||
* meaning of the data parameter is determined by the type, and each
|
||||
* MPV_RENDER_PARAM_* documents what type the value must point to.
|
||||
*
|
||||
* Each value documents the required data type as the pointer you cast to
|
||||
* void* and set on mpv_render_param.data. For example, if MPV_RENDER_PARAM_FOO
|
||||
* documents the type as Something* , then the code should look like this:
|
||||
*
|
||||
* Something foo = {...};
|
||||
* mpv_render_param param;
|
||||
* param.type = MPV_RENDER_PARAM_FOO;
|
||||
* param.data = & foo;
|
||||
*
|
||||
* Normally, the data field points to exactly 1 object. If the type is char*,
|
||||
* it points to a 0-terminated string.
|
||||
*
|
||||
* In all cases (unless documented otherwise) the pointers need to remain
|
||||
* valid during the call only. Unless otherwise documented, the API functions
|
||||
* will not write to the params array or any data pointed to it.
|
||||
*
|
||||
* As a convention, parameter arrays are always terminated by type==0. There
|
||||
* is no specific order of the parameters required. The order of the 2 fields in
|
||||
* this struct is guaranteed (even after ABI changes).
|
||||
*/
|
||||
typedef struct mpv_render_param {
|
||||
enum mpv_render_param_type type;
|
||||
void *data;
|
||||
} mpv_render_param;
|
||||
|
||||
|
||||
/**
|
||||
* Predefined values for MPV_RENDER_PARAM_API_TYPE.
|
||||
*/
|
||||
// See render_gl.h
|
||||
#define MPV_RENDER_API_TYPE_OPENGL "opengl"
|
||||
// See section "Software renderer"
|
||||
#define MPV_RENDER_API_TYPE_SW "sw"
|
||||
|
||||
/**
|
||||
* Flags used in mpv_render_frame_info.flags. Each value represents a bit in it.
|
||||
*/
|
||||
typedef enum mpv_render_frame_info_flag {
|
||||
/**
|
||||
* Set if there is actually a next frame. If unset, there is no next frame
|
||||
* yet, and other flags and fields that require a frame to be queued will
|
||||
* be unset.
|
||||
*
|
||||
* This is set for _any_ kind of frame, even for redraw requests.
|
||||
*
|
||||
* Note that when this is unset, it simply means no new frame was
|
||||
* decoded/queued yet, not necessarily that the end of the video was
|
||||
* reached. A new frame can be queued after some time.
|
||||
*
|
||||
* If the return value of mpv_render_context_render() had the
|
||||
* MPV_RENDER_UPDATE_FRAME flag set, this flag will usually be set as well,
|
||||
* unless the frame is rendered, or discarded by other asynchronous events.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_PRESENT = 1 << 0,
|
||||
/**
|
||||
* If set, the frame is not an actual new video frame, but a redraw request.
|
||||
* For example if the video is paused, and an option that affects video
|
||||
* rendering was changed (or any other reason), an update request can be
|
||||
* issued and this flag will be set.
|
||||
*
|
||||
* Typically, redraw frames will not be subject to video timing.
|
||||
*
|
||||
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_REDRAW = 1 << 1,
|
||||
/**
|
||||
* If set, this is supposed to reproduce the previous frame perfectly. This
|
||||
* is usually used for certain "video-sync" options ("display-..." modes).
|
||||
* Typically the renderer will blit the video from a FBO. Unset otherwise.
|
||||
*
|
||||
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_REPEAT = 1 << 2,
|
||||
/**
|
||||
* If set, the player timing code expects that the user thread blocks on
|
||||
* vsync (by either delaying the render call, or by making a call to
|
||||
* mpv_render_context_report_swap() at vsync time).
|
||||
*
|
||||
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
|
||||
*/
|
||||
MPV_RENDER_FRAME_INFO_BLOCK_VSYNC = 1 << 3,
|
||||
} mpv_render_frame_info_flag;
|
||||
|
||||
/**
|
||||
* Information about the next video frame that will be rendered. Can be
|
||||
* retrieved with MPV_RENDER_PARAM_NEXT_FRAME_INFO.
|
||||
*/
|
||||
typedef struct mpv_render_frame_info {
|
||||
/**
|
||||
* A bitset of mpv_render_frame_info_flag values (i.e. multiple flags are
|
||||
* combined with bitwise or).
|
||||
*/
|
||||
uint64_t flags;
|
||||
/**
|
||||
* Absolute time at which the frame is supposed to be displayed. This is in
|
||||
* the same unit and base as the time returned by mpv_get_time_us(). For
|
||||
* frames that are redrawn, or if vsync locked video timing is used (see
|
||||
* "video-sync" option), then this can be 0. The "video-timing-offset"
|
||||
* option determines how much "headroom" the render thread gets (but a high
|
||||
* enough frame rate can reduce it anyway). mpv_render_context_render() will
|
||||
* normally block until the time is elapsed, unless you pass it
|
||||
* MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 0.
|
||||
*/
|
||||
int64_t target_time;
|
||||
} mpv_render_frame_info;
|
||||
|
||||
/**
|
||||
* Initialize the renderer state. Depending on the backend used, this will
|
||||
* access the underlying GPU API and initialize its own objects.
|
||||
*
|
||||
* You must free the context with mpv_render_context_free(). Not doing so before
|
||||
* the mpv core is destroyed may result in memory leaks or crashes.
|
||||
*
|
||||
* Currently, only at most 1 context can exists per mpv core (it represents the
|
||||
* main video output).
|
||||
*
|
||||
* You should pass the following parameters:
|
||||
* - MPV_RENDER_PARAM_API_TYPE to select the underlying backend/GPU API.
|
||||
* - Backend-specific init parameter, like MPV_RENDER_PARAM_OPENGL_INIT_PARAMS.
|
||||
* - Setting MPV_RENDER_PARAM_ADVANCED_CONTROL and following its rules is
|
||||
* strongly recommended.
|
||||
* - If you want to use hwdec, possibly hwdec interop resources.
|
||||
*
|
||||
* @param res set to the context (on success) or NULL (on failure). The value
|
||||
* is never read and always overwritten.
|
||||
* @param mpv handle used to get the core (the mpv_render_context won't depend
|
||||
* on this specific handle, only the core referenced by it)
|
||||
* @param params an array of parameters, terminated by type==0. It's left
|
||||
* unspecified what happens with unknown parameters. At least
|
||||
* MPV_RENDER_PARAM_API_TYPE is required, and most backends will
|
||||
* require another backend-specific parameter.
|
||||
* @return error code, including but not limited to:
|
||||
* MPV_ERROR_UNSUPPORTED: the OpenGL version is not supported
|
||||
* (or required extensions are missing)
|
||||
* MPV_ERROR_NOT_IMPLEMENTED: an unknown API type was provided, or
|
||||
* support for the requested API was not
|
||||
* built in the used libmpv binary.
|
||||
* MPV_ERROR_INVALID_PARAMETER: at least one of the provided parameters was
|
||||
* not valid.
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_create(mpv_render_context **res, mpv_handle *mpv,
|
||||
mpv_render_param *params);
|
||||
|
||||
/**
|
||||
* Attempt to change a single parameter. Not all backends and parameter types
|
||||
* support all kinds of changes.
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
* @param param the parameter type and data that should be set
|
||||
* @return error code. If a parameter could actually be changed, this returns
|
||||
* success, otherwise an error code depending on the parameter type
|
||||
* and situation.
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_set_parameter(mpv_render_context *ctx,
|
||||
mpv_render_param param);
|
||||
|
||||
/**
|
||||
* Retrieve information from the render context. This is NOT a counterpart to
|
||||
* mpv_render_context_set_parameter(), because you generally can't read
|
||||
* parameters set with it, and this function is not meant for this purpose.
|
||||
* Instead, this is for communicating information from the renderer back to the
|
||||
* user. See mpv_render_param_type; entries which support this function
|
||||
* explicitly mention it, and for other entries you can assume it will fail.
|
||||
*
|
||||
* You pass param with param.type set and param.data pointing to a variable
|
||||
* of the required data type. The function will then overwrite that variable
|
||||
* with the returned value (at least on success).
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
* @param param the parameter type and data that should be retrieved
|
||||
* @return error code. If a parameter could actually be retrieved, this returns
|
||||
* success, otherwise an error code depending on the parameter type
|
||||
* and situation. MPV_ERROR_NOT_IMPLEMENTED is used for unknown
|
||||
* param.type, or if retrieving it is not supported.
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_get_info(mpv_render_context *ctx,
|
||||
mpv_render_param param);
|
||||
|
||||
typedef void (*mpv_render_update_fn)(void *cb_ctx);
|
||||
|
||||
/**
|
||||
* Set the callback that notifies you when a new video frame is available, or
|
||||
* if the video display configuration somehow changed and requires a redraw.
|
||||
* Similar to mpv_set_wakeup_callback(), you must not call any mpv API from
|
||||
* the callback, and all the other listed restrictions apply (such as not
|
||||
* exiting the callback by throwing exceptions).
|
||||
*
|
||||
* This can be called from any thread, except from an update callback. In case
|
||||
* of the OpenGL backend, no OpenGL state or API is accessed.
|
||||
*
|
||||
* Calling this will raise an update callback immediately.
|
||||
*
|
||||
* @param callback callback(callback_ctx) is called if the frame should be
|
||||
* redrawn
|
||||
* @param callback_ctx opaque argument to the callback
|
||||
*/
|
||||
MPV_EXPORT void mpv_render_context_set_update_callback(mpv_render_context *ctx,
|
||||
mpv_render_update_fn callback,
|
||||
void *callback_ctx);
|
||||
|
||||
/**
|
||||
* The API user is supposed to call this when the update callback was invoked
|
||||
* (like all mpv_render_* functions, this has to happen on the render thread,
|
||||
* and _not_ from the update callback itself).
|
||||
*
|
||||
* This is optional if MPV_RENDER_PARAM_ADVANCED_CONTROL was not set (default).
|
||||
* Otherwise, it's a hard requirement that this is called after each update
|
||||
* callback. If multiple update callback happened, and the function could not
|
||||
* be called sooner, it's OK to call it once after the last callback.
|
||||
*
|
||||
* If an update callback happens during or after this function, the function
|
||||
* must be called again at the soonest possible time.
|
||||
*
|
||||
* If MPV_RENDER_PARAM_ADVANCED_CONTROL was set, this will do additional work
|
||||
* such as allocating textures for the video decoder.
|
||||
*
|
||||
* @return a bitset of mpv_render_update_flag values (i.e. multiple flags are
|
||||
* combined with bitwise or). Typically, this will tell the API user
|
||||
* what should happen next. E.g. if the MPV_RENDER_UPDATE_FRAME flag is
|
||||
* set, mpv_render_context_render() should be called. If flags unknown
|
||||
* to the API user are set, or if the return value is 0, nothing needs
|
||||
* to be done.
|
||||
*/
|
||||
MPV_EXPORT uint64_t mpv_render_context_update(mpv_render_context *ctx);
|
||||
|
||||
/**
|
||||
* Flags returned by mpv_render_context_update(). Each value represents a bit
|
||||
* in the function's return value.
|
||||
*/
|
||||
typedef enum mpv_render_update_flag {
|
||||
/**
|
||||
* A new video frame must be rendered. mpv_render_context_render() must be
|
||||
* called.
|
||||
*/
|
||||
MPV_RENDER_UPDATE_FRAME = 1 << 0,
|
||||
} mpv_render_context_flag;
|
||||
|
||||
/**
|
||||
* Render video.
|
||||
*
|
||||
* Typically renders the video to a target surface provided via mpv_render_param
|
||||
* (the details depend on the backend in use). Options like "panscan" are
|
||||
* applied to determine which part of the video should be visible and how the
|
||||
* video should be scaled. You can change these options at runtime by using the
|
||||
* mpv property API.
|
||||
*
|
||||
* The renderer will reconfigure itself every time the target surface
|
||||
* configuration (such as size) is changed.
|
||||
*
|
||||
* This function implicitly pulls a video frame from the internal queue and
|
||||
* renders it. If no new frame is available, the previous frame is redrawn.
|
||||
* The update callback set with mpv_render_context_set_update_callback()
|
||||
* notifies you when a new frame was added. The details potentially depend on
|
||||
* the backends and the provided parameters.
|
||||
*
|
||||
* Generally, libmpv will invoke your update callback some time before the video
|
||||
* frame should be shown, and then lets this function block until the supposed
|
||||
* display time. This will limit your rendering to video FPS. You can prevent
|
||||
* this by setting the "video-timing-offset" global option to 0. (This applies
|
||||
* only to "audio" video sync mode.)
|
||||
*
|
||||
* You should pass the following parameters:
|
||||
* - Backend-specific target object, such as MPV_RENDER_PARAM_OPENGL_FBO.
|
||||
* - Possibly transformations, such as MPV_RENDER_PARAM_FLIP_Y.
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
* @param params an array of parameters, terminated by type==0. Which parameters
|
||||
* are required depends on the backend. It's left unspecified what
|
||||
* happens with unknown parameters.
|
||||
* @return error code
|
||||
*/
|
||||
MPV_EXPORT int mpv_render_context_render(mpv_render_context *ctx, mpv_render_param *params);
|
||||
|
||||
/**
|
||||
* Tell the renderer that a frame was flipped at the given time. This is
|
||||
* optional, but can help the player to achieve better timing.
|
||||
*
|
||||
* Note that calling this at least once informs libmpv that you will use this
|
||||
* function. If you use it inconsistently, expect bad video playback.
|
||||
*
|
||||
* If this is called while no video is initialized, it is ignored.
|
||||
*
|
||||
* @param ctx a valid render context
|
||||
*/
|
||||
MPV_EXPORT void mpv_render_context_report_swap(mpv_render_context *ctx);
|
||||
|
||||
/**
|
||||
* Destroy the mpv renderer state.
|
||||
*
|
||||
* If video is still active (e.g. a file playing), video will be disabled
|
||||
* forcefully.
|
||||
*
|
||||
* @param ctx a valid render context. After this function returns, this is not
|
||||
* a valid pointer anymore. NULL is also allowed and does nothing.
|
||||
*/
|
||||
MPV_EXPORT void mpv_render_context_free(mpv_render_context *ctx);
|
||||
|
||||
#ifdef MPV_CPLUGIN_DYNAMIC_SYM
|
||||
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_create)
|
||||
#define mpv_render_context_create pfn_mpv_render_context_create
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_set_parameter)
|
||||
#define mpv_render_context_set_parameter pfn_mpv_render_context_set_parameter
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_get_info)
|
||||
#define mpv_render_context_get_info pfn_mpv_render_context_get_info
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_set_update_callback)
|
||||
#define mpv_render_context_set_update_callback pfn_mpv_render_context_set_update_callback
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_update)
|
||||
#define mpv_render_context_update pfn_mpv_render_context_update
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_render)
|
||||
#define mpv_render_context_render pfn_mpv_render_context_render
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_report_swap)
|
||||
#define mpv_render_context_report_swap pfn_mpv_render_context_report_swap
|
||||
MPV_DEFINE_SYM_PTR(mpv_render_context_free)
|
||||
#define mpv_render_context_free pfn_mpv_render_context_free
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,211 @@
|
||||
/* Copyright (C) 2018 the mpv developers
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef MPV_CLIENT_API_RENDER_GL_H_
|
||||
#define MPV_CLIENT_API_RENDER_GL_H_
|
||||
|
||||
#include "render.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* OpenGL backend
|
||||
* --------------
|
||||
*
|
||||
* This header contains definitions for using OpenGL with the render.h API.
|
||||
*
|
||||
* OpenGL interop
|
||||
* --------------
|
||||
*
|
||||
* The OpenGL backend has some special rules, because OpenGL itself uses
|
||||
* implicit per-thread contexts, which causes additional API problems.
|
||||
*
|
||||
* This assumes the OpenGL context lives on a certain thread controlled by the
|
||||
* API user. All mpv_render_* APIs have to be assumed to implicitly use the
|
||||
* OpenGL context if you pass a mpv_render_context using the OpenGL backend,
|
||||
* unless specified otherwise.
|
||||
*
|
||||
* The OpenGL context is indirectly accessed through the OpenGL function
|
||||
* pointers returned by the get_proc_address callback in mpv_opengl_init_params.
|
||||
* Generally, mpv will not load the system OpenGL library when using this API.
|
||||
*
|
||||
* OpenGL state
|
||||
* ------------
|
||||
*
|
||||
* OpenGL has a large amount of implicit state. All the mpv functions mentioned
|
||||
* above expect that the OpenGL state is reasonably set to OpenGL standard
|
||||
* defaults. Likewise, mpv will attempt to leave the OpenGL context with
|
||||
* standard defaults. The following state is excluded from this:
|
||||
*
|
||||
* - the glViewport state
|
||||
* - the glScissor state (but GL_SCISSOR_TEST is in its default value)
|
||||
* - glBlendFuncSeparate() state (but GL_BLEND is in its default value)
|
||||
* - glClearColor() state
|
||||
* - mpv may overwrite the callback set with glDebugMessageCallback()
|
||||
* - mpv always disables GL_DITHER at init
|
||||
*
|
||||
* Messing with the state could be avoided by creating shared OpenGL contexts,
|
||||
* but this is avoided for the sake of compatibility and interoperability.
|
||||
*
|
||||
* On OpenGL 2.1, mpv will strictly call functions like glGenTextures() to
|
||||
* create OpenGL objects. You will have to do the same. This ensures that
|
||||
* objects created by mpv and the API users don't clash. Also, legacy state
|
||||
* must be either in its defaults, or not interfere with core state.
|
||||
*
|
||||
* API use
|
||||
* -------
|
||||
*
|
||||
* The mpv_render_* API is used. That API supports multiple backends, and this
|
||||
* section documents specifics for the OpenGL backend.
|
||||
*
|
||||
* Use mpv_render_context_create() with MPV_RENDER_PARAM_API_TYPE set to
|
||||
* MPV_RENDER_API_TYPE_OPENGL, and MPV_RENDER_PARAM_OPENGL_INIT_PARAMS provided.
|
||||
*
|
||||
* Call mpv_render_context_render() with MPV_RENDER_PARAM_OPENGL_FBO to render
|
||||
* the video frame to an FBO.
|
||||
*
|
||||
* Hardware decoding
|
||||
* -----------------
|
||||
*
|
||||
* Hardware decoding via this API is fully supported, but requires some
|
||||
* additional setup. (At least if direct hardware decoding modes are wanted,
|
||||
* instead of copying back surface data from GPU to CPU RAM.)
|
||||
*
|
||||
* There may be certain requirements on the OpenGL implementation:
|
||||
*
|
||||
* - Windows: ANGLE is required (although in theory GL/DX interop could be used)
|
||||
* - Intel/Linux: EGL is required, and also the native display resource needs
|
||||
* to be provided (e.g. MPV_RENDER_PARAM_X11_DISPLAY for X11 and
|
||||
* MPV_RENDER_PARAM_WL_DISPLAY for Wayland)
|
||||
* - nVidia/Linux: Both GLX and EGL should work (GLX is required if vdpau is
|
||||
* used, e.g. due to old drivers.)
|
||||
* - macOS: CGL is required (CGLGetCurrentContext() returning non-NULL)
|
||||
* - iOS: EAGL is required (EAGLContext.currentContext returning non-nil)
|
||||
*
|
||||
* Once these things are setup, hardware decoding can be enabled/disabled at
|
||||
* any time by setting the "hwdec" property.
|
||||
*/
|
||||
|
||||
/**
|
||||
* For initializing the mpv OpenGL state via MPV_RENDER_PARAM_OPENGL_INIT_PARAMS.
|
||||
*/
|
||||
typedef struct mpv_opengl_init_params {
|
||||
/**
|
||||
* This retrieves OpenGL function pointers, and will use them in subsequent
|
||||
* operation.
|
||||
* Usually, you can simply call the GL context APIs from this callback (e.g.
|
||||
* glXGetProcAddressARB or wglGetProcAddress), but some APIs do not always
|
||||
* return pointers for all standard functions (even if present); in this
|
||||
* case you have to compensate by looking up these functions yourself when
|
||||
* libmpv wants to resolve them through this callback.
|
||||
* libmpv will not normally attempt to resolve GL functions on its own, nor
|
||||
* does it link to GL libraries directly.
|
||||
*/
|
||||
void *(*get_proc_address)(void *ctx, const char *name);
|
||||
/**
|
||||
* Value passed as ctx parameter to get_proc_address().
|
||||
*/
|
||||
void *get_proc_address_ctx;
|
||||
} mpv_opengl_init_params;
|
||||
|
||||
/**
|
||||
* For MPV_RENDER_PARAM_OPENGL_FBO.
|
||||
*/
|
||||
typedef struct mpv_opengl_fbo {
|
||||
/**
|
||||
* Framebuffer object name. This must be either a valid FBO generated by
|
||||
* glGenFramebuffers() that is complete and color-renderable, or 0. If the
|
||||
* value is 0, this refers to the OpenGL default framebuffer.
|
||||
*/
|
||||
int fbo;
|
||||
/**
|
||||
* Valid dimensions. This must refer to the size of the framebuffer. This
|
||||
* must always be set.
|
||||
*/
|
||||
int w, h;
|
||||
/**
|
||||
* Underlying texture internal format (e.g. GL_RGBA8), or 0 if unknown. If
|
||||
* this is the default framebuffer, this can be an equivalent.
|
||||
*/
|
||||
int internal_format;
|
||||
} mpv_opengl_fbo;
|
||||
|
||||
/**
|
||||
* Deprecated. For MPV_RENDER_PARAM_DRM_DISPLAY.
|
||||
*/
|
||||
typedef struct mpv_opengl_drm_params {
|
||||
int fd;
|
||||
int crtc_id;
|
||||
int connector_id;
|
||||
struct _drmModeAtomicReq **atomic_request_ptr;
|
||||
int render_fd;
|
||||
} mpv_opengl_drm_params;
|
||||
|
||||
/**
|
||||
* For MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE.
|
||||
*/
|
||||
typedef struct mpv_opengl_drm_draw_surface_size {
|
||||
/**
|
||||
* size of the draw plane surface in pixels.
|
||||
*/
|
||||
int width, height;
|
||||
} mpv_opengl_drm_draw_surface_size;
|
||||
|
||||
/**
|
||||
* For MPV_RENDER_PARAM_DRM_DISPLAY_V2.
|
||||
*/
|
||||
typedef struct mpv_opengl_drm_params_v2 {
|
||||
/**
|
||||
* DRM fd (int). Set to -1 if invalid.
|
||||
*/
|
||||
int fd;
|
||||
|
||||
/**
|
||||
* Currently used crtc id
|
||||
*/
|
||||
int crtc_id;
|
||||
|
||||
/**
|
||||
* Currently used connector id
|
||||
*/
|
||||
int connector_id;
|
||||
|
||||
/**
|
||||
* Pointer to a drmModeAtomicReq pointer that is being used for the renderloop.
|
||||
* This pointer should hold a pointer to the atomic request pointer
|
||||
* The atomic request pointer is usually changed at every renderloop.
|
||||
*/
|
||||
struct _drmModeAtomicReq **atomic_request_ptr;
|
||||
|
||||
/**
|
||||
* DRM render node. Used for VAAPI interop.
|
||||
* Set to -1 if invalid.
|
||||
*/
|
||||
int render_fd;
|
||||
} mpv_opengl_drm_params_v2;
|
||||
|
||||
|
||||
/**
|
||||
* For backwards compatibility with the old naming of mpv_opengl_drm_draw_surface_size
|
||||
*/
|
||||
#define mpv_opengl_drm_osd_size mpv_opengl_drm_draw_surface_size
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,247 @@
|
||||
/* Copyright (C) 2017 the mpv developers
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef MPV_CLIENT_API_STREAM_CB_H_
|
||||
#define MPV_CLIENT_API_STREAM_CB_H_
|
||||
|
||||
#include "client.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Warning: this API is not stable yet.
|
||||
*
|
||||
* Overview
|
||||
* --------
|
||||
*
|
||||
* This API can be used to make mpv read from a stream with a custom
|
||||
* implementation. This interface is inspired by funopen on BSD and
|
||||
* fopencookie on linux. The stream is backed by user-defined callbacks
|
||||
* which can implement customized open, read, seek, size and close behaviors.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
*
|
||||
* Register your stream callbacks with the mpv_stream_cb_add_ro() function. You
|
||||
* have to provide a mpv_stream_cb_open_ro_fn callback to it (open_fn argument).
|
||||
*
|
||||
* Once registered, you can `loadfile myprotocol://myfile`. Your open_fn will be
|
||||
* invoked with the URI and you must fill out the provided mpv_stream_cb_info
|
||||
* struct. This includes your stream callbacks (like read_fn), and an opaque
|
||||
* cookie, which will be passed as the first argument to all the remaining
|
||||
* stream callbacks.
|
||||
*
|
||||
* Note that your custom callbacks must not invoke libmpv APIs as that would
|
||||
* cause a deadlock. (Unless you call a different mpv_handle than the one the
|
||||
* callback was registered for, and the mpv_handles refer to different mpv
|
||||
* instances.)
|
||||
*
|
||||
* Stream lifetime
|
||||
* ---------------
|
||||
*
|
||||
* A stream remains valid until its close callback has been called. It's up to
|
||||
* libmpv to call the close callback, and the libmpv user cannot close it
|
||||
* directly with the stream_cb API.
|
||||
*
|
||||
* For example, if you consider your custom stream to become suddenly invalid
|
||||
* (maybe because the underlying stream died), libmpv will continue using your
|
||||
* stream. All you can do is returning errors from each callback, until libmpv
|
||||
* gives up and closes it.
|
||||
*
|
||||
* Protocol registration and lifetime
|
||||
* ----------------------------------
|
||||
*
|
||||
* Protocols remain registered until the mpv instance is terminated. This means
|
||||
* in particular that it can outlive the mpv_handle that was used to register
|
||||
* it, but once mpv_terminate_destroy() is called, your registered callbacks
|
||||
* will not be called again.
|
||||
*
|
||||
* Protocol unregistration is finished after the mpv core has been destroyed
|
||||
* (e.g. after mpv_terminate_destroy() has returned).
|
||||
*
|
||||
* If you do not call mpv_terminate_destroy() yourself (e.g. plugin-style code),
|
||||
* you will have to deal with the registration or even streams outliving your
|
||||
* code. Here are some possible ways to do this:
|
||||
* - call mpv_terminate_destroy(), which destroys the core, and will make sure
|
||||
* all streams are closed once this function returns
|
||||
* - you refcount all resources your stream "cookies" reference, so that it
|
||||
* doesn't matter if streams live longer than expected
|
||||
* - create "cancellation" semantics: after your protocol has been unregistered,
|
||||
* notify all your streams that are still opened, and make them drop all
|
||||
* referenced resources - then return errors from the stream callbacks as
|
||||
* long as the stream is still opened
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read callback used to implement a custom stream. The semantics of the
|
||||
* callback match read(2) in blocking mode. Short reads are allowed (you can
|
||||
* return less bytes than requested, and libmpv will retry reading the rest
|
||||
* with another call). If no data can be immediately read, the callback must
|
||||
* block until there is new data. A return of 0 will be interpreted as final
|
||||
* EOF, although libmpv might retry the read, or seek to a different position.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
* @param buf buffer to read data into
|
||||
* @param size of the buffer
|
||||
* @return number of bytes read into the buffer
|
||||
* @return 0 on EOF
|
||||
* @return -1 on error
|
||||
*/
|
||||
typedef int64_t (*mpv_stream_cb_read_fn)(void *cookie, char *buf, uint64_t nbytes);
|
||||
|
||||
/**
|
||||
* Seek callback used to implement a custom stream.
|
||||
*
|
||||
* Note that mpv will issue a seek to position 0 immediately after opening. This
|
||||
* is used to test whether the stream is seekable (since seekability might
|
||||
* depend on the URI contents, not just the protocol). Return
|
||||
* MPV_ERROR_UNSUPPORTED if seeking is not implemented for this stream. This
|
||||
* seek also serves to establish the fact that streams start at position 0.
|
||||
*
|
||||
* This callback can be NULL, in which it behaves as if always returning
|
||||
* MPV_ERROR_UNSUPPORTED.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
* @param offset target absolute stream position
|
||||
* @return the resulting offset of the stream
|
||||
* MPV_ERROR_UNSUPPORTED or MPV_ERROR_GENERIC if the seek failed
|
||||
*/
|
||||
typedef int64_t (*mpv_stream_cb_seek_fn)(void *cookie, int64_t offset);
|
||||
|
||||
/**
|
||||
* Size callback used to implement a custom stream.
|
||||
*
|
||||
* Return MPV_ERROR_UNSUPPORTED if no size is known.
|
||||
*
|
||||
* This callback can be NULL, in which it behaves as if always returning
|
||||
* MPV_ERROR_UNSUPPORTED.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
* @return the total size in bytes of the stream
|
||||
*/
|
||||
typedef int64_t (*mpv_stream_cb_size_fn)(void *cookie);
|
||||
|
||||
/**
|
||||
* Close callback used to implement a custom stream.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
*/
|
||||
typedef void (*mpv_stream_cb_close_fn)(void *cookie);
|
||||
|
||||
/**
|
||||
* Cancel callback used to implement a custom stream.
|
||||
*
|
||||
* This callback is used to interrupt any current or future read and seek
|
||||
* operations. It will be called from a separate thread than the demux
|
||||
* thread, and should not block.
|
||||
*
|
||||
* This callback can be NULL.
|
||||
*
|
||||
* Available since API 1.106.
|
||||
*
|
||||
* @param cookie opaque cookie identifying the stream,
|
||||
* returned from mpv_stream_cb_open_fn
|
||||
*/
|
||||
typedef void (*mpv_stream_cb_cancel_fn)(void *cookie);
|
||||
|
||||
/**
|
||||
* See mpv_stream_cb_open_ro_fn callback.
|
||||
*/
|
||||
typedef struct mpv_stream_cb_info {
|
||||
/**
|
||||
* Opaque user-provided value, which will be passed to the other callbacks.
|
||||
* The close callback will be called to release the cookie. It is not
|
||||
* interpreted by mpv. It doesn't even need to be a valid pointer.
|
||||
*
|
||||
* The user sets this in the mpv_stream_cb_open_ro_fn callback.
|
||||
*/
|
||||
void *cookie;
|
||||
|
||||
/**
|
||||
* Callbacks set by the user in the mpv_stream_cb_open_ro_fn callback. Some
|
||||
* of them are optional, and can be left unset.
|
||||
*
|
||||
* The following callbacks are mandatory: read_fn, close_fn
|
||||
*/
|
||||
mpv_stream_cb_read_fn read_fn;
|
||||
mpv_stream_cb_seek_fn seek_fn;
|
||||
mpv_stream_cb_size_fn size_fn;
|
||||
mpv_stream_cb_close_fn close_fn;
|
||||
mpv_stream_cb_cancel_fn cancel_fn; /* since API 1.106 */
|
||||
} mpv_stream_cb_info;
|
||||
|
||||
/**
|
||||
* Open callback used to implement a custom read-only (ro) stream. The user
|
||||
* must set the callback fields in the passed info struct. The cookie field
|
||||
* also can be set to store state associated to the stream instance.
|
||||
*
|
||||
* Note that the info struct is valid only for the duration of this callback.
|
||||
* You can't change the callbacks or the pointer to the cookie at a later point.
|
||||
*
|
||||
* Each stream instance created by the open callback can have different
|
||||
* callbacks.
|
||||
*
|
||||
* The close_fn callback will terminate the stream instance. The pointers to
|
||||
* your callbacks and cookie will be discarded, and the callbacks will not be
|
||||
* called again.
|
||||
*
|
||||
* @param user_data opaque user data provided via mpv_stream_cb_add()
|
||||
* @param uri name of the stream to be opened (with protocol prefix)
|
||||
* @param info fields which the user should fill
|
||||
* @return 0 on success, MPV_ERROR_LOADING_FAILED if the URI cannot be opened.
|
||||
*/
|
||||
typedef int (*mpv_stream_cb_open_ro_fn)(void *user_data, char *uri,
|
||||
mpv_stream_cb_info *info);
|
||||
|
||||
/**
|
||||
* Add a custom stream protocol. This will register a protocol handler under
|
||||
* the given protocol prefix, and invoke the given callbacks if an URI with the
|
||||
* matching protocol prefix is opened.
|
||||
*
|
||||
* The "ro" is for read-only - only read-only streams can be registered with
|
||||
* this function.
|
||||
*
|
||||
* The callback remains registered until the mpv core is registered.
|
||||
*
|
||||
* If a custom stream with the same name is already registered, then the
|
||||
* MPV_ERROR_INVALID_PARAMETER error is returned.
|
||||
*
|
||||
* @param protocol protocol prefix, for example "foo" for "foo://" URIs
|
||||
* @param user_data opaque pointer passed into the mpv_stream_cb_open_fn
|
||||
* callback.
|
||||
* @return error code
|
||||
*/
|
||||
MPV_EXPORT int mpv_stream_cb_add_ro(mpv_handle *ctx, const char *protocol, void *user_data,
|
||||
mpv_stream_cb_open_ro_fn open_fn);
|
||||
|
||||
#ifdef MPV_CPLUGIN_DYNAMIC_SYM
|
||||
|
||||
MPV_DEFINE_SYM_PTR(mpv_stream_cb_add_ro)
|
||||
#define mpv_stream_cb_add_ro pfn_mpv_stream_cb_add_ro
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
#define UTIL_EXTERN
|
||||
#include "jni_utils.h"
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "utf8_convert.h"
|
||||
|
||||
jstring new_java_string(JNIEnv* env, const char* utf8) {
|
||||
if (!utf8) return NULL;
|
||||
const std::u16string u16 = plezy::utf8::ToUtf16(utf8);
|
||||
return env->NewString(reinterpret_cast<const jchar*>(u16.data()), static_cast<jsize>(u16.size()));
|
||||
}
|
||||
|
||||
std::string java_string_to_utf8(JNIEnv* env, jstring jstr) {
|
||||
if (!jstr) return std::string();
|
||||
const jsize len = env->GetStringLength(jstr);
|
||||
const jchar* chars = env->GetStringChars(jstr, NULL);
|
||||
if (!chars) return std::string();
|
||||
std::string out = plezy::utf8::FromUtf16(reinterpret_cast<const char16_t*>(chars), static_cast<size_t>(len));
|
||||
env->ReleaseStringChars(jstr, chars);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool acquire_jni_env(JavaVM* vm, JNIEnv** env) {
|
||||
int ret = vm->GetEnv((void**)env, JNI_VERSION_1_6);
|
||||
if (ret == JNI_EDETACHED)
|
||||
return vm->AttachCurrentThread(env, NULL) == 0;
|
||||
else
|
||||
return ret == JNI_OK;
|
||||
}
|
||||
|
||||
void init_methods_cache(JNIEnv* env) {
|
||||
static bool methods_initialized = false;
|
||||
if (methods_initialized) return;
|
||||
|
||||
// Plain assignments straight from env->FindClass, promoted to global refs
|
||||
// on the following line, keep every Get*MethodID owner traceable for
|
||||
// scripts/checks/check_shrinker_rules.py.
|
||||
java_Integer = env->FindClass("java/lang/Integer");
|
||||
java_Integer = reinterpret_cast<jclass>(env->NewGlobalRef(java_Integer));
|
||||
java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V");
|
||||
java_Double = env->FindClass("java/lang/Double");
|
||||
java_Double = reinterpret_cast<jclass>(env->NewGlobalRef(java_Double));
|
||||
java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V");
|
||||
java_Boolean = env->FindClass("java/lang/Boolean");
|
||||
java_Boolean = reinterpret_cast<jclass>(env->NewGlobalRef(java_Boolean));
|
||||
java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V");
|
||||
|
||||
mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");
|
||||
mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));
|
||||
mpv_MpvPlayer_onPropertyChanged_S =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_Sb =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Z)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_Sl =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;J)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_Sd =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;D)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SS =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(I)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(I)V");
|
||||
mpv_MpvPlayer_onLogMessage =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V");
|
||||
|
||||
methods_initialized = true;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#define jni_func_name(name) Java_com_edde746_plezy_libmpv_MpvPlayer_##name
|
||||
#define jni_func(return_type, name, ...) \
|
||||
JNIEXPORT return_type JNICALL jni_func_name(name)(JNIEnv * env, jobject obj, ##__VA_ARGS__)
|
||||
|
||||
bool acquire_jni_env(JavaVM* vm, JNIEnv** env);
|
||||
void init_methods_cache(JNIEnv* env);
|
||||
|
||||
// Standard-UTF-8 string crossings; see utf8_convert.h for why NewStringUTF /
|
||||
// GetStringUTFChars are wrong for mpv data. `utf8` may be NULL (-> NULL).
|
||||
jstring new_java_string(JNIEnv* env, const char* utf8);
|
||||
// `jstr` may be NULL (-> empty).
|
||||
std::string java_string_to_utf8(JNIEnv* env, jstring jstr);
|
||||
|
||||
#ifndef UTIL_EXTERN
|
||||
#define UTIL_EXTERN extern
|
||||
#endif
|
||||
|
||||
UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean;
|
||||
UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
|
||||
|
||||
UTIL_EXTERN jclass mpv_MpvPlayer;
|
||||
UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_S, mpv_MpvPlayer_onPropertyChanged_Sb,
|
||||
mpv_MpvPlayer_onPropertyChanged_Sl, mpv_MpvPlayer_onPropertyChanged_Sd, mpv_MpvPlayer_onPropertyChanged_SS,
|
||||
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage;
|
||||
@@ -0,0 +1,13 @@
|
||||
#include "log.h"
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
|
||||
void die(const char* msg) {
|
||||
ALOGE("%s", msg);
|
||||
JNIEnv* env = nullptr;
|
||||
if (g_vm && acquire_jni_env(g_vm, &env) && env) {
|
||||
jclass cls = env->FindClass("java/lang/RuntimeException");
|
||||
if (cls) env->ThrowNew(cls, msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define DEBUG 1
|
||||
|
||||
#define LOG_TAG "mpv"
|
||||
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#if DEBUG
|
||||
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
|
||||
#else
|
||||
#define ALOGV(...) (void)0
|
||||
#endif
|
||||
|
||||
void die(const char* msg);
|
||||
|
||||
#define CHECK_MPV_INIT() \
|
||||
do { \
|
||||
if (__builtin_expect(!g_mpv, 0)) { \
|
||||
die("libmpv is not initialized"); \
|
||||
return; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_MPV_INIT_RET(val) \
|
||||
do { \
|
||||
if (__builtin_expect(!g_mpv, 0)) { \
|
||||
die("libmpv is not initialized"); \
|
||||
return val; \
|
||||
} \
|
||||
} while (0)
|
||||
@@ -0,0 +1,157 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <clocale>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/jni.h>
|
||||
}
|
||||
|
||||
#include "event.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
#define ARRAYLEN(a) (sizeof(a) / sizeof(a[0]))
|
||||
|
||||
void render_cleanup(JNIEnv* env);
|
||||
|
||||
static void* destroy_mpv_thread(void* arg) {
|
||||
mpv_handle* handle = (mpv_handle*)arg;
|
||||
mpv_terminate_destroy(handle);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Fire-and-forget mpv_terminate_destroy on a detached thread.
|
||||
// Safe because the event thread has been joined and g_mpv cleared —
|
||||
// no other code references this handle.
|
||||
static void async_destroy(mpv_handle* handle) {
|
||||
pthread_t tid;
|
||||
if (pthread_create(&tid, NULL, destroy_mpv_thread, handle) == 0) {
|
||||
pthread_detach(tid);
|
||||
} else {
|
||||
// Fallback: destroy synchronously if thread creation fails
|
||||
mpv_terminate_destroy(handle);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeCreate, jobject appctx);
|
||||
jni_func(void, nativeInit);
|
||||
jni_func(void, nativeDestroy);
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray);
|
||||
};
|
||||
|
||||
JavaVM* g_vm;
|
||||
mpv_handle* g_mpv;
|
||||
std::atomic<bool> g_event_thread_request_exit(false);
|
||||
|
||||
static pthread_t event_thread_id;
|
||||
static std::mutex g_lifecycle_mutex;
|
||||
|
||||
static void prepare_environment(JNIEnv* env, jobject appctx) {
|
||||
setlocale(LC_NUMERIC, "C");
|
||||
|
||||
if (!env->GetJavaVM(&g_vm) && g_vm) av_jni_set_java_vm(g_vm, NULL);
|
||||
|
||||
jobject global_appctx = env->NewGlobalRef(appctx);
|
||||
if (global_appctx) av_jni_set_android_app_ctx(global_appctx, NULL);
|
||||
|
||||
init_methods_cache(env);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCreate, jobject appctx) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
mpv_handle* leaked_mpv = NULL;
|
||||
if (g_mpv) {
|
||||
ALOGE("destroying leaked mpv instance");
|
||||
leaked_mpv = g_mpv;
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(leaked_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
g_mpv = NULL;
|
||||
render_cleanup(env);
|
||||
}
|
||||
|
||||
g_mpv = mpv_create();
|
||||
if (!g_mpv) {
|
||||
die("context init failed");
|
||||
if (leaked_mpv) mpv_terminate_destroy(leaked_mpv);
|
||||
return;
|
||||
}
|
||||
|
||||
mpv_request_log_messages(g_mpv, "v");
|
||||
|
||||
// Async teardown of leaked handle — doesn't block caller
|
||||
if (leaked_mpv) async_destroy(leaked_mpv);
|
||||
}
|
||||
|
||||
jni_func(void, nativeInit) {
|
||||
if (!g_mpv) {
|
||||
die("mpv is not created");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mpv_initialize(g_mpv) < 0) {
|
||||
die("mpv init failed");
|
||||
return;
|
||||
}
|
||||
|
||||
g_event_thread_request_exit = false;
|
||||
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
|
||||
die("thread create failed");
|
||||
return;
|
||||
}
|
||||
pthread_setname_np(event_thread_id, "event_thread");
|
||||
}
|
||||
|
||||
jni_func(void, nativeDestroy) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
if (!g_mpv) {
|
||||
ALOGV("mpv destroy called but it's already destroyed");
|
||||
return;
|
||||
}
|
||||
mpv_handle* local_mpv = g_mpv;
|
||||
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(local_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
|
||||
g_mpv = NULL;
|
||||
render_cleanup(env);
|
||||
|
||||
// Async teardown — nativeDestroy returns immediately
|
||||
async_destroy(local_mpv);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
const char* arguments[128] = {0};
|
||||
int len = env->GetArrayLength(jarray);
|
||||
if (len >= (int)ARRAYLEN(arguments)) {
|
||||
die("too many command arguments");
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<std::string> storage;
|
||||
storage.reserve(len);
|
||||
for (int i = 0; i < len; ++i) {
|
||||
jstring jarg = (jstring)env->GetObjectArrayElement(jarray, i);
|
||||
storage.push_back(java_string_to_utf8(env, jarg));
|
||||
arguments[i] = storage.back().c_str();
|
||||
env->DeleteLocalRef(jarg);
|
||||
}
|
||||
|
||||
mpv_command(g_mpv, arguments);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jint, nativeSetOptionString, jstring option, jstring value);
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue);
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
|
||||
CHECK_MPV_INIT_RET(0);
|
||||
|
||||
const char* option = env->GetStringUTFChars(joption, NULL);
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
|
||||
int result = mpv_set_option_string(g_mpv, option, value.c_str());
|
||||
|
||||
env->ReleaseStringUTFChars(joption, option);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_get_property(JNIEnv* env, jstring jproperty, mpv_format format, void* output) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_get_property(g_mpv, prop, format, output);
|
||||
if (result < 0) ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format, void* value) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_set_property(g_mpv, prop, format, value);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
|
||||
int64_t value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
|
||||
double value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
|
||||
int value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
|
||||
}
|
||||
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
|
||||
char* value;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
jstring jvalue = new_java_string(env, value);
|
||||
mpv_free(value);
|
||||
return jvalue;
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyInt, jstring jproperty, jint jvalue) {
|
||||
int64_t value = static_cast<int64_t>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
|
||||
double value = static_cast<double>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
|
||||
int value = jvalue == JNI_TRUE ? 1 : 0;
|
||||
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue) {
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
const char* value_ptr = value.c_str();
|
||||
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
}
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format) {
|
||||
CHECK_MPV_INIT();
|
||||
const char* prop = env->GetStringUTFChars(property, NULL);
|
||||
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
|
||||
if (result < 0) ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(property, prop);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeAttachSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachSurface);
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachOsdSurface);
|
||||
};
|
||||
|
||||
static jobject surface;
|
||||
|
||||
jni_func(void, nativeAttachSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
surface = env->NewGlobalRef(surface_);
|
||||
if (!surface) {
|
||||
die("invalid surface provided");
|
||||
return;
|
||||
}
|
||||
int64_t wid = reinterpret_cast<intptr_t>(surface);
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, nativeDetachSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(surface);
|
||||
surface = NULL;
|
||||
}
|
||||
|
||||
static jobject osd_surface;
|
||||
|
||||
// The OSD plane of vo=mediacodec. Same lifetime rules as the video surface:
|
||||
// the global ref must outlive the VO, so detach only after vo has been unset.
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
osd_surface = env->NewGlobalRef(surface_);
|
||||
if (!osd_surface) {
|
||||
die("invalid osd surface provided");
|
||||
return;
|
||||
}
|
||||
int64_t wid = reinterpret_cast<intptr_t>(osd_surface);
|
||||
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, nativeDetachOsdSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
if (!osd_surface) return;
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = NULL;
|
||||
}
|
||||
|
||||
void render_cleanup(JNIEnv* env) {
|
||||
if (surface) {
|
||||
env->DeleteGlobalRef(surface);
|
||||
surface = NULL;
|
||||
}
|
||||
if (osd_surface) {
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// UTF-8 <-> UTF-16 transcoding for the JNI boundary.
|
||||
//
|
||||
// mpv speaks standard UTF-8. JNI's NewStringUTF/GetStringUTFChars speak
|
||||
// *modified* UTF-8: supplementary-plane characters are CESU-8 surrogate pairs
|
||||
// and NUL is 0xC0 0x80. Feeding one encoding to the other corrupts emoji in
|
||||
// file names and titles, and malformed bytes from mpv (log lines, ID3 tags,
|
||||
// system-encoded paths) abort under CheckJNI. Both directions therefore go
|
||||
// through UTF-16 with NewString/GetStringChars; malformed input is replaced
|
||||
// with U+FFFD one unit at a time, matching shared/cpp/sanitize_utf8.h on
|
||||
// desktop. Header-only and JNI-free so the host test harness can exercise it.
|
||||
|
||||
namespace plezy {
|
||||
namespace utf8 {
|
||||
|
||||
// Decodes one scalar value at `s`. Returns the number of bytes consumed, or 0
|
||||
// when `s` does not start a well-formed sequence (Unicode Table 3-7).
|
||||
inline size_t DecodeOne(const unsigned char* s, size_t len, uint32_t* cp) {
|
||||
const unsigned char c = s[0];
|
||||
if (c < 0x80) {
|
||||
*cp = c;
|
||||
return 1;
|
||||
}
|
||||
size_t need;
|
||||
unsigned char lo = 0x80, hi = 0xBF;
|
||||
if (c >= 0xC2 && c <= 0xDF) {
|
||||
need = 2;
|
||||
*cp = c & 0x1F;
|
||||
} else if (c >= 0xE0 && c <= 0xEF) {
|
||||
need = 3;
|
||||
*cp = c & 0x0F;
|
||||
if (c == 0xE0) lo = 0xA0;
|
||||
if (c == 0xED) hi = 0x9F; // no surrogates
|
||||
} else if (c >= 0xF0 && c <= 0xF4) {
|
||||
need = 4;
|
||||
*cp = c & 0x07;
|
||||
if (c == 0xF0) lo = 0x90;
|
||||
if (c == 0xF4) hi = 0x8F; // <= U+10FFFF
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
if (len < need) return 0;
|
||||
for (size_t i = 1; i < need; ++i) {
|
||||
const unsigned char b = s[i];
|
||||
if (b < lo || b > hi) return 0;
|
||||
lo = 0x80;
|
||||
hi = 0xBF;
|
||||
*cp = (*cp << 6) | (b & 0x3F);
|
||||
}
|
||||
return need;
|
||||
}
|
||||
|
||||
// Standard UTF-8 -> UTF-16. Malformed bytes become U+FFFD.
|
||||
inline std::u16string ToUtf16(const char* input, size_t len) {
|
||||
std::u16string out;
|
||||
if (!input) return out;
|
||||
out.reserve(len);
|
||||
const unsigned char* s = reinterpret_cast<const unsigned char*>(input);
|
||||
size_t pos = 0;
|
||||
while (pos < len) {
|
||||
uint32_t cp;
|
||||
const size_t n = DecodeOne(s + pos, len - pos, &cp);
|
||||
if (n == 0) {
|
||||
out.push_back(u'\uFFFD');
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
pos += n;
|
||||
if (cp < 0x10000) {
|
||||
out.push_back(static_cast<char16_t>(cp));
|
||||
} else {
|
||||
cp -= 0x10000;
|
||||
out.push_back(static_cast<char16_t>(0xD800 | (cp >> 10)));
|
||||
out.push_back(static_cast<char16_t>(0xDC00 | (cp & 0x3FF)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline std::u16string ToUtf16(const char* input) {
|
||||
return input ? ToUtf16(input, std::char_traits<char>::length(input)) : std::u16string();
|
||||
}
|
||||
|
||||
// UTF-16 -> standard UTF-8. Lone surrogates become U+FFFD.
|
||||
inline std::string FromUtf16(const char16_t* input, size_t len) {
|
||||
std::string out;
|
||||
if (!input) return out;
|
||||
out.reserve(len * 3);
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
uint32_t cp = input[i];
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||
if (i + 1 < len && input[i + 1] >= 0xDC00 && input[i + 1] <= 0xDFFF) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (input[i + 1] - 0xDC00);
|
||||
++i;
|
||||
} else {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
if (cp < 0x80) {
|
||||
out.push_back(static_cast<char>(cp));
|
||||
} else if (cp < 0x800) {
|
||||
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
|
||||
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
|
||||
} else if (cp < 0x10000) {
|
||||
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
|
||||
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
|
||||
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
|
||||
} else {
|
||||
out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
|
||||
out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
|
||||
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
|
||||
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace utf8
|
||||
} // namespace plezy
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
enum class EndFileReason(val id: Int) {
|
||||
Eof(0),
|
||||
Stop(2),
|
||||
Quit(3),
|
||||
Error(4),
|
||||
Redirect(5);
|
||||
|
||||
companion object {
|
||||
fun fromId(id: Int): EndFileReason? = entries.find { it.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
enum class LogLevel(internal val nativeValue: Int) {
|
||||
Fatal(10),
|
||||
Error(20),
|
||||
Warn(30),
|
||||
Info(40),
|
||||
Verbose(50),
|
||||
Debug(60),
|
||||
Trace(70);
|
||||
|
||||
companion object {
|
||||
internal fun fromNative(value: Int): LogLevel? = entries.find { it.nativeValue == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
data class LogMessage(
|
||||
val prefix: String,
|
||||
val level: LogLevel,
|
||||
val text: String
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
sealed interface MpvEvent {
|
||||
data object StartFile : MpvEvent
|
||||
data class EndFile(val reason: EndFileReason?) : MpvEvent
|
||||
data object FileLoaded : MpvEvent
|
||||
data object PlaybackRestart : MpvEvent
|
||||
|
||||
companion object {
|
||||
// Mirrors the ids event.cpp forwards; END_FILE arrives via its own JNI path.
|
||||
internal fun fromId(id: Int): MpvEvent? = when (id) {
|
||||
6 -> StartFile
|
||||
8 -> FileLoaded
|
||||
21 -> PlaybackRestart
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvException(message: String) : RuntimeException(message)
|
||||
@@ -0,0 +1,309 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Surface
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("mpv")
|
||||
System.loadLibrary("player")
|
||||
}
|
||||
|
||||
private val instance = AtomicReference<MpvPlayer?>(null)
|
||||
|
||||
suspend fun create(
|
||||
context: Context,
|
||||
configure: MpvPlayerConfig.() -> Unit = {}
|
||||
): MpvPlayer = withContext(Dispatchers.IO) {
|
||||
val player = MpvPlayer()
|
||||
// Atomically replace; mark old as closed so its background close() skips nativeDestroy
|
||||
instance.getAndSet(player)?.also { it.closed = true }
|
||||
// nativeCreate's safety net handles any leaked native session
|
||||
try {
|
||||
nativeCreate(context.applicationContext)
|
||||
MpvPlayerConfig().apply(configure)
|
||||
nativeInit()
|
||||
ensureActive()
|
||||
player
|
||||
} catch (e: Throwable) {
|
||||
instance.compareAndSet(player, null)
|
||||
try {
|
||||
nativeDestroy()
|
||||
} catch (_: Throwable) {}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// JNI callbacks — called from native event thread
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.None(name))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Flag(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Long) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Int64(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Double) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Double(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: String) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Str(name, value))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEvent(eventId: Int) {
|
||||
val event = MpvEvent.fromId(eventId) ?: return
|
||||
instance.get()?.rawEvents?.trySend(event)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEndFile(reason: Int) {
|
||||
instance.get()?.rawEvents?.trySend(
|
||||
MpvEvent.EndFile(EndFileReason.fromId(reason))
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onLogMessage(prefix: String, level: Int, text: String) {
|
||||
val logLevel = LogLevel.fromNative(level) ?: return
|
||||
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
}
|
||||
|
||||
// JNI native declarations — private to avoid internal name mangling
|
||||
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context)
|
||||
|
||||
@JvmStatic private external fun nativeInit()
|
||||
|
||||
@JvmStatic private external fun nativeDestroy()
|
||||
|
||||
@JvmStatic private external fun nativeCommand(cmd: Array<out String>)
|
||||
|
||||
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
|
||||
|
||||
@JvmStatic private external fun nativeDetachSurface()
|
||||
|
||||
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
|
||||
|
||||
@JvmStatic private external fun nativeDetachOsdSurface()
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
|
||||
|
||||
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
|
||||
|
||||
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
|
||||
}
|
||||
|
||||
// The native event thread hands everything to unbounded channels: trySend
|
||||
// on them cannot fail (until close) and cannot block mpv's event loop. A
|
||||
// pump per stream re-emits into the SharedFlow, whose SUSPEND overflow
|
||||
// parks the pump - not the native thread - while a collector catches up.
|
||||
// The previous design tryEmit-ed straight into the 64-slot SharedFlow
|
||||
// buffer, which silently dropped whatever arrived during a burst; losing
|
||||
// e.g. the one cplayer log line that signals a failed video chain.
|
||||
private val rawEvents = Channel<MpvEvent>(Channel.UNLIMITED)
|
||||
private val rawPropertyChanges = Channel<PropertyChange>(Channel.UNLIMITED)
|
||||
private val rawLogMessages = Channel<LogMessage>(Channel.UNLIMITED)
|
||||
|
||||
private val events = MutableSharedFlow<MpvEvent>(extraBufferCapacity = 64)
|
||||
private val propertyChanges = MutableSharedFlow<PropertyChange>(extraBufferCapacity = 64)
|
||||
private val logMessages = MutableSharedFlow<LogMessage>(extraBufferCapacity = 64)
|
||||
|
||||
private val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
init {
|
||||
pumpScope.launch { for (e in rawEvents) events.emit(e) }
|
||||
pumpScope.launch { for (c in rawPropertyChanges) propertyChanges.emit(c) }
|
||||
pumpScope.launch { for (m in rawLogMessages) logMessages.emit(m) }
|
||||
}
|
||||
|
||||
val eventFlow: SharedFlow<MpvEvent> = events.asSharedFlow()
|
||||
val propertyFlow: SharedFlow<PropertyChange> = propertyChanges.asSharedFlow()
|
||||
val logFlow: SharedFlow<LogMessage> = logMessages.asSharedFlow()
|
||||
|
||||
// Commands
|
||||
|
||||
suspend fun command(vararg args: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeCommand(args) }
|
||||
}
|
||||
|
||||
// Surface — not suspend, called from SurfaceHolder.Callback
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachSurface(surface)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachSurface()
|
||||
}
|
||||
|
||||
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
|
||||
fun attachOsdSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachOsdSurface(surface)
|
||||
}
|
||||
|
||||
fun detachOsdSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachOsdSurface()
|
||||
}
|
||||
|
||||
// Property getters
|
||||
|
||||
suspend fun getInt(name: String): Int? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
|
||||
}
|
||||
|
||||
suspend fun getDouble(name: String): Double? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
|
||||
}
|
||||
|
||||
suspend fun getFlag(name: String): Boolean? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
|
||||
}
|
||||
|
||||
suspend fun getString(name: String): String? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
|
||||
}
|
||||
|
||||
// Property setters
|
||||
|
||||
suspend fun setProperty(name: String, value: Int) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Double) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Boolean) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
|
||||
}
|
||||
|
||||
// Property observation
|
||||
|
||||
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, format.nativeValue)
|
||||
return propertyFlow.filter { it.name == name }
|
||||
}
|
||||
|
||||
fun observeFlag(name: String): Flow<Boolean> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Flag>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeInt(name: String): Flow<Long> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Int64>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeDouble(name: String): Flow<Double> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Double>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
fun observeString(name: String): Flow<String> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Str>()
|
||||
.filter { it.name == name }
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
// Only destroy native if we're still the active player.
|
||||
// If create() already replaced us, nativeCreate's safety net handles native cleanup.
|
||||
if (instance.compareAndSet(this, null)) {
|
||||
nativeDestroy()
|
||||
}
|
||||
// After nativeDestroy no callback can produce: closing the channels
|
||||
// lets each pump drain what is already queued and then complete.
|
||||
rawEvents.close()
|
||||
rawPropertyChanges.close()
|
||||
rawLogMessages.close()
|
||||
}
|
||||
|
||||
private fun checkNotClosed() {
|
||||
check(!closed) { "MpvPlayer has been closed" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvPlayerConfig internal constructor() {
|
||||
fun setOption(name: String, value: String) {
|
||||
val result = MpvPlayer.setOptionString(name, value)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set option '$name' to '$value': error $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
sealed interface PropertyChange {
|
||||
val name: String
|
||||
|
||||
data class None(override val name: String) : PropertyChange
|
||||
data class Flag(override val name: String, val value: Boolean) : PropertyChange
|
||||
data class Int64(override val name: String, val value: Long) : PropertyChange
|
||||
data class Double(override val name: String, val value: kotlin.Double) : PropertyChange
|
||||
data class Str(override val name: String, val value: String) : PropertyChange
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
enum class PropertyFormat(internal val nativeValue: Int) {
|
||||
None(0),
|
||||
String(1),
|
||||
Flag(3),
|
||||
Int64(4),
|
||||
Double(5)
|
||||
}
|
||||
@@ -25,3 +25,4 @@ plugins {
|
||||
|
||||
include(":app")
|
||||
include(":libass")
|
||||
include(":libmpv")
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -797,12 +797,12 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */ = {
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/edde746/MPVKit";
|
||||
repositoryURL = "https://github.com/edde746/mpv-build";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
|
||||
revision = dafa7762af20052031a7b512c9761a5d8bde327d;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -810,7 +810,7 @@
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
6A8A46202EDB320D0057B88C /* MPVKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */;
|
||||
productName = MPVKit;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"identity" : "mpv-build",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"identity" : "mpv-build",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:convert';
|
||||
|
||||
import '../models/plex/plex_home.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../profiles/plex_home_cache_codec.dart';
|
||||
import '../profiles/profile_registry.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/server_registry.dart';
|
||||
@@ -20,24 +20,23 @@ import 'plex_account_setup.dart';
|
||||
///
|
||||
/// Plex Home users are NOT persisted here — the bootstrap copies the
|
||||
/// legacy `homeUsersCache` into the per-connection
|
||||
/// `plex_home_users_{connectionId}` SharedPreferences slot so
|
||||
/// [PlexHomeService] picks it up on cold start.
|
||||
/// `plex_home_users_{connectionId}` SharedPreferences slot and asks
|
||||
/// [PlexHomeService] to reload (or fetch) it.
|
||||
class ConnectionBootstrap {
|
||||
ConnectionBootstrap({
|
||||
required this.storage,
|
||||
required this.connectionRegistry,
|
||||
required this.serverRegistry,
|
||||
required this.profileRegistry,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
required this.plexHome,
|
||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
|
||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
}) : _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
|
||||
final StorageService storage;
|
||||
final ConnectionRegistry connectionRegistry;
|
||||
final ServerRegistry serverRegistry;
|
||||
final ProfileRegistry profileRegistry;
|
||||
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
|
||||
final PlexHomeService plexHome;
|
||||
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
|
||||
|
||||
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
|
||||
@@ -63,6 +62,9 @@ class ConnectionBootstrap {
|
||||
final prepared = await _preparePlexVirtualProfile(account);
|
||||
if (!prepared) {
|
||||
if (migratedAccount != null && hadLegacyPlexToken) {
|
||||
// A failed/empty fetch may have left an empty cache slot on disk
|
||||
// for the id we are about to drop; don't leave it orphaned.
|
||||
await storage.clearPlexHomeUsersCache(migratedAccount.id);
|
||||
await connectionRegistry.remove(migratedAccount.id);
|
||||
}
|
||||
appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later');
|
||||
@@ -147,9 +149,12 @@ class ConnectionBootstrap {
|
||||
/// profile. Plex users are never persisted as local Plezy profiles.
|
||||
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
|
||||
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
|
||||
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
|
||||
users ??= await _fetchAndCachePlexHomeUsers(account);
|
||||
final hydratedUsers = users;
|
||||
if (copied) {
|
||||
await plexHome.reloadFromStorage();
|
||||
} else {
|
||||
await plexHome.refresh(account);
|
||||
}
|
||||
final hydratedUsers = plexHome.current[account.id] ?? const [];
|
||||
if (hydratedUsers.isEmpty) return false;
|
||||
|
||||
final legacyActiveUuid = storage.getCurrentUserUUID();
|
||||
@@ -196,31 +201,6 @@ class ConnectionBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
|
||||
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return decodePlexHomeUsersCache(raw);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
|
||||
try {
|
||||
final users = await _plexHomeUserFetcher(account.accountToken);
|
||||
if (users.isNotEmpty) {
|
||||
await storage.savePlexHomeUsersCache(account.id, encodePlexHomeUsersCache(users));
|
||||
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
|
||||
}
|
||||
return users;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
|
||||
if (storage.prefs.getString('home_users_cache') == null) return;
|
||||
var target = account;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Keeps a subtree from taking focus while the [ModalRoute] it lives in is
|
||||
/// covered by another route, and hands focus back when it is uncovered.
|
||||
///
|
||||
/// Flutter only marks a covered route's scope `skipTraversal`; any descendant
|
||||
/// may still call `requestFocus()` and win. That is harmless on a single
|
||||
/// navigator, where every screen's `ModalRoute.of(context).isCurrent` guard
|
||||
/// tells the truth. It breaks with a nested navigator: a route pushed on the
|
||||
/// *root* navigator (the profile picker, the PIN dialog) covers the whole
|
||||
/// nested stack, yet each nested route still reports `isCurrent == true` and
|
||||
/// its focus self-heals — sidebar reveal, library grid load, TV browse rail —
|
||||
/// yank the remote off the visible route, which on tvOS reads as a dead
|
||||
/// remote (#2034, #2239).
|
||||
///
|
||||
/// The boundary restores the invariant once, at the navigator boundary,
|
||||
/// instead of at every reclaim site: while the enclosing route is not current
|
||||
/// the subtree is [ExcludeFocus]ed, so every `requestFocus()` below it is a
|
||||
/// no-op. On uncover it re-requests the subtree's own [FocusScope], whose
|
||||
/// focus history still leads back to the leaf that had focus before the
|
||||
/// cover; Flutter's own restoration cannot, because the covering route's pop
|
||||
/// culls the excluded scope from the route scope's history before the
|
||||
/// exclusion lifts.
|
||||
class CoveredRouteFocusBoundary extends StatefulWidget {
|
||||
const CoveredRouteFocusBoundary({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<CoveredRouteFocusBoundary> createState() => _CoveredRouteFocusBoundaryState();
|
||||
}
|
||||
|
||||
class _CoveredRouteFocusBoundaryState extends State<CoveredRouteFocusBoundary> {
|
||||
final _scope = FocusScopeNode(debugLabel: 'CoveredRouteFocusBoundary');
|
||||
bool _covered = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ModalRoute.of subscribes to the route's status, so this rebuilds on
|
||||
// every isCurrent flip. Outside any route the subtree is never covered.
|
||||
final covered = !(ModalRoute.of(context)?.isCurrent ?? true);
|
||||
if (_covered && !covered) {
|
||||
// The exclusion lifts in this build's didUpdateWidget, after the pop
|
||||
// already parked focus on the route scope; restore once it has.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_scope.hasFocus) _scope.requestFocus();
|
||||
});
|
||||
}
|
||||
_covered = covered;
|
||||
return ExcludeFocus(
|
||||
excluding: covered,
|
||||
child: FocusScope(node: _scope, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
class FocusTheme {
|
||||
FocusTheme._();
|
||||
@@ -24,6 +25,19 @@ class FocusTheme {
|
||||
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
|
||||
}
|
||||
|
||||
/// How long a TV row (or the hub list) glides after one D-pad focus step.
|
||||
///
|
||||
/// Apple TV keeps the ~500ms ease-out measured from the native focus
|
||||
/// engine's scrollable containers (issue #2006): Siri Remote swipes chain
|
||||
/// steps into one continuous glide and users expect that inertia. D-pad
|
||||
/// platforms have no such reference: Leanback's `GridLayoutManager` prices a
|
||||
/// one-card step at roughly 100-150ms, so a 500ms glide there trails the
|
||||
/// focus border on every press and reads as input lag next to the launcher.
|
||||
/// Successive presses (including hold-repeats) retarget the animation from
|
||||
/// wherever the row currently is, so a fast series still glides continuously.
|
||||
static Duration navigationScrollDuration() =>
|
||||
PlatformDetector.isAppleTV() ? const Duration(milliseconds: 500) : const Duration(milliseconds: 150);
|
||||
|
||||
/// [radii] overrides [borderRadius] when per-corner radii are needed
|
||||
/// (M3E grouped cards: large outer / small inner corners).
|
||||
static BoxDecoration focusDecoration(
|
||||
|
||||
@@ -86,6 +86,11 @@ class TvTextInputController {
|
||||
/// Focus the field without opening either native or Flutter text input for
|
||||
/// this focus entry.
|
||||
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
|
||||
|
||||
/// Focus the field and open its text input, as an explicit Select would —
|
||||
/// for a field the app creates on the user's behalf (a new editor row) that
|
||||
/// should be typed into at once, without a second press.
|
||||
void focusAndOpenTextInput() => _host?._focusAndOpenTextInput();
|
||||
}
|
||||
|
||||
String _describeTextInputKey(KeyEvent event) {
|
||||
@@ -1167,6 +1172,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Focus the field and open text input as an explicit Select would. Clears
|
||||
/// per-focus suppression first so a field configured with
|
||||
/// [TvTextInputAutoOpenBehavior.never] still opens.
|
||||
void _focusAndOpenTextInput() {
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||
if (focusNode.hasFocus) {
|
||||
_openTextInputForFocusedField();
|
||||
return;
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
// Focus lands in FocusManager's microtask. Activating before that would be
|
||||
// undone by the focus sync this frame's build already scheduled, which
|
||||
// deactivates an unfocused field.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted && focusNode.hasFocus) _openTextInputForFocusedField();
|
||||
});
|
||||
}
|
||||
|
||||
void _openTextInputForFocusedField() {
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_activateNativeTextInput();
|
||||
} else if (widget.input._hasTvKeyboard && !_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
|
||||
// The overlay is a navigator route; push it once the focus request has
|
||||
// landed so the route's focus scope does not race the field's.
|
||||
_tvKeyboardOpenScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_openTvKeyboard();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setNativeTextInputFocused(bool focused) {
|
||||
if (_reportedNativeTextInputFocused == focused) {
|
||||
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Ön ayar silindi",
|
||||
"confirmDeletePreset": "Bu ön ayarı silmək istədiyinizə əminsiniz?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# şərh",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context və gpu-api Linux-da nəzərə alınmır: daxili video həmişə video müstəvisində vo=libmpv vasitəsilə göstərilir və gpu-next (ArtCNN kimi hesablama şeyderlərinə lazımdır) daxili işləyə bilməz."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} fasilə etdi",
|
||||
"participantResumed": "${name} davam etdirdi",
|
||||
"participantSeeked": "${name} oynatma mövqeyini dəyişdi",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buferləyir",
|
||||
"participantNeedsUpdate": "${name} köhnə tətbiq versiyasındadır",
|
||||
"resumingWithout": "${name} olmadan davam edilir",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Keyfiyyət profili",
|
||||
"rootFolder": "Kök qovluq",
|
||||
"languageProfile": "Dil profili",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Sorğu göndərildi",
|
||||
"requestFailed": "Sorğu uğursuz oldu: ${error}",
|
||||
"requestsLoadFailed": "Seçimlər yüklənə bilmədi",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Bloklanmış",
|
||||
"couldNotReach": "${url} ünvanına çatmaq olmadı: ${error}",
|
||||
"noInstanceAtUrl": "${url} ünvanında Seerr instansiyası yoxdur (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "https://seerr.example.com kimi server ünvanı daxil edin",
|
||||
"quickConnectUnsupported": "Bu Seerr nüsxəsi Sürətli Qoşulmanı dəstəkləmir. Seerr 3.4 və ya daha yeni versiya tələb olunur.",
|
||||
"notInitialized": "Bu Seerr instansiyasının ilkin quraşdırılması tamamlanmayıb",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Пресетът е изтрит",
|
||||
"confirmDeletePreset": "Сигурни ли сте, че искате да изтриете този пресет?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context и gpu-api се игнорират на Linux: вграденото видео винаги се рендерира през vo=libmpv върху видео равнината, а gpu-next (който е нужен за compute шейдъри като ArtCNN) не може да работи вградено."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} постави на пауза",
|
||||
"participantResumed": "${name} продължи",
|
||||
"participantSeeked": "${name} промени позицията на възпроизвеждане",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} буферира",
|
||||
"participantNeedsUpdate": "${name} е с по-стара версия на приложението — синхронизирането не е налично",
|
||||
"resumingWithout": "Продължаване без ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Профил за качество",
|
||||
"rootFolder": "Основна папка",
|
||||
"languageProfile": "Езиков профил",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Заявката е изпратена",
|
||||
"requestFailed": "Заявката се провали: ${error}",
|
||||
"requestsLoadFailed": "Неуспешно зареждане на опциите за заявка",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "В списъка с блокирани",
|
||||
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
|
||||
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
|
||||
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Forudindstilling slettet",
|
||||
"confirmDeletePreset": "Er du sikker på, at du vil slette denne forudindstilling?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context og gpu-api ignoreres på Linux: indlejret video renderes altid via vo=libmpv på videoplanen, og gpu-next (som compute-shaders som ArtCNN kræver) kan ikke køre indlejret."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} satte på pause",
|
||||
"participantResumed": "${name} genoptog",
|
||||
"participantSeeked": "${name} ændrede afspilningspositionen",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} bufferer",
|
||||
"participantNeedsUpdate": "${name} bruger en ældre appversion — synkronisering er ikke tilgængelig",
|
||||
"resumingWithout": "Fortsætter uden ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Kvalitetsprofil",
|
||||
"rootFolder": "Rodmappe",
|
||||
"languageProfile": "Sprogprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Anmodning sendt",
|
||||
"requestFailed": "Anmodning mislykkedes: ${error}",
|
||||
"requestsLoadFailed": "Kunne ikke indlæse anmodningsmuligheder",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "På blokeringslisten",
|
||||
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Indtast en serveradresse som https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Denne Seerr-instans understøtter ikke Quick Connect. Den kræver Seerr 3.4 eller nyere.",
|
||||
"notInitialized": "Denne Seerr-instans har ikke fuldført førstegangsopsætningen",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Voreinstellung gelöscht",
|
||||
"confirmDeletePreset": "Diese Voreinstellung wirklich löschen?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context und gpu-api werden unter Linux ignoriert: eingebettetes Video wird immer über vo=libmpv auf der Videoebene gerendert, und gpu-next (das Compute-Shader wie ArtCNN benötigen) kann nicht eingebettet ausgeführt werden."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} hat pausiert",
|
||||
"participantResumed": "${name} hat fortgesetzt",
|
||||
"participantSeeked": "${name} hat die Wiedergabeposition geändert",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} puffert",
|
||||
"participantNeedsUpdate": "${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar",
|
||||
"resumingWithout": "Fortfahren ohne ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Qualitätsprofil",
|
||||
"rootFolder": "Stammordner",
|
||||
"languageProfile": "Sprachprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Anfrage gesendet",
|
||||
"requestFailed": "Anfrage fehlgeschlagen: ${error}",
|
||||
"requestsLoadFailed": "Anfrageoptionen konnten nicht geladen werden",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Auf der Sperrliste",
|
||||
"couldNotReach": "${url} nicht erreichbar: ${error}",
|
||||
"noInstanceAtUrl": "Keine Seerr-Instanz unter ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Gib eine Serveradresse ein, z. B. https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Diese Seerr-Instanz unterstützt Quick Connect nicht. Dafür ist Seerr 3.4 oder neuer erforderlich.",
|
||||
"notInitialized": "Die Ersteinrichtung dieser Seerr-Instanz wurde noch nicht abgeschlossen",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Preset deleted",
|
||||
"confirmDeletePreset": "Are you sure you want to delete this preset?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "option=value",
|
||||
"addLine": "Add line",
|
||||
"removeLine": "Remove line",
|
||||
"embeddedVoHint": "vo, gpu-context and gpu-api are ignored on Linux: embedded video always renders through vo=libmpv on the video plane, and gpu-next (which compute shaders like ArtCNN need) cannot run embedded."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} paused",
|
||||
"participantResumed": "${name} resumed",
|
||||
"participantSeeked": "${name} changed the playback position",
|
||||
"participantChangedSpeed": "${name} set the speed to ${speed}",
|
||||
"participantBuffering": "${name} is buffering",
|
||||
"participantNeedsUpdate": "${name} is on an older app version — sync unavailable",
|
||||
"resumingWithout": "Resuming without ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Quality profile",
|
||||
"rootFolder": "Root folder",
|
||||
"languageProfile": "Language profile",
|
||||
"tags": "Tags",
|
||||
"noTags": "No tags",
|
||||
"defaultOption": "${name} (Default)",
|
||||
"animeNote": "This series is an anime.",
|
||||
"requestSubmitted": "Request submitted",
|
||||
"requestFailed": "Request failed: ${error}",
|
||||
"requestsLoadFailed": "Couldn't load request options",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Blocklisted",
|
||||
"couldNotReach": "Could not reach ${url}: ${error}",
|
||||
"noInstanceAtUrl": "No Seerr instance at ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "An authenticating reverse proxy (SSO or HTTP auth) answered instead of Seerr. Plezy cannot sign in through it: let Seerr's /api/v1 path bypass the proxy for this app, or use an address that reaches Seerr directly.",
|
||||
"invalidUrl": "Enter a server address like https://seerr.example.com",
|
||||
"quickConnectUnsupported": "This Seerr instance does not support Quick Connect. It needs Seerr 3.4 or newer.",
|
||||
"notInitialized": "This Seerr instance has not completed first-run setup",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Preajuste eliminado",
|
||||
"confirmDeletePreset": "¿Estás seguro de que quieres eliminar este preajuste?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context y gpu-api se ignoran en Linux: el vídeo integrado siempre se renderiza mediante vo=libmpv en el plano de vídeo, y gpu-next (que los shaders de cómputo como ArtCNN necesitan) no puede ejecutarse integrado."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} pausó",
|
||||
"participantResumed": "${name} reanudó",
|
||||
"participantSeeked": "${name} cambió la posición de reproducción",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} está almacenando en búfer",
|
||||
"participantNeedsUpdate": "${name} usa una versión anterior de la aplicación — sincronización no disponible",
|
||||
"resumingWithout": "Reanudando sin ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Perfil de calidad",
|
||||
"rootFolder": "Carpeta raíz",
|
||||
"languageProfile": "Perfil de idioma",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Solicitud enviada",
|
||||
"requestFailed": "La solicitud falló: ${error}",
|
||||
"requestsLoadFailed": "No se pudieron cargar las opciones de solicitud",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "En la lista de bloqueo",
|
||||
"couldNotReach": "No se pudo conectar con ${url}: ${error}",
|
||||
"noInstanceAtUrl": "No hay ninguna instancia de Seerr en ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Introduce una dirección de servidor como https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Esta instancia de Seerr no admite Quick Connect. Necesita Seerr 3.4 o más reciente.",
|
||||
"notInitialized": "Esta instancia de Seerr no ha completado la configuración inicial",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Préréglage supprimé",
|
||||
"confirmDeletePreset": "Êtes-vous sûr de vouloir supprimer ce préréglage ?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context et gpu-api sont ignorés sous Linux : la vidéo intégrée est toujours rendue via vo=libmpv sur le plan vidéo, et gpu-next (dont les shaders de calcul comme ArtCNN ont besoin) ne peut pas fonctionner en mode intégré."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} a mis en pause",
|
||||
"participantResumed": "${name} a repris",
|
||||
"participantSeeked": "${name} a changé la position de lecture",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "La lecture de ${name} est en cours de mise en mémoire tampon",
|
||||
"participantNeedsUpdate": "${name} utilise une ancienne version de l’app — synchronisation indisponible",
|
||||
"resumingWithout": "Reprise sans ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Profil de qualité",
|
||||
"rootFolder": "Dossier racine",
|
||||
"languageProfile": "Profil de langue",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Demande envoyée",
|
||||
"requestFailed": "Échec de la demande : ${error}",
|
||||
"requestsLoadFailed": "Impossible de charger les options de demande",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Sur la liste de blocage",
|
||||
"couldNotReach": "Impossible de joindre ${url} : ${error}",
|
||||
"noInstanceAtUrl": "Aucune instance Seerr à l’adresse ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Saisissez une adresse de serveur comme https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Cette instance Seerr ne prend pas en charge Quick Connect. Elle nécessite Seerr 3.4 ou version ultérieure.",
|
||||
"notInitialized": "La configuration initiale de cette instance Seerr n’est pas terminée",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Előbeállítás törölve",
|
||||
"confirmDeletePreset": "Biztosan törölni szeretnéd ezt az előbeállítást?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# megjegyzés",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "A vo, gpu-context és gpu-api beállítások Linuxon figyelmen kívül maradnak: a beágyazott videó mindig a vo=libmpv-n keresztül jelenik meg a videósíkon, a gpu-next (amelyre az ArtCNN-hez hasonló compute shadereknek szükségük van) pedig nem futhat beágyazva."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} szüneteltette a lejátszást",
|
||||
"participantResumed": "${name} folytatta a lejátszást",
|
||||
"participantSeeked": "${name} módosította a lejátszási pozíciót",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} pufferel",
|
||||
"participantNeedsUpdate": "${name} régebbi alkalmazásverziót használ — a szinkronizálás nem érhető el",
|
||||
"resumingWithout": "Folytatás a következő nélkül: ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Minőségi profil",
|
||||
"rootFolder": "Gyökérmappa",
|
||||
"languageProfile": "Nyelvi profil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Igénylés elküldve",
|
||||
"requestFailed": "Az igénylés nem sikerült: ${error}",
|
||||
"requestsLoadFailed": "Nem sikerült betölteni az igénylési opciókat",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Tiltólistán",
|
||||
"couldNotReach": "Nem sikerült elérni ezt: ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nem található Seerr-példány ezen a címen: ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Adj meg egy szervercímet, például: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Ez a Seerr-példány nem támogatja a Quick Connectet. Seerr 3.4 vagy újabb verzió szükséges.",
|
||||
"notInitialized": "Ennek a Seerr-példánynak a kezdeti beállítása még nem fejeződött be",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Preset eliminato",
|
||||
"confirmDeletePreset": "Sei sicuro di voler eliminare questo preset?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context e gpu-api vengono ignorati su Linux: il video incorporato viene sempre renderizzato tramite vo=libmpv sul piano video e gpu-next (che gli shader di calcolo come ArtCNN richiedono) non può essere eseguito in modalità incorporata."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} ha messo in pausa",
|
||||
"participantResumed": "${name} ha ripreso",
|
||||
"participantSeeked": "${name} ha cambiato la posizione di riproduzione",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} è in buffering",
|
||||
"participantNeedsUpdate": "${name} usa una versione precedente dell'app — sincronizzazione non disponibile",
|
||||
"resumingWithout": "Ripresa senza ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Profilo di qualità",
|
||||
"rootFolder": "Cartella radice",
|
||||
"languageProfile": "Profilo della lingua",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Richiesta inviata",
|
||||
"requestFailed": "Richiesta non riuscita: ${error}",
|
||||
"requestsLoadFailed": "Impossibile caricare le opzioni di richiesta",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Nella lista di blocco",
|
||||
"couldNotReach": "Impossibile raggiungere ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nessuna istanza Seerr all'indirizzo ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Inserisci un indirizzo del server come https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Questa istanza Seerr non supporta Quick Connect. È necessaria la versione 3.4 o successiva di Seerr.",
|
||||
"notInitialized": "Questa istanza Seerr non ha completato la configurazione iniziale",
|
||||
|
||||
@@ -851,6 +851,9 @@
|
||||
"presetDeleted": "プリセットを削除しました",
|
||||
"confirmDeletePreset": "このプリセットを削除してもよろしいですか?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "Linux では vo、gpu-context、gpu-api は無視されます。埋め込み動画は常にビデオプレーン上で vo=libmpv を通してレンダリングされ、gpu-next(ArtCNN のようなコンピュートシェーダーに必要)は埋め込みでは実行できません。"
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1618,6 +1621,7 @@
|
||||
"participantPaused": "${name}が一時停止しました",
|
||||
"participantResumed": "${name}が再開しました",
|
||||
"participantSeeked": "${name}が再生位置を変更しました",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name}がバッファリング中",
|
||||
"participantNeedsUpdate": "${name}は古いバージョンのアプリを使用しているため、同期できません",
|
||||
"resumingWithout": "${name}抜きで再開",
|
||||
@@ -2064,6 +2068,10 @@
|
||||
"qualityProfile": "画質プロファイル",
|
||||
"rootFolder": "ルートフォルダ",
|
||||
"languageProfile": "言語プロファイル",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "リクエストを送信しました",
|
||||
"requestFailed": "リクエストに失敗しました: ${error}",
|
||||
"requestsLoadFailed": "リクエストオプションを読み込めませんでした",
|
||||
@@ -2075,6 +2083,7 @@
|
||||
"statusBlocklisted": "ブロックリスト登録済み",
|
||||
"couldNotReach": "${url}に接続できませんでした: ${error}",
|
||||
"noInstanceAtUrl": "${url}にSeerrインスタンスがありません(HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "https://seerr.example.comのようなサーバーアドレスを入力してください",
|
||||
"quickConnectUnsupported": "このSeerrインスタンスはQuick Connectに対応していません。Seerr 3.4以降が必要です。",
|
||||
"notInitialized": "このSeerrインスタンスでは初回セットアップが完了していません",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Баптау өшірілді",
|
||||
"confirmDeletePreset": "Осы баптауды өшіргіңіз келетініне сенімдісіз бе?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# түсініктеме",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context және gpu-api Linux-те еленбейді: ендірілген бейне әрқашан бейне жазықтығында vo=libmpv арқылы көрсетіледі, ал gpu-next (ArtCNN сияқты compute шейдерлеріне қажет) ендірілген режимде жұмыс істей алмайды."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} кідіртті",
|
||||
"participantResumed": "${name} жалғастырды",
|
||||
"participantSeeked": "${name} уақытты өзгертті",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} буферлеуде",
|
||||
"participantNeedsUpdate": "${name} ескі нұсқада",
|
||||
"resumingWithout": "${name} ескерусіз жалғастырылуда",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Сапа профилі",
|
||||
"rootFolder": "Түпкі қапшық",
|
||||
"languageProfile": "Тіл профилі",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Сұрау жіберілді",
|
||||
"requestFailed": "Сұрау қатесі: ${error}",
|
||||
"requestsLoadFailed": "Параметрлерді жүктеу мүмкін болмады",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Бұғаттау тізімінде",
|
||||
"couldNotReach": "${url} мекенжайына қосылу мүмкін болмады: ${error}",
|
||||
"noInstanceAtUrl": "${url} мекенжайында Seerr данасы жоқ (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Сервер мекенжайын енгізіңіз, мысалы: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Бұл Seerr данасы Жылдам қосылуды қолдамайды. Оған Seerr 3.4 немесе одан жаңарақ нұсқа қажет.",
|
||||
"notInitialized": "Бұл Seerr данасының бастапқы баптауы аяқталмаған",
|
||||
|
||||
@@ -851,6 +851,9 @@
|
||||
"presetDeleted": "프리셋이 삭제되었습니다",
|
||||
"confirmDeletePreset": "이 프리셋을 삭제하시겠습니까?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "Linux에서는 vo, gpu-context, gpu-api가 무시됩니다. 내장 동영상은 항상 비디오 평면에서 vo=libmpv로 렌더링되며, gpu-next(ArtCNN 같은 컴퓨트 셰이더에 필요)는 내장 방식으로 실행할 수 없습니다."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1618,6 +1621,7 @@
|
||||
"participantPaused": "${name}님이 일시정지했습니다",
|
||||
"participantResumed": "${name}님이 재생했습니다",
|
||||
"participantSeeked": "${name}님이 재생 위치를 변경했습니다",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name}님이 버퍼링 중입니다",
|
||||
"participantNeedsUpdate": "${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다",
|
||||
"resumingWithout": "${name}님 없이 재생을 재개합니다",
|
||||
@@ -2064,6 +2068,10 @@
|
||||
"qualityProfile": "화질 프로파일",
|
||||
"rootFolder": "루트 폴더",
|
||||
"languageProfile": "언어 프로파일",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "요청을 제출했습니다",
|
||||
"requestFailed": "요청 실패: ${error}",
|
||||
"requestsLoadFailed": "요청 옵션을 불러올 수 없습니다",
|
||||
@@ -2075,6 +2083,7 @@
|
||||
"statusBlocklisted": "차단 목록에 있음",
|
||||
"couldNotReach": "${url}에 연결할 수 없습니다: ${error}",
|
||||
"noInstanceAtUrl": "${url}에 Seerr 인스턴스가 없습니다(HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "https://seerr.example.com과 같은 서버 주소를 입력하세요",
|
||||
"quickConnectUnsupported": "이 Seerr 인스턴스는 Quick Connect를 지원하지 않습니다. Seerr 3.4 이상이 필요합니다.",
|
||||
"notInitialized": "이 Seerr 인스턴스는 최초 실행 설정을 완료하지 않았습니다",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Forhåndsinnstilling slettet",
|
||||
"confirmDeletePreset": "Er du sikker på at du vil slette denne forhåndsinnstillingen?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# kommentar",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context og gpu-api ignoreres på Linux: innebygd video renderes alltid via vo=libmpv på videoplanet, og gpu-next (som compute-shadere som ArtCNN trenger) kan ikke kjøre innebygd."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} satte avspillingen på pause",
|
||||
"participantResumed": "${name} startet avspillingen igjen",
|
||||
"participantSeeked": "${name} endret avspillingsposisjonen",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buffrer",
|
||||
"participantNeedsUpdate": "${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig",
|
||||
"resumingWithout": "Fortsetter uten ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Kvalitetsprofil",
|
||||
"rootFolder": "Rotmappe",
|
||||
"languageProfile": "Språkprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Forespørsel sendt",
|
||||
"requestFailed": "Forespørsel mislyktes: ${error}",
|
||||
"requestsLoadFailed": "Kunne ikke laste forespørselsalternativer",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "På blokkeringslisten",
|
||||
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Skriv inn en serveradresse som https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Denne Seerr-instansen støtter ikke Quick Connect. Den krever Seerr 3.4 eller nyere.",
|
||||
"notInitialized": "Denne Seerr-instansen har ikke fullført førstegangsoppsettet",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Voorinstelling verwijderd",
|
||||
"confirmDeletePreset": "Weet je zeker dat je deze voorinstelling wilt verwijderen?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context en gpu-api worden genegeerd op Linux: ingebedde video wordt altijd weergegeven via vo=libmpv op het videovlak, en gpu-next (nodig voor compute-shaders zoals ArtCNN) kan niet ingebed draaien."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} heeft gepauzeerd",
|
||||
"participantResumed": "${name} heeft hervat",
|
||||
"participantSeeked": "${name} heeft de afspeelpositie gewijzigd",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} is aan het bufferen",
|
||||
"participantNeedsUpdate": "${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar",
|
||||
"resumingWithout": "Hervatten zonder ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Kwaliteitsprofiel",
|
||||
"rootFolder": "Hoofdmap",
|
||||
"languageProfile": "Taalprofiel",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Aanvraag verzonden",
|
||||
"requestFailed": "Aanvraag mislukt: ${error}",
|
||||
"requestsLoadFailed": "Aanvraagopties konden niet worden geladen",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Op de blokkeerlijst",
|
||||
"couldNotReach": "Kon ${url} niet bereiken: ${error}",
|
||||
"noInstanceAtUrl": "Geen Seerr-instantie op ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Voer een serveradres in, zoals https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Deze Seerr-instantie ondersteunt Quick Connect niet. Hiervoor is Seerr 3.4 of nieuwer nodig.",
|
||||
"notInitialized": "De eerste configuratie van deze Seerr-instantie is niet voltooid",
|
||||
|
||||
@@ -863,6 +863,9 @@
|
||||
"presetDeleted": "Usunięto ustawienie wstępne",
|
||||
"confirmDeletePreset": "Czy na pewno chcesz usunąć to ustawienie wstępne?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context i gpu-api są ignorowane w systemie Linux: wbudowane wideo jest zawsze renderowane przez vo=libmpv na płaszczyźnie wideo, a gpu-next (wymagany przez shadery obliczeniowe takie jak ArtCNN) nie może działać w trybie wbudowanym."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1648,6 +1651,7 @@
|
||||
"participantPaused": "${name} wstrzymał",
|
||||
"participantResumed": "${name} wznowił",
|
||||
"participantSeeked": "${name} zmienił pozycję odtwarzania",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buforuje",
|
||||
"participantNeedsUpdate": "${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna",
|
||||
"resumingWithout": "Wznawianie bez ${name}",
|
||||
@@ -2094,6 +2098,10 @@
|
||||
"qualityProfile": "Profil jakości",
|
||||
"rootFolder": "Folder główny",
|
||||
"languageProfile": "Profil językowy",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Zamówienie wysłane",
|
||||
"requestFailed": "Zamówienie nie powiodło się: ${error}",
|
||||
"requestsLoadFailed": "Nie udało się wczytać opcji zamówienia",
|
||||
@@ -2105,6 +2113,7 @@
|
||||
"statusBlocklisted": "Na liście blokowanych",
|
||||
"couldNotReach": "Nie udało się połączyć z ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Pod adresem ${url} nie ma instancji Seerr (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Wprowadź adres serwera, np. https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Ta instancja Seerr nie obsługuje Quick Connect. Wymagany jest Seerr 3.4 lub nowszy.",
|
||||
"notInitialized": "Ta instancja Seerr nie ukończyła konfiguracji początkowej",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Predefinição excluída",
|
||||
"confirmDeletePreset": "Tem certeza de que deseja excluir esta predefinição?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context e gpu-api são ignorados no Linux: o vídeo incorporado é sempre renderizado via vo=libmpv no plano de vídeo, e gpu-next (necessário para shaders de computação como ArtCNN) não pode ser executado incorporado."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} pausou",
|
||||
"participantResumed": "${name} retomou",
|
||||
"participantSeeked": "${name} mudou a posição da reprodução",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} está aguardando o carregamento",
|
||||
"participantNeedsUpdate": "${name} está usando uma versão mais antiga do app — sincronização indisponível",
|
||||
"resumingWithout": "Retomando sem ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Perfil de qualidade",
|
||||
"rootFolder": "Pasta raiz",
|
||||
"languageProfile": "Perfil de idioma",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Solicitação enviada",
|
||||
"requestFailed": "Falha na solicitação: ${error}",
|
||||
"requestsLoadFailed": "Não foi possível carregar as opções de solicitação",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Na lista de bloqueio",
|
||||
"couldNotReach": "Não foi possível acessar ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Nenhuma instância do Seerr em ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Insira um endereço de servidor como https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Esta instância do Seerr não oferece suporte a Quick Connect. Ela precisa do Seerr 3.4 ou mais recente.",
|
||||
"notInitialized": "Esta instância do Seerr não concluiu a configuração inicial",
|
||||
|
||||
@@ -863,6 +863,9 @@
|
||||
"presetDeleted": "Пресет удалён",
|
||||
"confirmDeletePreset": "Вы уверены, что хотите удалить этот пресет?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context и gpu-api игнорируются в Linux: встроенное видео всегда отображается через vo=libmpv на видеоплоскости, а gpu-next (нужный для compute-шейдеров вроде ArtCNN) не может работать во встроенном режиме."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1648,6 +1651,7 @@
|
||||
"participantPaused": "${name} поставил на паузу",
|
||||
"participantResumed": "${name} возобновил",
|
||||
"participantSeeked": "${name} перемотал",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "У ${name} идёт буферизация",
|
||||
"participantNeedsUpdate": "${name} использует старую версию приложения — синхронизация недоступна",
|
||||
"resumingWithout": "Возобновление без ${name}",
|
||||
@@ -2094,6 +2098,10 @@
|
||||
"qualityProfile": "Профиль качества",
|
||||
"rootFolder": "Корневая папка",
|
||||
"languageProfile": "Языковой профиль",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Запрос отправлен",
|
||||
"requestFailed": "Ошибка запроса: ${error}",
|
||||
"requestsLoadFailed": "Не удалось загрузить параметры запроса",
|
||||
@@ -2105,6 +2113,7 @@
|
||||
"statusBlocklisted": "В списке блокировки",
|
||||
"couldNotReach": "Не удалось связаться с ${url}: ${error}",
|
||||
"noInstanceAtUrl": "По адресу ${url} нет экземпляра Seerr (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Введите адрес сервера, например https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Этот экземпляр Seerr не поддерживает Quick Connect. Нужна версия Seerr 3.4 или новее.",
|
||||
"notInitialized": "Первоначальная настройка этого экземпляра Seerr не завершена",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 43747 (1988 per locale)
|
||||
/// Strings: 43756 (1988 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -2506,6 +2506,15 @@ class Translations$mpvConfig$en {
|
||||
/// en: 'gpu-api=vulkan hwdec=auto # comment'
|
||||
String get configPlaceholder => 'gpu-api=vulkan\nhwdec=auto\n# comment';
|
||||
|
||||
/// en: 'option=value'
|
||||
String get lineHint => 'option=value';
|
||||
|
||||
/// en: 'Add line'
|
||||
String get addLine => 'Add line';
|
||||
|
||||
/// en: 'Remove line'
|
||||
String get removeLine => 'Remove line';
|
||||
|
||||
/// en: '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.'
|
||||
String get 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.';
|
||||
}
|
||||
@@ -4145,6 +4154,9 @@ class Translations$watchTogether$en {
|
||||
/// en: '${name} changed the playback position'
|
||||
String participantSeeked({required Object name}) => '${name} changed the playback position';
|
||||
|
||||
/// en: '${name} set the speed to ${speed}'
|
||||
String participantChangedSpeed({required Object name, required Object speed}) => '${name} set the speed to ${speed}';
|
||||
|
||||
/// en: '${name} is buffering'
|
||||
String participantBuffering({required Object name}) => '${name} is buffering';
|
||||
|
||||
@@ -5213,6 +5225,18 @@ class Translations$seerr$en {
|
||||
/// en: 'Language profile'
|
||||
String get languageProfile => 'Language profile';
|
||||
|
||||
/// en: 'Tags'
|
||||
String get tags => 'Tags';
|
||||
|
||||
/// en: 'No tags'
|
||||
String get noTags => 'No tags';
|
||||
|
||||
/// en: '${name} (Default)'
|
||||
String defaultOption({required Object name}) => '${name} (Default)';
|
||||
|
||||
/// en: 'This series is an anime.'
|
||||
String get animeNote => 'This series is an anime.';
|
||||
|
||||
/// en: 'Request submitted'
|
||||
String get requestSubmitted => 'Request submitted';
|
||||
|
||||
@@ -5246,6 +5270,9 @@ class Translations$seerr$en {
|
||||
/// en: 'No Seerr instance at ${url} (HTTP ${status})'
|
||||
String noInstanceAtUrl({required Object url, required Object status}) => 'No Seerr instance at ${url} (HTTP ${status})';
|
||||
|
||||
/// en: '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.'
|
||||
String get 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.';
|
||||
|
||||
/// en: 'Enter a server address like https://seerr.example.com'
|
||||
String get invalidUrl => 'Enter a server address like https://seerr.example.com';
|
||||
|
||||
@@ -7749,6 +7776,9 @@ extension on Translations {
|
||||
'mpvConfig.presetDeleted' => 'Preset deleted',
|
||||
'mpvConfig.confirmDeletePreset' => 'Are you sure you want to delete this preset?',
|
||||
'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment',
|
||||
'mpvConfig.lineHint' => 'option=value',
|
||||
'mpvConfig.addLine' => 'Add line',
|
||||
'mpvConfig.removeLine' => 'Remove line',
|
||||
'mpvConfig.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.confirmAction' => 'Confirm Action',
|
||||
'profiles.addPlezyProfile' => 'Add Plezy profile',
|
||||
@@ -7970,11 +8000,11 @@ extension on Translations {
|
||||
'libraries.groupings.episodes' => 'Episodes',
|
||||
'libraries.groupings.artists' => 'Artists',
|
||||
'libraries.groupings.albums' => 'Albums',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.groupings.tracks' => 'Tracks',
|
||||
'libraries.groupings.folders' => 'Folders',
|
||||
'libraries.filterCategories.genre' => 'Genre',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.year' => 'Year',
|
||||
'libraries.filterCategories.contentRating' => 'Content Rating',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
@@ -8422,6 +8452,7 @@ extension on Translations {
|
||||
'watchTogether.participantPaused' => ({required Object name}) => '${name} paused',
|
||||
'watchTogether.participantResumed' => ({required Object name}) => '${name} resumed',
|
||||
'watchTogether.participantSeeked' => ({required Object name}) => '${name} changed the playback position',
|
||||
'watchTogether.participantChangedSpeed' => ({required Object name, required Object speed}) => '${name} set the speed to ${speed}',
|
||||
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is buffering',
|
||||
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable',
|
||||
'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}',
|
||||
@@ -8483,12 +8514,12 @@ extension on Translations {
|
||||
'downloads.invalidEpisodeCount' => 'Enter a valid episode count.',
|
||||
'downloads.keepSynced' => 'Keep synced',
|
||||
'downloads.downloadOnce' => 'Download once',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Keep ${count} unwatched',
|
||||
'downloads.editSyncRule' => 'Edit sync rule',
|
||||
'downloads.removeSyncRule' => 'Remove sync rule',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.removeListSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"?',
|
||||
'downloads.deleteSyncRuleDownloads' => 'Also delete associated downloads',
|
||||
'downloads.deleteSyncRuleDownloadsDescription' => 'Downloads used by another sync rule or profile will be kept.',
|
||||
@@ -8836,6 +8867,10 @@ extension on Translations {
|
||||
'seerr.qualityProfile' => 'Quality profile',
|
||||
'seerr.rootFolder' => 'Root folder',
|
||||
'seerr.languageProfile' => 'Language profile',
|
||||
'seerr.tags' => 'Tags',
|
||||
'seerr.noTags' => 'No tags',
|
||||
'seerr.defaultOption' => ({required Object name}) => '${name} (Default)',
|
||||
'seerr.animeNote' => 'This series is an anime.',
|
||||
'seerr.requestSubmitted' => 'Request submitted',
|
||||
'seerr.requestFailed' => ({required Object error}) => 'Request failed: ${error}',
|
||||
'seerr.requestsLoadFailed' => 'Couldn\'t load request options',
|
||||
@@ -8847,6 +8882,7 @@ extension on Translations {
|
||||
'seerr.statusBlocklisted' => 'Blocklisted',
|
||||
'seerr.couldNotReach' => ({required Object url, required Object error}) => 'Could not reach ${url}: ${error}',
|
||||
'seerr.noInstanceAtUrl' => ({required Object url, required Object status}) => 'No Seerr instance at ${url} (HTTP ${status})',
|
||||
'seerr.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.',
|
||||
'seerr.invalidUrl' => 'Enter a server address like https://seerr.example.com',
|
||||
'seerr.quickConnectUnsupported' => 'This Seerr instance does not support Quick Connect. It needs Seerr 3.4 or newer.',
|
||||
'seerr.notInitialized' => 'This Seerr instance has not completed first-run setup',
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Förval borttaget",
|
||||
"confirmDeletePreset": "Är du säker på att du vill ta bort detta förval?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context och gpu-api ignoreras på Linux: inbäddad video renderas alltid via vo=libmpv på videoplanet, och gpu-next (som compute-shaders som ArtCNN behöver) kan inte köras inbäddat."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} pausade",
|
||||
"participantResumed": "${name} återupptog",
|
||||
"participantSeeked": "${name} ändrade uppspelningspositionen",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buffrar",
|
||||
"participantNeedsUpdate": "${name} använder en äldre appversion — synkronisering är inte tillgänglig",
|
||||
"resumingWithout": "Återupptar utan ${name}",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Kvalitetsprofil",
|
||||
"rootFolder": "Rotmapp",
|
||||
"languageProfile": "Språkprofil",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Begäran skickad",
|
||||
"requestFailed": "Begäran kunde inte genomföras: ${error}",
|
||||
"requestsLoadFailed": "Det gick inte att läsa in alternativ för begäran",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "På blockeringslistan",
|
||||
"couldNotReach": "Kunde inte nå ${url}: ${error}",
|
||||
"noInstanceAtUrl": "Det finns ingen Seerr-instans på ${url} (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Ange en serveradress, t.ex. https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Den här Seerr-instansen stöder inte Quick Connect. Den kräver Seerr 3.4 eller nyare.",
|
||||
"notInitialized": "Den här Seerr-instansen har inte slutfört den inledande konfigurationen",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Önayar silindi",
|
||||
"confirmDeletePreset": "Bu önayarı silmek istediğinizden emin misiniz?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# yorum",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "vo, gpu-context ve gpu-api Linux'ta yok sayılır: gömülü video her zaman video düzleminde vo=libmpv üzerinden işlenir ve gpu-next (ArtCNN gibi compute shader'ların ihtiyaç duyduğu) gömülü olarak çalışamaz."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} duraklattı",
|
||||
"participantResumed": "${name} devam ettirdi",
|
||||
"participantSeeked": "${name} oynatma konumunu değiştirdi",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} arabelleğe alıyor",
|
||||
"participantNeedsUpdate": "${name} eski bir uygulama sürümünde — eşitleme kullanılamıyor",
|
||||
"resumingWithout": "${name} olmadan devam ediliyor",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Kalite profili",
|
||||
"rootFolder": "Kök klasör",
|
||||
"languageProfile": "Dil profili",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "İstek gönderildi",
|
||||
"requestFailed": "İstek başarısız oldu: ${error}",
|
||||
"requestsLoadFailed": "İstek seçenekleri yüklenemedi",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Engelleme listesinde",
|
||||
"couldNotReach": "${url} adresine ulaşılamadı: ${error}",
|
||||
"noInstanceAtUrl": "${url} adresinde Seerr örneği bulunamadı (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "https://seerr.example.com gibi bir sunucu adresi girin",
|
||||
"quickConnectUnsupported": "Bu Seerr örneği Quick Connect'i desteklemiyor. Seerr 3.4 veya daha yenisini gerektiriyor.",
|
||||
"notInitialized": "Bu Seerr örneği ilk çalıştırma kurulumunu tamamlamadı",
|
||||
|
||||
@@ -855,6 +855,9 @@
|
||||
"presetDeleted": "Sozlama oʻchirildi",
|
||||
"confirmDeletePreset": "Ushbu sozlamani oʻchirishga ishonchingiz komilmi?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# izoh",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "Linux-da vo, gpu-context va gpu-api e'tiborga olinmaydi: ichki video har doim video tekisligida vo=libmpv orqali ko'rsatiladi va gpu-next (ArtCNN kabi compute shaderlar uchun zarur) ichki rejimda ishlay olmaydi."
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1628,6 +1631,7 @@
|
||||
"participantPaused": "${name} toʻxtatdi",
|
||||
"participantResumed": "${name} davom ettirdi",
|
||||
"participantSeeked": "${name} vaqtni oʻzgartirdi",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} buferlamoqda",
|
||||
"participantNeedsUpdate": "${name} eski versiyada",
|
||||
"resumingWithout": "${name} hisobga olinmasdan davom ettirilmoqda",
|
||||
@@ -2074,6 +2078,10 @@
|
||||
"qualityProfile": "Sifat profili",
|
||||
"rootFolder": "Asosiy jild",
|
||||
"languageProfile": "Til profili",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Soʻrov yuborildi",
|
||||
"requestFailed": "Soʻrov xatoligi: ${error}",
|
||||
"requestsLoadFailed": "Parametrlarni yuklab boʻlmadi",
|
||||
@@ -2085,6 +2093,7 @@
|
||||
"statusBlocklisted": "Bloklangan roʻyxatda",
|
||||
"couldNotReach": "${url} manziliga ulanib boʻlmadi: ${error}",
|
||||
"noInstanceAtUrl": "${url} manzilida Seerr nusxasi yoʻq (HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "Server manzilini kiriting, masalan: https://seerr.example.com",
|
||||
"quickConnectUnsupported": "Bu Seerr nusxasi Tezkor ulanishni qoʻllab-quvvatlamaydi. Buning uchun Seerr 3.4 yoki undan yangi versiya kerak.",
|
||||
"notInitialized": "Bu Seerr nusxasining dastlabki sozlamasi yakunlanmagan",
|
||||
|
||||
@@ -851,6 +851,9 @@
|
||||
"presetDeleted": "預設組已刪除",
|
||||
"confirmDeletePreset": "確定要刪除此預設組嗎?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# 註解",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "在 Linux 上會忽略 vo、gpu-context 和 gpu-api:嵌入式影片一律透過影片平面上的 vo=libmpv 轉譯,而 gpu-next(ArtCNN 等計算著色器需要它)無法以嵌入式方式執行。"
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1618,6 +1621,7 @@
|
||||
"participantPaused": "${name} 暫停了播放",
|
||||
"participantResumed": "${name} 恢復了播放",
|
||||
"participantSeeked": "${name} 變更了播放位置",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} 正在緩衝",
|
||||
"participantNeedsUpdate": "${name} 正在使用舊版應用程式,無法進行同步",
|
||||
"resumingWithout": "不等待 ${name},繼續播放",
|
||||
@@ -2064,6 +2068,10 @@
|
||||
"qualityProfile": "畫質設定檔(Quality Profile)",
|
||||
"rootFolder": "根目錄資料夾",
|
||||
"languageProfile": "語言設定檔(Language Profile)",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "請求已送出",
|
||||
"requestFailed": "請求失敗:${error}",
|
||||
"requestsLoadFailed": "無法載入請求選項",
|
||||
@@ -2075,6 +2083,7 @@
|
||||
"statusBlocklisted": "已加入封鎖清單",
|
||||
"couldNotReach": "無法連線至 ${url}:${error}",
|
||||
"noInstanceAtUrl": "在 ${url} 找不到 Seerr 執行個體(HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "請輸入伺服器位址,例如 https://seerr.example.com",
|
||||
"quickConnectUnsupported": "此 Seerr 執行個體不支援 Quick Connect。需要 Seerr 3.4 或更新版本。",
|
||||
"notInitialized": "此 Seerr 執行個體尚未完成首次執行設定",
|
||||
|
||||
@@ -851,6 +851,9 @@
|
||||
"presetDeleted": "预设已删除",
|
||||
"confirmDeletePreset": "确定要删除此预设吗?",
|
||||
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
|
||||
"lineHint": "",
|
||||
"addLine": "",
|
||||
"removeLine": "",
|
||||
"embeddedVoHint": "Linux 上会忽略 vo、gpu-context 和 gpu-api:嵌入式视频始终通过视频平面上的 vo=libmpv 渲染,而 gpu-next(ArtCNN 等计算着色器需要它)无法以嵌入式方式运行。"
|
||||
},
|
||||
"dialog": {
|
||||
@@ -1618,6 +1621,7 @@
|
||||
"participantPaused": "${name} 暂停了播放",
|
||||
"participantResumed": "${name} 恢复了播放",
|
||||
"participantSeeked": "${name} 更改了播放位置",
|
||||
"participantChangedSpeed": "",
|
||||
"participantBuffering": "${name} 正在缓冲",
|
||||
"participantNeedsUpdate": "${name} 正在使用较旧版本的应用,无法同步",
|
||||
"resumingWithout": "不再等待 ${name},继续播放",
|
||||
@@ -2064,6 +2068,10 @@
|
||||
"qualityProfile": "画质配置",
|
||||
"rootFolder": "根目录",
|
||||
"languageProfile": "语言配置",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "请求已提交",
|
||||
"requestFailed": "请求失败:${error}",
|
||||
"requestsLoadFailed": "无法加载请求选项",
|
||||
@@ -2075,6 +2083,7 @@
|
||||
"statusBlocklisted": "已加入屏蔽列表",
|
||||
"couldNotReach": "无法连接到 ${url}:${error}",
|
||||
"noInstanceAtUrl": "${url} 上没有 Seerr 实例(HTTP ${status})",
|
||||
"behindAuthProxy": "",
|
||||
"invalidUrl": "输入服务器地址,例如 https://seerr.example.com",
|
||||
"quickConnectUnsupported": "此 Seerr 实例不支持 Quick Connect。需要 Seerr 3.4 或更高版本。",
|
||||
"notInitialized": "此 Seerr 实例尚未完成首次运行设置",
|
||||
|
||||
+19
-25
@@ -33,6 +33,7 @@ import 'screens/auth_screen.dart';
|
||||
import 'screens/profile/pin_entry_dialog.dart';
|
||||
import 'screens/profile/profile_switch_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/assistive_technology_service.dart';
|
||||
import 'services/device_performance.dart';
|
||||
import 'services/video_decode_capabilities.dart';
|
||||
import 'services/macos_window_service.dart';
|
||||
@@ -50,7 +51,6 @@ import 'services/gamepad_service.dart';
|
||||
import 'services/trackers/tracker_coordinator.dart';
|
||||
import 'providers/account_preferences_controller.dart';
|
||||
import 'services/account_preferences_repository.dart';
|
||||
import 'providers/user_profile_provider.dart';
|
||||
import 'providers/multi_server_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
import 'providers/download_provider.dart';
|
||||
@@ -138,6 +138,9 @@ void main() {
|
||||
// Keep the accessibility tree available to Maestro and other UI automation
|
||||
// without adding release-build overhead.
|
||||
if (kDebugMode) binding.ensureSemantics();
|
||||
// Android: skip the per-frame semantics pass when the only bound
|
||||
// accessibility service cannot read it (launcher hooks, key remappers).
|
||||
AssistiveTechnologyService.instance.ensureStarted();
|
||||
_installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug
|
||||
|
||||
// On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS
|
||||
@@ -1527,10 +1530,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
if (now.difference(_lastResumeProbe) >= cooldown) {
|
||||
_lastResumeProbe = now;
|
||||
// Await health check before reconnecting so stale "online" servers
|
||||
// get marked offline and included in the reconnection sweep.
|
||||
// get marked offline and included in the reconnection sweep. Servers
|
||||
// that stayed online but were failed over onto a remote endpoint
|
||||
// while local ones exist get re-raced: a same-interface sleep/wake
|
||||
// never fires the connectivity event that would otherwise do it.
|
||||
unawaited(() async {
|
||||
await _serverManager.checkServerHealth();
|
||||
await _serverManager.reconnectOfflineServers();
|
||||
await _serverManager.reoptimizeDemotedServers(reason: 'resume');
|
||||
}());
|
||||
}
|
||||
case AppLifecycleState.paused:
|
||||
@@ -1747,20 +1754,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
ProxyProvider<AccountPreferencesController, AccountPreferencesRepository>(
|
||||
update: (_, controller, _) => controller.repository,
|
||||
),
|
||||
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
||||
create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
|
||||
update: (context, activeProfile, connections, previous) {
|
||||
final provider = previous!;
|
||||
provider.attach(
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||
serverManager: context.read<MultiServerProvider>().serverManager,
|
||||
accountPreferences: context.read<AccountPreferencesController>(),
|
||||
);
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
// Shader presets are app-global — deliberately outside the
|
||||
// profile-scoped session in ProfileSessionScreen.
|
||||
@@ -1874,9 +1867,15 @@ class FormFactorScale extends StatelessWidget {
|
||||
}
|
||||
if (!PlatformDetector.isAutomotive()) return child;
|
||||
|
||||
// Car system bars can sit on the left or right, are opaque, and may be
|
||||
// impossible to hide (OEM policy). Nothing is worth drawing under them,
|
||||
// and the mobile screens only honour top/bottom insets, so consume the
|
||||
// horizontal ones here, once, for every route (car app quality AR-1).
|
||||
// Inside the scaled MediaQuery so the SafeArea reads the scaled padding.
|
||||
final insetChild = SafeArea(top: false, bottom: false, child: child);
|
||||
return SettingValueBuilder<double>(
|
||||
pref: SettingsService.automotiveUiScale,
|
||||
builder: (context, scale, _) => _scaledSurface(child: child, scale: scale, zeroInsets: false),
|
||||
builder: (context, scale, _) => _scaledSurface(child: insetChild, scale: scale, zeroInsets: false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1957,15 +1956,9 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// The app's first screen: undo any orientation lock a previous run's
|
||||
// full-screen player left behind, and re-apply it whenever the form
|
||||
// factor signals (Theme.platform / MediaQuery size) change.
|
||||
OrientationHelper.restoreDefaultOrientations(context);
|
||||
// full-screen player left behind.
|
||||
unawaited(OrientationHelper.restoreDefaultOrientations());
|
||||
}
|
||||
|
||||
void _setStatus(String message) {
|
||||
@@ -2028,6 +2021,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
connectionRegistry: connRegistry,
|
||||
serverRegistry: registry,
|
||||
profileRegistry: profileRegistry,
|
||||
plexHome: context.read<PlexHomeService>(),
|
||||
);
|
||||
await bootstrap.run();
|
||||
final pruned = await ProfileConnectionCleanup(
|
||||
|
||||
@@ -259,15 +259,9 @@ class AccountPreferences implements MediaServerUserProfile {
|
||||
@override
|
||||
String? get defaultAudioLanguage => preferredAudioLanguage;
|
||||
|
||||
@override
|
||||
List<String>? get defaultAudioLanguages => null;
|
||||
|
||||
@override
|
||||
String? get defaultSubtitleLanguage => preferredSubtitleLanguage;
|
||||
|
||||
@override
|
||||
List<String>? get defaultSubtitleLanguages => null;
|
||||
|
||||
@override
|
||||
SubtitlePlaybackMode? get subtitleMode => subtitlePlaybackMode;
|
||||
}
|
||||
|
||||
@@ -98,12 +98,18 @@ int fallbackPageTotal({required int offset, required int itemCount, int? request
|
||||
/// back shorter than [pageSize]. The short-page break is for backends whose
|
||||
/// total is unreliable; leave it off when the total is authoritative.
|
||||
///
|
||||
/// [onPage] receives the accumulated items after each intermediate page —
|
||||
/// i.e. only when another request will follow — so callers can render while
|
||||
/// pagination continues. It never fires for single-page listings or the final
|
||||
/// page; the returned list covers those.
|
||||
///
|
||||
/// [abort] is checked before and after every request. Errors propagate.
|
||||
Future<List<T>> drainPages<T>(
|
||||
Future<LibraryPage<T>> Function(int start, int size) fetchPage, {
|
||||
required int pageSize,
|
||||
AbortController? abort,
|
||||
bool stopOnShortPage = false,
|
||||
void Function(List<T> accumulated)? onPage,
|
||||
}) async {
|
||||
final all = <T>[];
|
||||
var start = 0;
|
||||
@@ -116,6 +122,7 @@ Future<List<T>> drainPages<T>(
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
if (stopOnShortPage && page.items.length < pageSize) break;
|
||||
onPage?.call(all);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,16 @@ enum MediaBrowserDialect {
|
||||
/// Emby 404s; chapter-name fallback still applies.
|
||||
bool get supportsMediaSegments => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// Before taking the first entry of a `TranscodingProfile.VideoCodec` list,
|
||||
/// the server rotates codecs the admin has not enabled
|
||||
/// (`AllowHevcEncoding`/`AllowAv1Encoding`, both off by default) to the
|
||||
/// back — Jellyfin's `EncodingHelper.ShiftVideoCodecsIfNeeded`. Emby has
|
||||
/// no such step and no AV1 encoder at all: it hands `av1` straight to
|
||||
/// ffmpeg and the HLS request fails with 500 `No video encoder found for
|
||||
/// 'av1'` (#2230). Neither server checks actual encoder availability, so a
|
||||
/// leading codec must be one the dialect is known to emit.
|
||||
bool get rotatesDisabledTranscodeCodecs => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the
|
||||
/// route resolves to audio streaming with `Lyrics` as the container and
|
||||
/// spawns an ffmpeg process that fails with a 500.
|
||||
|
||||
@@ -75,24 +75,31 @@ class MediaHub {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isContinueWatchingKey(String rawKey) {
|
||||
// Per-key memo: every card build and D-pad step re-asks these, and the
|
||||
// answer needs two regex passes over a key. The set of distinct hub keys a
|
||||
// session sees is small, so the maps stay small too.
|
||||
final Map<String, bool> _continueWatchingKeys = <String, bool>{};
|
||||
final Map<String, bool> _continueWatchingActionKeys = <String, bool>{};
|
||||
final RegExp _hubKeySeparators = RegExp(r'[^a-z0-9]+');
|
||||
|
||||
bool _isContinueWatchingKey(String rawKey) => _continueWatchingKeys.putIfAbsent(rawKey, () {
|
||||
final compactKey = _compactHubKey(rawKey);
|
||||
if (compactKey == 'continuewatching') return true;
|
||||
|
||||
final tokens = _hubKeyTokens(rawKey);
|
||||
return tokens.contains('inprogress') || _hasTailToken(tokens, 'continue');
|
||||
}
|
||||
});
|
||||
|
||||
bool _usesContinueWatchingActionKey(String rawKey) {
|
||||
bool _usesContinueWatchingActionKey(String rawKey) => _continueWatchingActionKeys.putIfAbsent(rawKey, () {
|
||||
final tokens = _hubKeyTokens(rawKey);
|
||||
return _hasTailToken(tokens, 'nextup') || tokens.contains('ondeck');
|
||||
}
|
||||
});
|
||||
|
||||
List<String> _hubKeyTokens(String rawKey) {
|
||||
return rawKey.toLowerCase().split(RegExp(r'[^a-z0-9]+')).where((part) => part.isNotEmpty).toList(growable: false);
|
||||
return rawKey.toLowerCase().split(_hubKeySeparators).where((part) => part.isNotEmpty).toList(growable: false);
|
||||
}
|
||||
|
||||
String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '');
|
||||
String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(_hubKeySeparators, '');
|
||||
|
||||
bool _hasTailToken(List<String> tokens, String token) => tokens.isNotEmpty && tokens.last == token;
|
||||
|
||||
|
||||
@@ -177,8 +177,6 @@ sealed class MediaItem with _$MediaItem {
|
||||
@JsonKey(fromJson: _mediaItemVersionsFromJson) List<MediaVersion>? mediaVersions,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
String? trailerKey,
|
||||
@JsonKey(fromJson: flexibleInt) int? playlistItemId,
|
||||
@JsonKey(fromJson: flexibleInt) int? playQueueItemId,
|
||||
@@ -266,7 +264,6 @@ sealed class MediaItem with _$MediaItem {
|
||||
@JsonKey(fromJson: _mediaItemVersionsFromJson) List<MediaVersion>? mediaVersions,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
String? audioLanguage,
|
||||
|
||||
/// Jellyfin playlist entry id used by playlist write endpoints.
|
||||
String? playlistItemId,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,8 +67,6 @@ PlexMediaItem _$PlexMediaItemFromJson(Map<String, dynamic> json) =>
|
||||
mediaVersions: _mediaItemVersionsFromJson(json['mediaVersions']),
|
||||
libraryId: json['libraryId'] as String?,
|
||||
libraryTitle: json['libraryTitle'] as String?,
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
trailerKey: json['trailerKey'] as String?,
|
||||
playlistItemId: flexibleInt(json['playlistItemId']),
|
||||
playQueueItemId: flexibleInt(json['playQueueItemId']),
|
||||
@@ -135,8 +133,6 @@ Map<String, dynamic> _$PlexMediaItemToJson(PlexMediaItem instance) =>
|
||||
'mediaVersions': ?instance.mediaVersions?.map((e) => e.toJson()).toList(),
|
||||
'libraryId': ?instance.libraryId,
|
||||
'libraryTitle': ?instance.libraryTitle,
|
||||
'audioLanguage': ?instance.audioLanguage,
|
||||
'subtitleLanguage': ?instance.subtitleLanguage,
|
||||
'trailerKey': ?instance.trailerKey,
|
||||
'playlistItemId': ?instance.playlistItemId,
|
||||
'playQueueItemId': ?instance.playQueueItemId,
|
||||
@@ -207,7 +203,6 @@ JellyfinMediaItem _$JellyfinMediaItemFromJson(Map<String, dynamic> json) =>
|
||||
mediaVersions: _mediaItemVersionsFromJson(json['mediaVersions']),
|
||||
libraryId: json['libraryId'] as String?,
|
||||
libraryTitle: json['libraryTitle'] as String?,
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
playlistItemId: json['playlistItemId'] as String?,
|
||||
serverId: json['serverId'] as String?,
|
||||
serverName: json['serverName'] as String?,
|
||||
@@ -270,7 +265,6 @@ Map<String, dynamic> _$JellyfinMediaItemToJson(JellyfinMediaItem instance) =>
|
||||
'mediaVersions': ?instance.mediaVersions?.map((e) => e.toJson()).toList(),
|
||||
'libraryId': ?instance.libraryId,
|
||||
'libraryTitle': ?instance.libraryTitle,
|
||||
'audioLanguage': ?instance.audioLanguage,
|
||||
'playlistItemId': ?instance.playlistItemId,
|
||||
'serverId': ?instance.serverId,
|
||||
'serverName': ?instance.serverName,
|
||||
|
||||
@@ -20,8 +20,9 @@ enum SubtitlePlaybackMode {
|
||||
}
|
||||
|
||||
/// Backend-neutral subset of a server-stored user profile, scoped to the
|
||||
/// fields the player needs for auto-track selection. Each backend exposes
|
||||
/// these on its own concrete type ([PlexUserProfile], [JellyfinUserProfile]).
|
||||
/// fields the player needs for auto-track selection. [AccountPreferences] is
|
||||
/// the implementation, so playback reads the same cache the Account
|
||||
/// preferences screen writes.
|
||||
///
|
||||
/// Language strings are server-shaped (Plex returns 639-2/B like "fre",
|
||||
/// Jellyfin returns 639-2/T like "fra"); [LanguageCodes.getVariations]
|
||||
@@ -31,23 +32,17 @@ abstract class MediaServerUserProfile {
|
||||
/// language preferences. False means "keep the file's default track".
|
||||
bool get autoSelectAudio;
|
||||
|
||||
/// Primary preferred audio language. May be null when the user has no
|
||||
/// preference set.
|
||||
/// Preferred audio language. May be null when the user has no preference
|
||||
/// set. Plex also stores a ranked list, but PMS applies that itself when
|
||||
/// stamping `selected`; the client only needs the primary as a fallback.
|
||||
String? get defaultAudioLanguage;
|
||||
|
||||
/// Additional ranked audio language preferences. Plex exposes a list,
|
||||
/// Jellyfin only the primary; Jellyfin implementations return null.
|
||||
List<String>? get defaultAudioLanguages;
|
||||
|
||||
/// Primary preferred subtitle language. May be null.
|
||||
/// Preferred subtitle language. May be null.
|
||||
String? get defaultSubtitleLanguage;
|
||||
|
||||
/// Additional ranked subtitle language preferences. Same Plex/Jellyfin
|
||||
/// difference as the audio list.
|
||||
List<String>? get defaultSubtitleLanguages;
|
||||
|
||||
/// Server-side subtitle mode when exposed by the backend. Plex does not map
|
||||
/// cleanly to Jellyfin's mode enum, so it returns null and keeps existing
|
||||
/// Plex-selected-stream behavior.
|
||||
/// Server-side subtitle mode when the backend leaves auto-selection to the
|
||||
/// client (MediaBrowser). Plex exposes `autoSelectSubtitle` too, but PMS
|
||||
/// applies it itself when stamping `selected` on streams, so
|
||||
/// [TrackSelectionService] ignores the mode for Plex items.
|
||||
SubtitlePlaybackMode? get subtitleMode => null;
|
||||
}
|
||||
|
||||
@@ -376,11 +376,22 @@ class PlaybackExtras {
|
||||
return RegExp(source, caseSensitive: false);
|
||||
}
|
||||
|
||||
/// Longest chapter that may become a chapter-derived intro marker.
|
||||
///
|
||||
/// Detected intros (Plex, Intro Skipper) fall well inside two minutes; a
|
||||
/// movie's first chapter titled "Opening Credits" or "Introduction" runs
|
||||
/// five to ten minutes of actual picture, and skipping it skips the film
|
||||
/// (#2235). Applies only to markers minted here from chapter titles, never
|
||||
/// to server-supplied markers, and never to credits, which are legitimately
|
||||
/// long on movies.
|
||||
static const maxChapterIntroDuration = Duration(minutes: 3);
|
||||
|
||||
/// Returns [PlaybackExtras] using real markers when available, filling any
|
||||
/// missing marker types from chapter titles matching intro/credits patterns.
|
||||
/// [forceChapterFallback] prefers chapter-derived markers for any type they
|
||||
/// provide. When real markers exist, reclassifies markers with unknown types
|
||||
/// against the patterns so non-standard type strings get recognized.
|
||||
/// Chapter-derived intros longer than [maxChapterIntroDuration] are dropped.
|
||||
factory PlaybackExtras.withChapterFallback({
|
||||
required List<MediaChapter> chapters,
|
||||
required List<MediaMarker> markers,
|
||||
@@ -411,6 +422,7 @@ class PlaybackExtras {
|
||||
|
||||
final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null);
|
||||
if (end == null) continue;
|
||||
if (type == 'intro' && end - start > maxChapterIntroDuration.inMilliseconds) continue;
|
||||
|
||||
synthetic.add(MediaMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end));
|
||||
}
|
||||
|
||||
@@ -12,8 +12,16 @@ class MediaStream {
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
final String? displayTitle;
|
||||
|
||||
/// The server's current pick for this user (Plex `selected`, Jellyfin's
|
||||
/// default-stream index). Distinct from [isDefault]: Plex marks the
|
||||
/// container's default track separately, and the player's selection ladder
|
||||
/// ranks the two differently.
|
||||
final bool selected;
|
||||
|
||||
/// The container's own default flag (Plex `default`, Jellyfin `IsDefault`).
|
||||
final bool isDefault;
|
||||
|
||||
// Audio
|
||||
final int? channels;
|
||||
|
||||
@@ -41,6 +49,7 @@ class MediaStream {
|
||||
this.title,
|
||||
this.displayTitle,
|
||||
this.selected = false,
|
||||
this.isDefault = false,
|
||||
this.channels,
|
||||
this.frameRate,
|
||||
this.hdr = false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../services/plex_constants.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import 'metadata_edit_models.dart';
|
||||
@@ -82,7 +83,8 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
final success = await client.updateMetadata(
|
||||
sectionId: sectionId,
|
||||
ratingKey: draft.sourceItem.id,
|
||||
typeNumber: _plexTypeNumberForKind(draft.sourceItem.kind),
|
||||
// supportsKind restricts drafts to the four video kinds.
|
||||
typeNumber: PlexMetadataType.forKind(draft.sourceItem.kind) ?? 0,
|
||||
title: _changedString(draft, 'title'),
|
||||
titleSort: _changedString(draft, 'titleSort'),
|
||||
originalTitle: _changedString(draft, 'originalTitle'),
|
||||
@@ -329,14 +331,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
String? _prefKey(String fieldId) => fieldId.startsWith('pref:') ? fieldId.substring(5) : null;
|
||||
}
|
||||
|
||||
int _plexTypeNumberForKind(MediaKind kind) => switch (kind) {
|
||||
MediaKind.movie => 1,
|
||||
MediaKind.show => 2,
|
||||
MediaKind.season => 3,
|
||||
MediaKind.episode => 4,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
const _plexLocaleCodes = [
|
||||
'ar-SA',
|
||||
'bg-BG',
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import '../../media/media_server_user_profile.dart';
|
||||
|
||||
/// Jellyfin user playback preferences, sourced from `User.Configuration`
|
||||
/// (returned by `/Users/{userId}` or `/Users/Me`). Jellyfin exposes only a
|
||||
/// single ranked audio/subtitle language, so the multi-list accessors on
|
||||
/// [MediaServerUserProfile] return null.
|
||||
class JellyfinUserProfile implements MediaServerUserProfile {
|
||||
@override
|
||||
final bool autoSelectAudio;
|
||||
|
||||
@override
|
||||
final String? defaultAudioLanguage;
|
||||
|
||||
@override
|
||||
final String? defaultSubtitleLanguage;
|
||||
|
||||
/// Server-reported subtitle mode (None / Default / Always / OnlyForced /
|
||||
/// Smart).
|
||||
@override
|
||||
final SubtitlePlaybackMode? subtitleMode;
|
||||
|
||||
const JellyfinUserProfile({
|
||||
required this.autoSelectAudio,
|
||||
this.defaultAudioLanguage,
|
||||
this.defaultSubtitleLanguage,
|
||||
this.subtitleMode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<String>? get defaultAudioLanguages => null;
|
||||
|
||||
@override
|
||||
List<String>? get defaultSubtitleLanguages => null;
|
||||
|
||||
/// Build from `/Users/Me` (or `/Users/{userId}`) response — pulls the
|
||||
/// `Configuration` block; missing values fall back to server defaults
|
||||
/// (auto-select on, no language preference).
|
||||
factory JellyfinUserProfile.fromUserDto(Map<String, dynamic> json) {
|
||||
final config = json['Configuration'] as Map<String, dynamic>? ?? const {};
|
||||
final audio = config['AudioLanguagePreference'] as String?;
|
||||
final subtitle = config['SubtitleLanguagePreference'] as String?;
|
||||
final playDefault = config['PlayDefaultAudioTrack'] as bool? ?? true;
|
||||
return JellyfinUserProfile(
|
||||
autoSelectAudio: playDefault,
|
||||
defaultAudioLanguage: (audio == null || audio.isEmpty) ? null : audio,
|
||||
defaultSubtitleLanguage: (subtitle == null || subtitle.isEmpty) ? null : subtitle,
|
||||
subtitleMode: SubtitlePlaybackMode.fromServerValue(config['SubtitleMode']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,26 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../utils/json_utils.dart';
|
||||
import 'media_subscription.dart';
|
||||
|
||||
part 'livetv_dvr.g.dart';
|
||||
|
||||
List<ChannelMapping> _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson);
|
||||
|
||||
List<SubscriptionSetting> _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson);
|
||||
|
||||
List<Map<String, dynamic>> _parseRawMaps(Object? raw) => flexibleMapList(raw);
|
||||
|
||||
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
|
||||
/// A Plex Live TV DVR (e.g., HDHomeRun tuner, IPTV provider), reduced to what
|
||||
/// Plezy renders: identity, lineup labelling, and the enabled-channel mapping.
|
||||
/// Plex also reports tuner hardware, `Setting`, and `Device` relations for DVR
|
||||
/// setup, which Plezy does not implement.
|
||||
@JsonSerializable(createToJson: false)
|
||||
class LiveTvDvr {
|
||||
@JsonKey(defaultValue: '')
|
||||
final String key;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String uuid;
|
||||
final String? make;
|
||||
final String? model;
|
||||
final String? modelNumber;
|
||||
final String? firmware;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? tuners;
|
||||
final String? lineup;
|
||||
final String? lineupTitle;
|
||||
final String? lineupURL;
|
||||
final String? country;
|
||||
final String? language;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? status;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? state;
|
||||
final String? protocol;
|
||||
final String? sources;
|
||||
final String? uri;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? lastSeenAt;
|
||||
@JsonKey(name: 'ChannelMapping', fromJson: _parseChannelMappings)
|
||||
final List<ChannelMapping> channelMappings;
|
||||
@JsonKey(name: 'Setting', fromJson: _parseSettings)
|
||||
final List<SubscriptionSetting> settings;
|
||||
@JsonKey(name: 'Device', fromJson: _parseRawMaps)
|
||||
final List<Map<String, dynamic>> devices;
|
||||
|
||||
LiveTvDvr({
|
||||
required this.key,
|
||||
required this.uuid,
|
||||
this.make,
|
||||
this.model,
|
||||
this.modelNumber,
|
||||
this.firmware,
|
||||
this.tuners,
|
||||
this.lineup,
|
||||
this.lineupTitle,
|
||||
this.lineupURL,
|
||||
this.country,
|
||||
this.language,
|
||||
this.status,
|
||||
this.state,
|
||||
this.protocol,
|
||||
this.sources,
|
||||
this.uri,
|
||||
this.lastSeenAt,
|
||||
this.channelMappings = const [],
|
||||
this.settings = const [],
|
||||
this.devices = const [],
|
||||
});
|
||||
LiveTvDvr({required this.key, this.lineup, this.lineupTitle, this.lineupURL, this.channelMappings = const []});
|
||||
|
||||
factory LiveTvDvr.fromJson(Map<String, dynamic> json) => _$LiveTvDvrFromJson(json);
|
||||
}
|
||||
@@ -76,12 +29,10 @@ class LiveTvDvr {
|
||||
@JsonSerializable(createToJson: false)
|
||||
class ChannelMapping {
|
||||
final String? channelKey;
|
||||
final String? deviceIdentifier;
|
||||
@JsonKey(fromJson: flexibleBool)
|
||||
final bool? enabled;
|
||||
final String? lineupIdentifier;
|
||||
|
||||
ChannelMapping({this.channelKey, this.deviceIdentifier, this.enabled, this.lineupIdentifier});
|
||||
ChannelMapping({this.channelKey, this.enabled});
|
||||
|
||||
factory ChannelMapping.fromJson(Map<String, dynamic> json) => _$ChannelMappingFromJson(json);
|
||||
}
|
||||
|
||||
@@ -8,36 +8,16 @@ part of 'livetv_dvr.dart';
|
||||
|
||||
LiveTvDvr _$LiveTvDvrFromJson(Map<String, dynamic> json) => LiveTvDvr(
|
||||
key: json['key'] as String? ?? '',
|
||||
uuid: json['uuid'] as String? ?? '',
|
||||
make: json['make'] as String?,
|
||||
model: json['model'] as String?,
|
||||
modelNumber: json['modelNumber'] as String?,
|
||||
firmware: json['firmware'] as String?,
|
||||
tuners: flexibleInt(json['tuners']),
|
||||
lineup: json['lineup'] as String?,
|
||||
lineupTitle: json['lineupTitle'] as String?,
|
||||
lineupURL: json['lineupURL'] as String?,
|
||||
country: json['country'] as String?,
|
||||
language: json['language'] as String?,
|
||||
status: flexibleInt(json['status']),
|
||||
state: flexibleInt(json['state']),
|
||||
protocol: json['protocol'] as String?,
|
||||
sources: json['sources'] as String?,
|
||||
uri: json['uri'] as String?,
|
||||
lastSeenAt: flexibleInt(json['lastSeenAt']),
|
||||
channelMappings: json['ChannelMapping'] == null
|
||||
? const []
|
||||
: _parseChannelMappings(json['ChannelMapping']),
|
||||
settings: json['Setting'] == null
|
||||
? const []
|
||||
: _parseSettings(json['Setting']),
|
||||
devices: json['Device'] == null ? const [] : _parseRawMaps(json['Device']),
|
||||
);
|
||||
|
||||
ChannelMapping _$ChannelMappingFromJson(Map<String, dynamic> json) =>
|
||||
ChannelMapping(
|
||||
channelKey: json['channelKey'] as String?,
|
||||
deviceIdentifier: json['deviceIdentifier'] as String?,
|
||||
enabled: flexibleBool(json['enabled']),
|
||||
lineupIdentifier: json['lineupIdentifier'] as String?,
|
||||
);
|
||||
|
||||
@@ -87,6 +87,8 @@ class PlexAccountPreferences {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/// Singular language fields tolerate the array/CSV drift the account API has
|
||||
/// shown (#1488) by taking the first entry.
|
||||
static String? _profileLanguage(Object? value) => flexibleCsvStringList(value)?.first;
|
||||
|
||||
static SubtitlePlaybackMode? _subtitleMode(Object? value) => switch (flexibleInt(value)) {
|
||||
|
||||
@@ -14,7 +14,6 @@ class PlexConfig {
|
||||
/// Friendly device name — Plex dashboards and Tautulli show it as the
|
||||
/// session's "Player".
|
||||
final String? deviceName;
|
||||
final bool acceptJson;
|
||||
final String? machineIdentifier;
|
||||
final String? languageCode;
|
||||
|
||||
@@ -27,7 +26,6 @@ class PlexConfig {
|
||||
this.platform = 'Flutter',
|
||||
this.device,
|
||||
this.deviceName,
|
||||
this.acceptJson = true,
|
||||
this.machineIdentifier,
|
||||
this.languageCode,
|
||||
});
|
||||
@@ -39,7 +37,6 @@ class PlexConfig {
|
||||
String? product,
|
||||
String? platform,
|
||||
String? device,
|
||||
bool acceptJson = true,
|
||||
String? machineIdentifier,
|
||||
String? languageCode,
|
||||
}) async {
|
||||
@@ -54,7 +51,6 @@ class PlexConfig {
|
||||
platform: platform ?? identity.platform,
|
||||
device: device ?? sanitizeHeaderValue(identity.deviceModel),
|
||||
deviceName: sanitizeHeaderValue(identity.deviceName),
|
||||
acceptJson: acceptJson,
|
||||
machineIdentifier: machineIdentifier,
|
||||
languageCode: languageCode,
|
||||
);
|
||||
@@ -69,7 +65,7 @@ class PlexConfig {
|
||||
'X-Plex-Client-Profile-Name': 'Generic',
|
||||
'X-Plex-Device': ?device,
|
||||
'X-Plex-Device-Name': ?deviceName,
|
||||
if (acceptJson) 'Accept': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Charset': 'utf-8',
|
||||
'Accept-Language': ?_normalizedLanguageCode,
|
||||
'X-Plex-Language': ?_normalizedLanguageCode,
|
||||
@@ -96,7 +92,6 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
String? deviceName,
|
||||
bool? acceptJson,
|
||||
String? machineIdentifier,
|
||||
String? languageCode,
|
||||
}) {
|
||||
@@ -109,7 +104,6 @@ class PlexConfig {
|
||||
platform: platform ?? this.platform,
|
||||
device: device ?? this.device,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
acceptJson: acceptJson ?? this.acceptJson,
|
||||
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
|
||||
languageCode: languageCode ?? this.languageCode,
|
||||
);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../media/media_server_user_profile.dart';
|
||||
import '../../utils/json_utils.dart';
|
||||
|
||||
part 'plex_user_profile.g.dart';
|
||||
|
||||
/// Represents a Plex user's profile preferences
|
||||
/// Fetched from https://clients.plex.tv/api/v2/user
|
||||
///
|
||||
/// Every field parses tolerantly: the account API drifts (~July 2026 the
|
||||
/// language-list fields switched from arrays to CSV strings, #1488), and a
|
||||
/// single drifted field must never sink the whole profile.
|
||||
@JsonSerializable()
|
||||
class PlexUserProfile implements MediaServerUserProfile {
|
||||
@JsonKey(fromJson: _boolOrTrue)
|
||||
@override
|
||||
final bool autoSelectAudio;
|
||||
@JsonKey(fromJson: _flexibleLanguage)
|
||||
@override
|
||||
final String? defaultAudioLanguage;
|
||||
@JsonKey(fromJson: flexibleCsvStringList)
|
||||
@override
|
||||
final List<String>? defaultAudioLanguages;
|
||||
@JsonKey(fromJson: _flexibleLanguage)
|
||||
@override
|
||||
final String? defaultSubtitleLanguage;
|
||||
@JsonKey(fromJson: flexibleCsvStringList)
|
||||
@override
|
||||
final List<String>? defaultSubtitleLanguages;
|
||||
|
||||
@override
|
||||
SubtitlePlaybackMode? get subtitleMode => null;
|
||||
|
||||
PlexUserProfile({
|
||||
required this.autoSelectAudio,
|
||||
this.defaultAudioLanguage,
|
||||
this.defaultAudioLanguages,
|
||||
this.defaultSubtitleLanguage,
|
||||
this.defaultSubtitleLanguages,
|
||||
});
|
||||
|
||||
factory PlexUserProfile.fromJson(Map<String, dynamic> json) {
|
||||
final envelope = json['profile'];
|
||||
final profile = envelope is Map<String, dynamic> ? envelope : json;
|
||||
return _$PlexUserProfileFromJson(profile);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'profile': _$PlexUserProfileToJson(this)};
|
||||
}
|
||||
|
||||
/// Singular language fields tolerate the inverse drift (array/CSV → first entry).
|
||||
String? _flexibleLanguage(Object? v) => flexibleCsvStringList(v)?.first;
|
||||
|
||||
bool _boolOrTrue(Object? v) => flexibleBoolNullable(v) ?? true;
|
||||
@@ -1,28 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_user_profile.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexUserProfile _$PlexUserProfileFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => PlexUserProfile(
|
||||
autoSelectAudio: _boolOrTrue(json['autoSelectAudio']),
|
||||
defaultAudioLanguage: _flexibleLanguage(json['defaultAudioLanguage']),
|
||||
defaultAudioLanguages: flexibleCsvStringList(json['defaultAudioLanguages']),
|
||||
defaultSubtitleLanguage: _flexibleLanguage(json['defaultSubtitleLanguage']),
|
||||
defaultSubtitleLanguages: flexibleCsvStringList(
|
||||
json['defaultSubtitleLanguages'],
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) =>
|
||||
<String, dynamic>{
|
||||
'autoSelectAudio': instance.autoSelectAudio,
|
||||
'defaultAudioLanguage': instance.defaultAudioLanguage,
|
||||
'defaultAudioLanguages': instance.defaultAudioLanguages,
|
||||
'defaultSubtitleLanguage': instance.defaultSubtitleLanguage,
|
||||
'defaultSubtitleLanguages': instance.defaultSubtitleLanguages,
|
||||
};
|
||||
@@ -67,6 +67,10 @@ class SeerrRequestPayload {
|
||||
final String? rootFolder;
|
||||
final int? languageProfileId;
|
||||
|
||||
/// Arr tag ids. An empty list is a real override (no tags); null leaves
|
||||
/// the instance defaults in place.
|
||||
final List<int>? tags;
|
||||
|
||||
const SeerrRequestPayload({
|
||||
required this.mediaType,
|
||||
required this.mediaId,
|
||||
@@ -76,6 +80,7 @@ class SeerrRequestPayload {
|
||||
this.profileId,
|
||||
this.rootFolder,
|
||||
this.languageProfileId,
|
||||
this.tags,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -87,5 +92,6 @@ class SeerrRequestPayload {
|
||||
if (profileId != null) 'profileId': profileId,
|
||||
if (rootFolder != null) 'rootFolder': rootFolder,
|
||||
if (languageProfileId != null) 'languageProfileId': languageProfileId,
|
||||
if (tags != null) 'tags': tags,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user