Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6ee0861e | ||
|
|
986e769ac0 | ||
|
|
b61fc9e661 | ||
|
|
fa8da33ec8 | ||
|
|
44599549e1 | ||
|
|
e46b3dab7c | ||
|
|
e67bf996c5 | ||
|
|
66cc6fc192 | ||
|
|
691d10b3c6 | ||
|
|
42b9bf7fb8 | ||
|
|
2efd300a5f | ||
|
|
8b1ac156f3 | ||
|
|
be8fa15733 | ||
|
|
fa054153ae | ||
|
|
a230d51598 | ||
|
|
e4f3888694 | ||
|
|
2087dff32d | ||
|
|
d112ade601 | ||
|
|
6301d5124e | ||
|
|
13acd41b09 | ||
|
|
0003d95c0a | ||
|
|
4d2aa8f9a2 | ||
|
|
deadf712a8 | ||
|
|
ef91156827 | ||
|
|
42b6710f04 | ||
|
|
7e583ef05a | ||
|
|
2132c23fab | ||
|
|
c4be378219 | ||
|
|
f967b9b2f2 | ||
|
|
eafacff3be | ||
|
|
33d845dcdd | ||
|
|
0134d9dbc0 | ||
|
|
4fd9034e8e | ||
|
|
51beb5ac4b | ||
|
|
b43cd81102 | ||
|
|
5112d71820 | ||
|
|
ab477c971d | ||
|
|
59f44d9ae1 | ||
|
|
de39e15e36 | ||
|
|
7b1b0d766b | ||
|
|
960adf42f2 | ||
|
|
d181bd34ae | ||
|
|
b27cab4510 | ||
|
|
17c877c973 | ||
|
|
1373c299df | ||
|
|
7e84df6385 | ||
|
|
e8e67df379 | ||
|
|
c70f88cee0 | ||
|
|
54efd4fe73 | ||
|
|
deaa06826c | ||
|
|
d371bfed3a | ||
|
|
aeb06c2c9e | ||
|
|
9a9b89082b | ||
|
|
c79ff68a7b | ||
|
|
7e1f18f93f | ||
|
|
5bd742c8b8 | ||
|
|
5c37c72a08 | ||
|
|
22c1ad56d3 | ||
|
|
3b3a816de0 | ||
|
|
3fc229cd6c | ||
|
|
b5186624b8 | ||
|
|
0c809491c7 | ||
|
|
5860079b43 | ||
|
|
ce03857588 | ||
|
|
200d896bea | ||
|
|
8fe7e4bcfd | ||
|
|
d2b5039bb3 | ||
|
|
12b8c7e7ef | ||
|
|
e9988165df | ||
|
|
582d419618 | ||
|
|
72d15b96a2 | ||
|
|
d9e31fd1a4 | ||
|
|
23f9afa5b1 | ||
|
|
efb0e885ae | ||
|
|
5fb4c78385 | ||
|
|
5a666fd10f | ||
|
|
56312a9eb2 | ||
|
|
976bdbd021 | ||
|
|
274b31ebac | ||
|
|
e1ef52aba1 | ||
|
|
9db6f54b7a | ||
|
|
39ee089212 | ||
|
|
89d54d8f1d | ||
|
|
44773fd742 | ||
|
|
4f4a7338e0 | ||
|
|
10f9139ac6 | ||
|
|
3c1ab3cc6c | ||
|
|
c597390e1c | ||
|
|
2806115015 | ||
|
|
d9493c6157 | ||
|
|
7cfb890046 | ||
|
|
c6458295a1 | ||
|
|
6e2dda092d | ||
|
|
45e8da1610 | ||
|
|
ad026cecc1 | ||
|
|
ffc95d6b2c | ||
|
|
a3cbf323b3 | ||
|
|
2f2016fdbe | ||
|
|
ff1064d35f | ||
|
|
861111a191 | ||
|
|
463ad6b933 | ||
|
|
1ee648f442 | ||
|
|
ed71b200b9 | ||
|
|
a1d8ab5210 | ||
|
|
a13febf67b | ||
|
|
a97be5823f | ||
|
|
c9de78697b | ||
|
|
9ac8002b53 | ||
|
|
942065e39d | ||
|
|
94565b4ee0 | ||
|
|
0c5308cfa1 | ||
|
|
a18ecb5c4a | ||
|
|
f7060478d1 | ||
|
|
3f03262833 | ||
|
|
3c6d398598 | ||
|
|
8f893e7a67 | ||
|
|
465da3e82a | ||
|
|
04615ce2fc | ||
|
|
a1c98b9d27 | ||
|
|
85bf948004 | ||
|
|
c564635ad9 | ||
|
|
fce4193f7e | ||
|
|
628fd21ec3 | ||
|
|
d2c4e8345f | ||
|
|
ae555ed5d7 | ||
|
|
779c532fc7 | ||
|
|
595cd9fe24 | ||
|
|
75279ca196 | ||
|
|
bd2d19a1c1 | ||
|
|
66c6b137a5 |
+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 =="
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.17.0"
|
||||
sha256 "318b9ac0ff0d239b3edbad57a44c008e7aaf0c5623bc2c595407fce2987ba5e4"
|
||||
version "2.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -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,82 +58,31 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
// The in-project :libmpv module owns the mpv-build pin (repo-root
|
||||
// mpv-build.lock.json assets + checksums, plus the plezy.localMpvDir/
|
||||
// PLEZY_LOCAL_MPV_DIR escape hatch) and extracts the per-ABI tarballs'
|
||||
// prebuilt native libraries. This file reads two of its output trees
|
||||
// back: FFmpeg .so files for the Media3 adapter link step, and the libc++
|
||||
// runtime packaged at PROJECT scope below.
|
||||
val libmpvBuildDir = project(":libmpv").layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val libmpvNativeJniDir = File(libmpvBuildDir, "native/jni")
|
||||
val libmpvLibcxxJniDir = File(libmpvBuildDir, "libcxx/jni")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvDir, ".manifest")
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
||||
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
||||
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val outDir = File(mpvDir, "libcxx")
|
||||
inputs.file(aar)
|
||||
outputs.dir(outDir)
|
||||
doLast {
|
||||
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
||||
outDir.mkdirs()
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"unzip",
|
||||
"-q",
|
||||
"-o",
|
||||
aar.absolutePath,
|
||||
"jni/*/libc++_shared.so",
|
||||
"-d",
|
||||
outDir.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
val mpvFfmpegDevelopmentDir = layout.buildDirectory.dir("libmpv-ffmpeg-development").get().asFile
|
||||
|
||||
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
|
||||
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
|
||||
// build-only; the APK contains one FFmpeg implementation for both players.
|
||||
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
dependsOn(":libmpv:extractLibmpvNative")
|
||||
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
|
||||
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val libraries = listOf("avcodec", "avformat", "avutil", "swresample")
|
||||
inputs.file(aar)
|
||||
val libraries = listOf("avcodec", "avutil", "swresample")
|
||||
inputs.dir(libmpvNativeJniDir)
|
||||
inputs.property("ffmpegVersion", mpvFfmpegVersion)
|
||||
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
|
||||
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
|
||||
@@ -145,7 +94,6 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
outputs.files(
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavformat/avformat.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
|
||||
manifest
|
||||
)
|
||||
@@ -183,7 +131,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
listOf("libavcodec", "libavformat", "libavutil", "libswresample").forEach { library ->
|
||||
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
|
||||
project.copy {
|
||||
from(File(extractedSource, library)) {
|
||||
include("*.h")
|
||||
@@ -204,16 +152,12 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
|
||||
)
|
||||
|
||||
project.copy {
|
||||
from(zipTree(aar)) {
|
||||
from(libmpvNativeJniDir) {
|
||||
include(
|
||||
"jni/*/libavcodec.so",
|
||||
"jni/*/libavformat.so",
|
||||
"jni/*/libavutil.so",
|
||||
"jni/*/libswresample.so"
|
||||
"*/libavcodec.so",
|
||||
"*/libavutil.so",
|
||||
"*/libswresample.so"
|
||||
)
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
into(nativeDir)
|
||||
@@ -224,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()
|
||||
@@ -338,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
|
||||
@@ -437,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")
|
||||
}
|
||||
}
|
||||
@@ -446,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,16 +450,18 @@ tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative")
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
|
||||
dependsOn(prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted libmpv libc++ directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractMpvLibcxx)
|
||||
dependsOn(":libmpv:extractLibmpvNative")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
// mpv Kotlin API + JNI glue live in-project; the prebuilt libmpv/FFmpeg .so
|
||||
// set rides along from the module's extracted mpv-build tarballs.
|
||||
implementation(project(":libmpv"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
|
||||
Vendored
+15
-9
@@ -19,13 +19,19 @@
|
||||
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
|
||||
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
|
||||
|
||||
# ffmpeg_demuxer_jni.cc resolves the AVIO input proxy's callbacks by name
|
||||
# (FindClass on the interface, GetMethodID for position/read/readAt/length).
|
||||
# Keeping the interface members pins the names on every implementation,
|
||||
# including the anonymous proxy inside FfmpegExtractor.
|
||||
-keep interface com.edde746.plezy.exoplayer.FfmpegDemuxerJni$Input {
|
||||
long position();
|
||||
int read(byte[], int);
|
||||
int readAt(long, byte[], int);
|
||||
long length();
|
||||
# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
|
||||
# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
|
||||
# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
|
||||
# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
|
||||
# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
|
||||
# extractorOutput the same way to wrap LATM tracks.
|
||||
#
|
||||
# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
|
||||
# them in its companion object, so the throw surfaces as ExceptionInInitializerError
|
||||
# while constructing the extractor — every MKV direct-play, release builds only. Only
|
||||
# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
|
||||
# shrinking through the compile-time subclass references.
|
||||
-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
|
||||
private androidx.media3.extractor.ExtractorOutput extractorOutput;
|
||||
private androidx.media3.common.util.ParsableByteArray subtitleSample;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<!-- Serves a test asset over content:// so instrumentation covers the
|
||||
SAF/ContentDataSource read path. Exported because the test body
|
||||
runs in the app's process while this provider lives in the
|
||||
androidTest APK's own uid; it serves nothing but this APK's own
|
||||
assets. Merged into the androidTest APK, never into the app. -->
|
||||
<provider
|
||||
android:name="com.edde746.plezy.exoplayer.FixtureContentProvider"
|
||||
android:authorities="com.edde746.plezy.test.fixtures"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-117
@@ -1,117 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Serves a test asset over a {@code content://} URI so instrumentation can exercise media3's
|
||||
* {@code ContentDataSource} read path.
|
||||
*
|
||||
* <p>A download stored through SAF reaches the player as {@code content://} and is read with
|
||||
* {@code openAssetFileDescriptor} — a different reopen story than a plain file:
|
||||
* {@code FfmpegRandomAccessSource} has to open a <em>second</em> descriptor on the document while
|
||||
* the loader still holds one. Nothing else in the app produces that shape on demand, and MediaStore
|
||||
* would leave rows on the device.
|
||||
*
|
||||
* <p>The provider owns the whole fixture: a test body runs in the <em>app's</em> process and cannot
|
||||
* write into this APK's data directory, so handing it a path could never work. It copies the named
|
||||
* asset into its own cache and returns a read-only descriptor, which crosses the uid boundary fine.
|
||||
*
|
||||
* <p>Java rather than Kotlin on purpose: hosting this provider starts a process for the test APK
|
||||
* alone, and the Kotlin stdlib the instrumentation normally borrows from the app is not on that
|
||||
* process's classpath.
|
||||
*/
|
||||
public final class FixtureContentProvider extends ContentProvider {
|
||||
|
||||
public static final String AUTHORITY = "com.edde746.plezy.test.fixtures";
|
||||
|
||||
/** @param asset path inside this APK's assets, e.g. {@code ffmpeg/seek_cued.mkv}. */
|
||||
public static Uri uriFor(String asset) {
|
||||
return new Uri.Builder().scheme("content").authority(AUTHORITY).path(asset).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
String path = uri.getPath();
|
||||
if (path == null || path.length() <= 1) {
|
||||
throw new FileNotFoundException("no asset in " + uri);
|
||||
}
|
||||
String asset = path.substring(1);
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
throw new FileNotFoundException("provider has no context");
|
||||
}
|
||||
File cached = new File(context.getCacheDir(), asset.replace('/', '-'));
|
||||
if (!cached.isFile()) {
|
||||
copyAsset(context, asset, cached);
|
||||
}
|
||||
return ParcelFileDescriptor.open(cached, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
private static void copyAsset(Context context, String asset, File target) throws FileNotFoundException {
|
||||
File staging = null;
|
||||
try {
|
||||
context.getCacheDir().mkdirs();
|
||||
staging = File.createTempFile("fixture-", null, context.getCacheDir());
|
||||
try (InputStream source = context.getAssets().open(asset);
|
||||
OutputStream sink = new FileOutputStream(staging)) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
for (int read = source.read(buffer); read != -1; read = source.read(buffer)) {
|
||||
sink.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
// Publish atomically so a concurrent open never sees a short file.
|
||||
if (!staging.renameTo(target) && !target.isFile()) {
|
||||
throw new IOException("could not publish " + target);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
FileNotFoundException failure = new FileNotFoundException("could not stage asset " + asset);
|
||||
failure.initCause(e);
|
||||
throw failure;
|
||||
} finally {
|
||||
if (staging != null && staging.isFile()) {
|
||||
staging.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return "video/x-matroska";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Asserts the demuxer's JNI byte source stays reachable under R8.
|
||||
*
|
||||
* `ffmpeg_demuxer_jni.cc` resolves [FfmpegDemuxerJni.Input] by name — `FindClass`
|
||||
* on the interface, then `GetMethodID` for `position`, `read`, `readAt` and
|
||||
* `length`. Nothing in Kotlin calls those methods, so R8 may rename or remove
|
||||
* them while every debug check still passes; the AVIO callbacks would then fail
|
||||
* on the first read and every container would fall back to media3's extractors.
|
||||
* That is the same failure shape as #1703, which shipped.
|
||||
*
|
||||
* Run against the `minified` build type (`-Pplezy.testBuildType=minified`); on an
|
||||
* unminified variant it can only ever pass. Deliberately touches no media3 API,
|
||||
* so the only keep rules it depends on are the ones under test.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FfmpegDemuxerReachabilityTest {
|
||||
|
||||
@Test
|
||||
fun avioCallbacksResolveAndServeBothSequentialAndIndexReads() {
|
||||
assertTrue("ffmpeg demuxer JNI library is unavailable", FfmpegDemuxerJni.available)
|
||||
|
||||
val fixture = copyFixture()
|
||||
val input = FileInput(fixture)
|
||||
try {
|
||||
// nativeOpen drives the whole handshake: FindClass on Input, GetMethodID
|
||||
// for all four methods, then avio reads served back through them. A
|
||||
// shrunk or renamed member makes this fail instead of returning streams.
|
||||
val code = FfmpegDemuxerJni.nativeOpen(input)
|
||||
|
||||
assertEquals("nativeOpen failed (${input.lastError ?: "no java error"})", 0, code)
|
||||
assertTrue("demuxer reported no streams", FfmpegDemuxerJni.nativeStreamCount() > 0)
|
||||
assertTrue("position was never called", input.positionCalls > 0)
|
||||
|
||||
// Header parsing is sequential, so it only proves read()/position()/length().
|
||||
// readAt() is the member nothing in the app calls except this interface's
|
||||
// own implementation, and it is what an index seek runs on.
|
||||
val durationUs = FfmpegDemuxerJni.nativeDurationUs()
|
||||
assertTrue("fixture duration unknown", durationUs > 0)
|
||||
val seek = FfmpegDemuxerJni.nativeSeek(durationUs / 2)
|
||||
|
||||
assertTrue("nativeSeek failed with $seek (${input.lastError ?: "no java error"})", seek >= 0)
|
||||
assertTrue("readAt was never called for the index read", input.readAtCalls > 0)
|
||||
} finally {
|
||||
FfmpegDemuxerJni.nativeClose()
|
||||
input.close()
|
||||
fixture.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFixture(): File {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val cacheDir = instrumentation.targetContext.cacheDir.also { it.mkdirs() }
|
||||
val output = File.createTempFile("ffmpeg-reachability-", ".mkv", cacheDir)
|
||||
instrumentation.context.assets.open(FIXTURE).use { source ->
|
||||
output.outputStream().use(source::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/** Byte source with no media3 involvement, so R8 has nothing else to keep. */
|
||||
private class FileInput(file: File) : FfmpegDemuxerJni.Input {
|
||||
private val handle = RandomAccessFile(file, "r")
|
||||
private val total = file.length()
|
||||
|
||||
var readAtCalls = 0
|
||||
private set
|
||||
|
||||
var positionCalls = 0
|
||||
private set
|
||||
|
||||
override var lastError: String? = null
|
||||
|
||||
override fun position(): Long {
|
||||
positionCalls++
|
||||
return handle.filePointer
|
||||
}
|
||||
|
||||
override fun read(buf: ByteArray, length: Int): Int {
|
||||
val read = handle.read(buf, 0, length)
|
||||
return if (read < 0) 0 else read
|
||||
}
|
||||
|
||||
override fun readAt(position: Long, buf: ByteArray, length: Int): Int {
|
||||
readAtCalls++
|
||||
val restore = handle.filePointer
|
||||
return try {
|
||||
if (position >= total) return 0
|
||||
handle.seek(position)
|
||||
val read = handle.read(buf, 0, length)
|
||||
if (read < 0) 0 else read
|
||||
} finally {
|
||||
handle.seek(restore)
|
||||
}
|
||||
}
|
||||
|
||||
override fun length(): Long = total
|
||||
|
||||
fun close() = handle.close()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The cued fixture from [FfmpegExtractorSeekTest]: a SeekHead plus Cues at the end. */
|
||||
const val FIXTURE = "ffmpeg/seek_cued.mkv"
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* End-to-end regression coverage for #2088: audio demuxed by FfmpegExtractor
|
||||
* must reach MediaCodec with the per-codec csd shapes media3's own extractors
|
||||
* emit. With raw ffmpeg extradata as the only csd buffer, Android's Opus
|
||||
* decoders consume the first two real packets as codec delay / seek pre-roll
|
||||
* and then silently discard every decoded sample — the player never leaves
|
||||
* STATE_BUFFERING, which this test observes as a playback timeout.
|
||||
*
|
||||
* The extractors factory contains only FfmpegExtractor, so a fixture that
|
||||
* falls back to media3's extractors fails instead of passing vacuously.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FfmpegExtractorAudioCsdPlaybackTest {
|
||||
|
||||
@Test
|
||||
fun opusPlaysToEnd() {
|
||||
playToEnd("ffmpeg/stereo_opus.mka")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vorbisPlaysToEnd() {
|
||||
playToEnd("ffmpeg/stereo_vorbis.mka")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flacPlaysToEnd() {
|
||||
playToEnd("ffmpeg/stereo.flac")
|
||||
}
|
||||
|
||||
private fun playToEnd(fixture: String) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
|
||||
val playbackThread = HandlerThread("ffmpeg-csd-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val completed = CountDownLatch(1)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val fixtureUri = Uri.fromFile(fixtureFile)
|
||||
val ffmpegOnlyExtractors = ExtractorsFactory {
|
||||
val extractor = FfmpegExtractor.create(
|
||||
{ FfmpegDemuxerPolicy.Preference.FFMPEG },
|
||||
DvConversionMode.DISABLED,
|
||||
DefaultSubtitleParserFactory(),
|
||||
AssHandler(),
|
||||
FfmpegRandomAccessSource(DefaultDataSource.Factory(context)) { DataSpec(fixtureUri) }
|
||||
)
|
||||
assertNotNull("FFmpeg demuxer JNI library is unavailable", extractor)
|
||||
arrayOf(extractor!!)
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, PlezyRenderersFactory(context))
|
||||
.setLooper(playbackThread.looper)
|
||||
.build()
|
||||
playerReference.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
})
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
ffmpegOnlyExtractors
|
||||
).createMediaSource(MediaItem.fromUri(fixtureUri))
|
||||
player.setMediaSource(source)
|
||||
player.prepare()
|
||||
player.play()
|
||||
} catch (error: Throwable) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = completed.await(20, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerReference.get()?.release()
|
||||
playbackThread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
playbackThread.join(5_000)
|
||||
val fixtureDeleted = fixtureFile.delete()
|
||||
|
||||
assertTrue("Player teardown timed out for $fixture", teardownFinished)
|
||||
assertTrue("Playback timed out for $fixture", finished)
|
||||
assertNull("Playback failed for $fixture", errorReference.get())
|
||||
assertTrue("Fixture cleanup failed for $fixture", fixtureDeleted)
|
||||
}
|
||||
|
||||
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
|
||||
val output = File.createTempFile("ffmpeg-csd-fixture-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
-357
@@ -1,357 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Regression coverage for #2096 and for the seam that caused it.
|
||||
*
|
||||
* libavformat resolves seeks through its own index, reading it via
|
||||
* [FfmpegRandomAccessSource]. When that read cannot be served, matroskadec
|
||||
* burns its one-shot Cues parse and every later seek degrades to libavformat's
|
||||
* linear generic scan — which is what buffered a resume for minutes. The cued
|
||||
* fixture here has the same geometry as the file from the report (front
|
||||
* SeekHead, Cues at 99.5% of the file), so a scan is trivially distinguishable
|
||||
* from an index seek: it has to read most of the file.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FfmpegExtractorSeekTest {
|
||||
|
||||
private companion object {
|
||||
// Regenerate with:
|
||||
// ffmpeg -f lavfi -i "testsrc=size=160x120:rate=10:duration=30" \
|
||||
// -c:v libx264 -g 10 -pix_fmt yuv420p -preset veryfast seek_cued.mkv
|
||||
// mkvmerge --no-cues -o seek_cueless.mkv seek_cued.mkv
|
||||
// The cued fixture keeps ffmpeg's default layout: a SeekHead at byte 52
|
||||
// pointing at Cues written after the clusters, at 99.5% of the file.
|
||||
const val CUED = "ffmpeg/seek_cued.mkv"
|
||||
const val CUELESS = "ffmpeg/seek_cueless.mkv"
|
||||
|
||||
/** The fixtures are 30 s at 10 fps with a keyframe every second. */
|
||||
const val TARGET_US = 25_000_000L
|
||||
const val KEYFRAME_TOLERANCE_US = 1_500_000L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cuedSeekLandsOnTheTargetWithoutWalkingTheFile() {
|
||||
withHarness(CUED) { harness ->
|
||||
harness.pumpUntilSampleDelivered()
|
||||
assertTrue(
|
||||
"playback should start at the beginning, got ${harness.lastSampleUs}us",
|
||||
harness.lastSampleUs < 500_000L
|
||||
)
|
||||
|
||||
harness.resetByteCounter()
|
||||
harness.seekTo(TARGET_US)
|
||||
val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US)
|
||||
|
||||
assertTrue("no sample delivered after seeking to ${TARGET_US}us", landed)
|
||||
assertTrue(
|
||||
"seek landed at ${harness.lastSampleUs}us, expected within " +
|
||||
"${KEYFRAME_TOLERANCE_US}us of ${TARGET_US}us",
|
||||
harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US
|
||||
)
|
||||
// An index seek reads the Cues plus the target cluster. A generic scan
|
||||
// has to read everything up to 83% of the file to get here.
|
||||
val budget = harness.fixtureSize / 3
|
||||
assertTrue(
|
||||
"seek read ${harness.bytesRead} bytes of a ${harness.fixtureSize}-byte file; " +
|
||||
"expected under $budget (index seek, not a scan)",
|
||||
harness.bytesRead < budget
|
||||
)
|
||||
// Reopening per read would mean one request per 64 KB in steady state.
|
||||
assertTrue("random-access opens after seek: ${harness.randomAccessOpens}", harness.randomAccessOpens <= 4)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Without Cues libavformat falls back to its generic scan, exactly as media3's
|
||||
* own MatroskaExtractor scans clusters. That is slow but it must still land on
|
||||
* the target rather than stalling or silently delivering from the wrong place.
|
||||
*/
|
||||
@Test
|
||||
fun cuelessSeekStillReachesTheTarget() {
|
||||
withHarness(CUELESS) { harness ->
|
||||
harness.pumpUntilSampleDelivered()
|
||||
harness.seekTo(TARGET_US)
|
||||
val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US)
|
||||
|
||||
assertTrue("cueless seek to ${TARGET_US}us delivered nothing", landed)
|
||||
assertTrue(
|
||||
"cueless seek landed at ${harness.lastSampleUs}us",
|
||||
harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A download stored through SAF arrives as `content://`, which media3 reads
|
||||
* with `openAssetFileDescriptor`. The demuxer's index reads therefore have to
|
||||
* open a second descriptor on the same document while the loader still holds
|
||||
* one — the case a `file://` fixture cannot prove.
|
||||
*/
|
||||
@Test
|
||||
fun cuedSeekLandsOnTheTargetThroughAContentUri() {
|
||||
withHarness(CUED, contentUri = true) { harness ->
|
||||
harness.pumpUntilSampleDelivered()
|
||||
|
||||
harness.resetByteCounter()
|
||||
harness.seekTo(TARGET_US)
|
||||
val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US)
|
||||
|
||||
assertTrue("no sample delivered after a content:// seek to ${TARGET_US}us", landed)
|
||||
assertTrue(
|
||||
"content:// seek landed at ${harness.lastSampleUs}us",
|
||||
harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US
|
||||
)
|
||||
assertTrue(
|
||||
"content:// index read opened no descriptor of its own",
|
||||
harness.randomAccessOpens > 0
|
||||
)
|
||||
val budget = harness.fixtureSize / 3
|
||||
assertTrue(
|
||||
"content:// seek read ${harness.bytesRead} bytes of a ${harness.fixtureSize}-byte file; " +
|
||||
"expected under $budget (index seek, not a scan)",
|
||||
harness.bytesRead < budget
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playbackFromTheStartDeliversEverySampleFromTheBeginning() {
|
||||
withHarness(CUED) { harness ->
|
||||
val timestamps = harness.pumpSamples(count = 40)
|
||||
|
||||
assertTrue("expected samples from the start, got ${timestamps.size}", timestamps.size >= 40)
|
||||
assertTrue("first sample at ${timestamps.first()}us", timestamps.first() < 500_000L)
|
||||
// Delivery is in decode order, which is not presentation order once the
|
||||
// encoder emits B-frames; what must hold is that the set of samples is
|
||||
// gapless from zero (this fixture is a steady 10 fps).
|
||||
val sorted = timestamps.sorted()
|
||||
assertEquals(sorted.indices.map { it * 100_000L }, sorted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun withHarness(fixture: String, contentUri: Boolean = false, block: (Harness) -> Unit) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val file = copyFixture(instrumentation.context, context, fixture)
|
||||
// The content:// case reads the same asset back through the provider, which
|
||||
// keeps its own copy: this process cannot write into the test APK's dirs.
|
||||
val uri = if (contentUri) FixtureContentProvider.uriFor(fixture) else Uri.fromFile(file)
|
||||
val harness = Harness(context, file, uri)
|
||||
try {
|
||||
harness.prepare()
|
||||
block(harness)
|
||||
} finally {
|
||||
harness.close()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
|
||||
val cacheDir = targetContext.cacheDir.also { it.mkdirs() }
|
||||
val output = File.createTempFile("ffmpeg-seek-fixture-", ".mkv", cacheDir)
|
||||
instrumentationContext.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal stand-in for ProgressiveMediaPeriod's loader: it opens the data
|
||||
* source, feeds an ExtractorInput, and honours RESULT_SEEK by re-opening —
|
||||
* the same contract media3 implements in ExtractingLoadable.load().
|
||||
*/
|
||||
private class Harness(context: Context, fixture: File, private val uri: Uri) {
|
||||
val fixtureSize = fixture.length()
|
||||
private val counting = CountingDataSourceFactory(DefaultDataSource.Factory(context))
|
||||
private val io = FfmpegRandomAccessSource(counting) { DataSpec(uri) }
|
||||
private val output = CapturingOutput()
|
||||
private val positionHolder = PositionHolder()
|
||||
private val extractor = requireNotNull(
|
||||
FfmpegExtractor.create(
|
||||
{ FfmpegDemuxerPolicy.Preference.FFMPEG },
|
||||
DvConversionMode.DISABLED,
|
||||
DefaultSubtitleParserFactory(),
|
||||
AssHandler(),
|
||||
io
|
||||
)
|
||||
) { "native ffmpeg demuxer unavailable" }
|
||||
|
||||
private var dataSource: DataSource? = null
|
||||
private var input: ExtractorInput? = null
|
||||
private var opensBaseline = 0
|
||||
|
||||
val lastSampleUs: Long
|
||||
get() = output.lastSampleUs
|
||||
|
||||
val bytesRead: Long
|
||||
get() = counting.bytesRead
|
||||
|
||||
val randomAccessOpens: Int
|
||||
get() = io.openCount - opensBaseline
|
||||
|
||||
fun prepare() {
|
||||
openAt(0)
|
||||
assertTrue("fixture should be sniffed by the ffmpeg demuxer", extractor.sniff(input!!))
|
||||
input!!.resetPeekPosition()
|
||||
extractor.init(output)
|
||||
}
|
||||
|
||||
fun resetByteCounter() {
|
||||
counting.reset()
|
||||
opensBaseline = io.openCount
|
||||
}
|
||||
|
||||
fun seekTo(timeUs: Long) {
|
||||
// media3 restarts the load at the SeekMap's byte hint, then calls seek()
|
||||
// on the loader thread before the next read.
|
||||
val hint = output.seekMap?.getSeekPoints(timeUs)?.first?.position ?: 0L
|
||||
openAt(hint)
|
||||
extractor.seek(hint, timeUs)
|
||||
}
|
||||
|
||||
fun pumpUntilSampleDelivered(): Boolean {
|
||||
val before = output.sampleCount
|
||||
return pump { output.sampleCount > before }
|
||||
}
|
||||
|
||||
fun pumpUntilSampleAtLeast(timeUs: Long): Boolean = pump { output.lastSampleUs >= timeUs }
|
||||
|
||||
fun pumpSamples(count: Int): List<Long> {
|
||||
pump { output.sampleTimestamps.size >= count }
|
||||
return output.sampleTimestamps.toList()
|
||||
}
|
||||
|
||||
private fun pump(until: () -> Boolean): Boolean {
|
||||
repeat(200_000) {
|
||||
if (until()) return true
|
||||
when (extractor.read(input!!, positionHolder)) {
|
||||
Extractor.RESULT_SEEK -> openAt(positionHolder.position)
|
||||
Extractor.RESULT_END_OF_INPUT -> return until()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return until()
|
||||
}
|
||||
|
||||
private fun openAt(position: Long) {
|
||||
dataSource?.close()
|
||||
val source = counting.createDataSource()
|
||||
val remaining = source.open(DataSpec.Builder().setUri(uri).setPosition(position).build())
|
||||
dataSource = source
|
||||
input = DefaultExtractorInput(
|
||||
source,
|
||||
position,
|
||||
if (remaining == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else position + remaining
|
||||
)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
extractor.release()
|
||||
dataSource?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private class CapturingOutput : ExtractorOutput {
|
||||
val sampleTimestamps = mutableListOf<Long>()
|
||||
var seekMap: SeekMap? = null
|
||||
private set
|
||||
|
||||
val sampleCount: Int
|
||||
get() = sampleTimestamps.size
|
||||
|
||||
val lastSampleUs: Long
|
||||
get() = sampleTimestamps.lastOrNull() ?: C.TIME_UNSET
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = CapturingTrack(type, sampleTimestamps)
|
||||
|
||||
override fun endTracks() = Unit
|
||||
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
this.seekMap = seekMap
|
||||
}
|
||||
}
|
||||
|
||||
private class CapturingTrack(private val type: Int, private val timestamps: MutableList<Long>) : TrackOutput {
|
||||
private val scratch = ByteArray(64 * 1024)
|
||||
|
||||
override fun format(format: Format) = Unit
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean): Int = sampleData(input, length, allowEndOfInput, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
|
||||
val read = input.read(scratch, 0, minOf(length, scratch.size))
|
||||
return if (read == C.RESULT_END_OF_INPUT) 0 else read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int) {
|
||||
sampleData(data, length, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
data.skipBytes(length)
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
if (type == C.TRACK_TYPE_VIDEO) timestamps.add(timeUs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts every byte the demuxer pulls, through either read path. */
|
||||
private class CountingDataSourceFactory(private val delegate: DataSource.Factory) : DataSource.Factory {
|
||||
@Volatile private var counter = 0L
|
||||
|
||||
val bytesRead: Long
|
||||
get() = counter
|
||||
|
||||
fun reset() {
|
||||
counter = 0L
|
||||
}
|
||||
|
||||
override fun createDataSource(): DataSource = Counting(delegate.createDataSource())
|
||||
|
||||
private inner class Counting(private val inner: DataSource) : DataSource {
|
||||
override fun open(dataSpec: DataSpec): Long = inner.open(dataSpec)
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
val read = inner.read(buffer, offset, length)
|
||||
if (read > 0) counter += read
|
||||
return read
|
||||
}
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener)
|
||||
|
||||
override fun getUri(): Uri? = inner.uri
|
||||
|
||||
override fun getResponseHeaders(): Map<String, List<String>> = inner.responseHeaders
|
||||
|
||||
override fun close() = inner.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
|
||||
*
|
||||
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
|
||||
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
|
||||
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
|
||||
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
|
||||
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
|
||||
* be retired. The second test drives the production wrapper stack and requires the seek to
|
||||
* actually land.
|
||||
*/
|
||||
class MatroskaLateTracksSeekDeviceTest {
|
||||
private companion object {
|
||||
const val TAG = "MkvLateTracksSeek"
|
||||
const val SEEK_TARGET_MS = 500L
|
||||
const val SETTLE_MS = 2_000L
|
||||
}
|
||||
|
||||
private class Session(
|
||||
val handler: Handler,
|
||||
val thread: HandlerThread,
|
||||
val player: AtomicReference<ExoPlayer?>,
|
||||
val fixture: File
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stockMatroskaExtractorSnapsSeeksToStart() {
|
||||
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
|
||||
assertFalse(
|
||||
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
|
||||
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
|
||||
result.seekable
|
||||
)
|
||||
assertTrue(
|
||||
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrappedExtractorKeepsSeekPosition() {
|
||||
val result = runSeekScenario("wrapped") {
|
||||
val assHandler = AssHandler()
|
||||
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
|
||||
}
|
||||
assertTrue("wrapped extractor must report the item seekable", result.seekable)
|
||||
assertTrue(
|
||||
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
|
||||
|
||||
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
|
||||
val session = openPaused(label, extractorFactory)
|
||||
try {
|
||||
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
|
||||
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
|
||||
// The masked position is the seek target; the snap surfaces once the period resolves the
|
||||
// seek, so give the load a moment before sampling the settled position.
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
|
||||
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
|
||||
return ScenarioResult(seekable, positionAfterSeekMs)
|
||||
} finally {
|
||||
teardown(session)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixture = copyFixture(context)
|
||||
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
val ready = CountDownLatch(1)
|
||||
val failed = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
val exo = ExoPlayer.Builder(context).build()
|
||||
player.set(exo)
|
||||
exo.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) ready.countDown()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Log.e(TAG, "$label player error", error)
|
||||
failed.set(true)
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
ExtractorsFactory { arrayOf(extractorFactory()) }
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.playWhenReady = false
|
||||
exo.prepare()
|
||||
}
|
||||
|
||||
val session = Session(handler, thread, player, fixture)
|
||||
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
|
||||
teardown(session)
|
||||
error("$label playback never became ready")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
|
||||
val value = AtomicReference<T>()
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.let { value.set(read(it)) }
|
||||
done.countDown()
|
||||
}
|
||||
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
|
||||
return checkNotNull(value.get())
|
||||
}
|
||||
|
||||
private fun teardown(session: Session) {
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.release()
|
||||
done.countDown()
|
||||
}
|
||||
done.await(10, TimeUnit.SECONDS)
|
||||
session.thread.quitSafely()
|
||||
session.thread.join(5_000)
|
||||
session.fixture.delete()
|
||||
}
|
||||
|
||||
private fun copyFixture(context: android.content.Context): File {
|
||||
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("ffmpeg/matroska_tracks_at_end.mkv")
|
||||
.use { input -> output.outputStream().use { input.copyTo(it) } }
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -6,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"/>
|
||||
|
||||
|
||||
@@ -19,22 +19,20 @@ else()
|
||||
target_link_libraries(dovi_bridge log)
|
||||
endif()
|
||||
|
||||
foreach(ffmpeg_library avcodec avformat avutil swresample)
|
||||
foreach(ffmpeg_library avcodec avutil swresample)
|
||||
add_library(${ffmpeg_library} SHARED IMPORTED)
|
||||
set_target_properties(${ffmpeg_library} PROPERTIES
|
||||
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
|
||||
endforeach()
|
||||
|
||||
add_library(ffmpegJNI SHARED
|
||||
media3_ffmpeg_decoder/ffmpeg_jni.cc
|
||||
media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc)
|
||||
media3_ffmpeg_decoder/ffmpeg_jni.cc)
|
||||
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
|
||||
target_include_directories(ffmpegJNI PRIVATE
|
||||
"${MPV_FFMPEG_ROOT}/include"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
|
||||
target_link_libraries(ffmpegJNI
|
||||
avcodec
|
||||
avformat
|
||||
avutil
|
||||
swresample
|
||||
android
|
||||
|
||||
@@ -1,952 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Plezy
|
||||
*
|
||||
* libavformat demuxing for media3. Wraps an AVFormatContext whose AVIOContext
|
||||
* is served by a media3 ExtractorInput, so FFmpeg parses the container while
|
||||
* media3 keeps owning decoders, renderers, and the whole playback surface.
|
||||
*
|
||||
* The seam used to be seeking. media3's Extractor is a pull API: an extractor
|
||||
* cannot move the input; it returns RESULT_SEEK and the loader re-opens the
|
||||
* data source. libavformat expects random access and seeks whenever it pleases
|
||||
* (MP4 moov at end of file, AVI idx1, MKV Cues, MPEG-PS binary search), so
|
||||
* serving it from the loader alone meant unwinding the in-flight libavformat
|
||||
* call on every backward jump and replaying the whole open — which a
|
||||
* one-shot lazy parse like matroska's Cues cannot survive (#2096).
|
||||
*
|
||||
* libavformat now gets what it actually requires: the Kotlin side hands this
|
||||
* shim a random-access byte source (FfmpegRandomAccessSource), so every AVIO
|
||||
* read succeeds at the position libavformat asked for and nothing is ever
|
||||
* unwound. Two paths serve reads, chosen per read:
|
||||
*
|
||||
* - The loader's ExtractorInput when it already stands at that position. This
|
||||
* is the steady state for sample delivery, and it keeps media3's byte
|
||||
* accounting, back-pressure and load-error policy in charge of streaming.
|
||||
* - The random-access source otherwise (header, index, post-seek jumps).
|
||||
*
|
||||
* Kotlin nudges the loader back into step after a jump by returning
|
||||
* RESULT_SEEK to nativeLogicalPosition(); that is an optimization, never a
|
||||
* correctness requirement, so no budgets or retries guard it.
|
||||
*/
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
#ifdef __cplusplus
|
||||
#define __STDC_CONSTANT_MACROS
|
||||
#ifdef _STDINT_H
|
||||
#undef _STDINT_H
|
||||
#endif
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavcodec/bsf.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/dict.h>
|
||||
#include <libavutil/display.h>
|
||||
#include <libavutil/dovi_meta.h>
|
||||
#include <libavutil/error.h>
|
||||
#include <libavutil/rational.h>
|
||||
}
|
||||
|
||||
#define LOG_TAG "ffmpeg_demuxer"
|
||||
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
|
||||
|
||||
namespace {
|
||||
|
||||
// readPacket() result codes written to out[0]. Negative values are raw
|
||||
// AVERROR codes; ERR_JAVA signals the byte source threw and Kotlin must
|
||||
// rethrow the stored IOException.
|
||||
const jint CODE_PACKET = 0;
|
||||
const jint CODE_EOF = 1;
|
||||
const jint CODE_GROW = 2;
|
||||
const jint ERR_NOT_OPEN = -101;
|
||||
const jint ERR_JAVA = -102;
|
||||
|
||||
// streamInfo() long[] layout. Keep in sync with FfmpegDemuxerJni.kt.
|
||||
const int INFO_TRACK_TYPE = 0; // 0 video, 1 audio, 2 text, -1 unsupported
|
||||
const int INFO_WIDTH = 1;
|
||||
const int INFO_HEIGHT = 2;
|
||||
const int INFO_PAR_NUM = 3;
|
||||
const int INFO_PAR_DEN = 4;
|
||||
const int INFO_FPS_NUM = 5;
|
||||
const int INFO_FPS_DEN = 6;
|
||||
const int INFO_SAMPLE_RATE = 7;
|
||||
const int INFO_CHANNELS = 8;
|
||||
const int INFO_PCM_ENCODING = 9; // media3 C.ENCODING_* value, -1 when n/a
|
||||
const int INFO_ROTATION = 10;
|
||||
const int INFO_SELECTION_FLAGS = 11;
|
||||
const int INFO_ROLE_FLAGS = 12;
|
||||
const int INFO_BITRATE = 13;
|
||||
const int INFO_DOVI_PROFILE = 14; // DV profile from dvcC/dvvC side data, -1 when none
|
||||
const int INFO_DOVI_LEVEL = 15;
|
||||
const int INFO_LATM = 16; // 1 when the audio stream is LOAS/LATM-framed AAC
|
||||
const int INFO_LENGTH = 17;
|
||||
|
||||
// Android AudioFormat encoding constants (media3 C.ENCODING_* mirror them).
|
||||
const jint PCM_ENCODING_NONE = -1;
|
||||
const jint PCM_ENCODING_8BIT = 3;
|
||||
const jint PCM_ENCODING_16BIT = 2;
|
||||
const jint PCM_ENCODING_FLOAT = 4;
|
||||
const jint PCM_ENCODING_24BIT_PACKED = 21;
|
||||
const jint PCM_ENCODING_32BIT = 22;
|
||||
const jint PCM_ENCODING_ALAW = 11;
|
||||
const jint PCM_ENCODING_ULAW = 12;
|
||||
|
||||
// media3 C.SELECTION_FLAG_* and C.ROLE_FLAG_* values.
|
||||
const jint SELECTION_FLAG_DEFAULT = 1;
|
||||
const jint SELECTION_FLAG_FORCED = 2;
|
||||
const jint ROLE_FLAG_MAIN = 1;
|
||||
const jint ROLE_FLAG_DESCRIPTION = 64; // C.ROLE_FLAG_DESCRIPTION
|
||||
const jint ROLE_FLAG_HARD_OF_HEARING = 512; // C.ROLE_FLAG_HARD_OF_HEARING
|
||||
|
||||
constexpr jint kReadChunkBytes = 64 * 1024;
|
||||
|
||||
struct DemuxState {
|
||||
JavaVM* vm = nullptr;
|
||||
jobject input = nullptr;
|
||||
jmethodID midPosition = nullptr;
|
||||
jmethodID midRead = nullptr;
|
||||
jmethodID midReadAt = nullptr;
|
||||
jmethodID midLength = nullptr;
|
||||
jbyteArray readBuffer = nullptr;
|
||||
|
||||
AVFormatContext* format = nullptr;
|
||||
AVIOContext* avio = nullptr;
|
||||
bool opened = false;
|
||||
|
||||
// Byte position libavformat will read next. Authoritative: the AVIO seek
|
||||
// callback moves it, and both read paths serve exactly this offset.
|
||||
int64_t logicalPos = 0;
|
||||
// format->start_time in microseconds; subtracted from delivered pts so
|
||||
// media3's zero-based timeline matches the duration we report (MPEG-PS/TS
|
||||
// containers commonly start at a nonzero PCR).
|
||||
int64_t startTimeUs = 0;
|
||||
|
||||
AVPacket* packet = nullptr;
|
||||
AVPacket* filteredPacket = nullptr;
|
||||
// A packet that outgrew the Java-side buffer: already read and filtered,
|
||||
// waiting for redelivery after CODE_GROW. Without this flag the retry's
|
||||
// av_read_frame would silently drop it.
|
||||
bool packetPending = false;
|
||||
|
||||
// Annex-B conversion for H264/HEVC streams stored length-prefixed
|
||||
// (matroska/mp4 family); one context per stream, null when none needed.
|
||||
AVBSFContext** bsf = nullptr;
|
||||
unsigned nbStreams = 0;
|
||||
|
||||
bool javaError = false;
|
||||
};
|
||||
|
||||
// One demuxer at a time: a player session owns exactly one progressive source
|
||||
// and the extractor releases its state before another can open.
|
||||
DemuxState* gState = nullptr;
|
||||
JavaVM* gVm = nullptr;
|
||||
|
||||
// Serializes JNI entry points against closeState: the fallback path can
|
||||
// release the player while the loader thread is inside a native call, and
|
||||
// deleting DemuxState under it is a use-after-free (observed as a scudo
|
||||
// invalid-chunk abort during MPV fallback teardown).
|
||||
pthread_mutex_t gStateMutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
struct StateLock {
|
||||
StateLock() { pthread_mutex_lock(&gStateMutex); }
|
||||
~StateLock() { pthread_mutex_unlock(&gStateMutex); }
|
||||
};
|
||||
|
||||
JNIEnv* envFor() {
|
||||
JNIEnv* env = nullptr;
|
||||
if (gVm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) return nullptr;
|
||||
return env;
|
||||
}
|
||||
|
||||
bool javaPending(JNIEnv* env) { return env->ExceptionCheck() == JNI_TRUE; }
|
||||
|
||||
void javaClear(JNIEnv* env) {
|
||||
if (env->ExceptionCheck() == JNI_TRUE) env->ExceptionClear();
|
||||
}
|
||||
|
||||
jlong callPosition(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midPosition); }
|
||||
|
||||
jlong callLength(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midLength); }
|
||||
|
||||
// Returns bytes read, 0 at end of input, or -1 when the input failed (the
|
||||
// Kotlin proxy stored the IOException message).
|
||||
jint callRead(JNIEnv* env, DemuxState* s, jint length) {
|
||||
jint n = env->CallIntMethod(s->input, s->midRead, s->readBuffer, length);
|
||||
if (javaPending(env)) {
|
||||
javaClear(env);
|
||||
s->javaError = true;
|
||||
return -1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Same contract as callRead, but at an absolute position: the Kotlin source
|
||||
// reopens its handle when the position is not where it stands.
|
||||
jint callReadAt(JNIEnv* env, DemuxState* s, int64_t position, jint length) {
|
||||
jint n = env->CallIntMethod(s->input, s->midReadAt, static_cast<jlong>(position), s->readBuffer, length);
|
||||
if (javaPending(env)) {
|
||||
javaClear(env);
|
||||
s->javaError = true;
|
||||
return -1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
int avioReadPacket(void* opaque, uint8_t* buf, int bufSize) {
|
||||
DemuxState* s = static_cast<DemuxState*>(opaque);
|
||||
JNIEnv* env = envFor();
|
||||
if (env == nullptr) return AVERROR(EIO);
|
||||
if (bufSize <= 0) return 0;
|
||||
|
||||
const jint want = bufSize < kReadChunkBytes ? bufSize : kReadChunkBytes;
|
||||
// Prefer the loader's input when it already stands here: those bytes then
|
||||
// count as media3's own load progress, which is what drives its buffering
|
||||
// and back-pressure. Anywhere else, read at the absolute position.
|
||||
const int64_t inputPos = callPosition(env, s);
|
||||
if (javaPending(env)) {
|
||||
javaClear(env);
|
||||
s->javaError = true;
|
||||
return AVERROR(EIO);
|
||||
}
|
||||
const jint n = inputPos == s->logicalPos ? callRead(env, s, want) : callReadAt(env, s, s->logicalPos, want);
|
||||
if (s->javaError) return AVERROR(EIO);
|
||||
if (n < 0) return AVERROR(EIO);
|
||||
if (n == 0) return AVERROR_EOF;
|
||||
|
||||
env->GetByteArrayRegion(s->readBuffer, 0, n, reinterpret_cast<jbyte*>(buf));
|
||||
s->logicalPos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
int64_t avioSeek(void* opaque, int64_t offset, int whence) {
|
||||
DemuxState* s = static_cast<DemuxState*>(opaque);
|
||||
JNIEnv* env = envFor();
|
||||
if (env == nullptr) return AVERROR(EIO);
|
||||
|
||||
whence &= ~AVSEEK_FORCE;
|
||||
if (whence == AVSEEK_SIZE) {
|
||||
int64_t length = callLength(env, s);
|
||||
if (javaPending(env)) {
|
||||
javaClear(env);
|
||||
s->javaError = true;
|
||||
return -1;
|
||||
}
|
||||
return length < 0 ? -1 : length;
|
||||
}
|
||||
|
||||
int64_t target;
|
||||
if (whence == SEEK_CUR) {
|
||||
target = s->logicalPos + offset;
|
||||
} else if (whence == SEEK_END) {
|
||||
int64_t length = callLength(env, s);
|
||||
if (javaPending(env)) {
|
||||
javaClear(env);
|
||||
s->javaError = true;
|
||||
return AVERROR(EIO);
|
||||
}
|
||||
if (length < 0) return AVERROR(EIO);
|
||||
target = length + offset;
|
||||
} else {
|
||||
target = offset;
|
||||
}
|
||||
|
||||
// Pure bookkeeping: the next read serves this position from whichever path
|
||||
// can, so a seek can no longer fail or need reconciling. avio invalidates
|
||||
// its own buffers after this successful callback.
|
||||
s->logicalPos = target;
|
||||
return target;
|
||||
}
|
||||
|
||||
// Probing (ffio_ensure_seekback) can swap the AVIO buffer for a larger
|
||||
// allocation and free the original, so the pointer handed to
|
||||
// avio_alloc_context may be stale. Free whichever buffer the context
|
||||
// currently holds, exactly once.
|
||||
void freeAvio(DemuxState* s) {
|
||||
if (s->avio == nullptr) return;
|
||||
av_freep(&s->avio->buffer);
|
||||
avio_context_free(&s->avio);
|
||||
}
|
||||
|
||||
void closeState() {
|
||||
DemuxState* s = gState;
|
||||
if (s == nullptr) return; // Close without (or after) an open is a no-op.
|
||||
JNIEnv* env = envFor();
|
||||
if (s->filteredPacket != nullptr) {
|
||||
av_packet_free(&s->filteredPacket);
|
||||
}
|
||||
if (s->bsf != nullptr) {
|
||||
for (unsigned i = 0; i < s->nbStreams; i++) {
|
||||
if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]);
|
||||
}
|
||||
av_freep(&s->bsf);
|
||||
}
|
||||
if (s->packet != nullptr) {
|
||||
av_packet_free(&s->packet);
|
||||
}
|
||||
if (s->format != nullptr) {
|
||||
avformat_close_input(&s->format);
|
||||
}
|
||||
freeAvio(s);
|
||||
if (env != nullptr && s->input != nullptr) {
|
||||
if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer);
|
||||
env->DeleteGlobalRef(s->input);
|
||||
}
|
||||
delete s;
|
||||
gState = nullptr;
|
||||
}
|
||||
|
||||
const char* videoMime(AVCodecID id) {
|
||||
switch (id) {
|
||||
case AV_CODEC_ID_H264:
|
||||
return "video/avc";
|
||||
case AV_CODEC_ID_HEVC:
|
||||
return "video/hevc";
|
||||
case AV_CODEC_ID_MPEG4:
|
||||
return "video/mp4v-es";
|
||||
case AV_CODEC_ID_MPEG2VIDEO:
|
||||
return "video/mpeg2";
|
||||
case AV_CODEC_ID_MJPEG:
|
||||
return "video/mjpeg";
|
||||
case AV_CODEC_ID_VP8:
|
||||
return "video/x-vnd.on2.vp8";
|
||||
case AV_CODEC_ID_VP9:
|
||||
return "video/x-vnd.on2.vp9";
|
||||
case AV_CODEC_ID_AV1:
|
||||
return "video/av01";
|
||||
case AV_CODEC_ID_H263:
|
||||
return "video/3gpp";
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const char* audioMime(AVCodecID id, jint* pcmEncoding) {
|
||||
*pcmEncoding = PCM_ENCODING_NONE;
|
||||
switch (id) {
|
||||
case AV_CODEC_ID_AAC:
|
||||
return "audio/mp4a-latm";
|
||||
case AV_CODEC_ID_AAC_LATM:
|
||||
// LOAS/LATM framing. Kotlin wraps the track with LatmTrackOutput, which
|
||||
// unwraps to raw AAC access units and emits the real decoder Format;
|
||||
// this mime only marks the stream as supported.
|
||||
return "audio/mp4a-latm";
|
||||
case AV_CODEC_ID_MP3:
|
||||
return "audio/mpeg";
|
||||
case AV_CODEC_ID_MP2:
|
||||
return "audio/mpeg-L2";
|
||||
case AV_CODEC_ID_AC3:
|
||||
return "audio/ac3";
|
||||
case AV_CODEC_ID_EAC3:
|
||||
return "audio/eac3";
|
||||
case AV_CODEC_ID_DTS:
|
||||
return "audio/vnd.dts";
|
||||
case AV_CODEC_ID_TRUEHD:
|
||||
return "audio/true-hd";
|
||||
case AV_CODEC_ID_FLAC:
|
||||
return "audio/flac";
|
||||
case AV_CODEC_ID_OPUS:
|
||||
return "audio/opus";
|
||||
case AV_CODEC_ID_VORBIS:
|
||||
return "audio/vorbis";
|
||||
case AV_CODEC_ID_ALAC:
|
||||
return "audio/alac";
|
||||
case AV_CODEC_ID_AMR_NB:
|
||||
return "audio/amr";
|
||||
case AV_CODEC_ID_AMR_WB:
|
||||
return "audio/amr-wb";
|
||||
case AV_CODEC_ID_PCM_S16LE:
|
||||
*pcmEncoding = PCM_ENCODING_16BIT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_S16BE:
|
||||
*pcmEncoding = PCM_ENCODING_16BIT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_S8:
|
||||
*pcmEncoding = PCM_ENCODING_8BIT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_U8:
|
||||
*pcmEncoding = PCM_ENCODING_8BIT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_S24LE:
|
||||
*pcmEncoding = PCM_ENCODING_24BIT_PACKED;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_S32LE:
|
||||
*pcmEncoding = PCM_ENCODING_32BIT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_F32LE:
|
||||
*pcmEncoding = PCM_ENCODING_FLOAT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_F32BE:
|
||||
*pcmEncoding = PCM_ENCODING_FLOAT;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_ALAW:
|
||||
*pcmEncoding = PCM_ENCODING_ALAW;
|
||||
return "audio/raw";
|
||||
case AV_CODEC_ID_PCM_MULAW:
|
||||
*pcmEncoding = PCM_ENCODING_ULAW;
|
||||
return "audio/raw";
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const char* subtitleMime(AVCodecID id) {
|
||||
switch (id) {
|
||||
case AV_CODEC_ID_SUBRIP:
|
||||
return "application/x-subrip";
|
||||
case AV_CODEC_ID_ASS:
|
||||
case AV_CODEC_ID_SSA:
|
||||
return "text/x-ssa";
|
||||
case AV_CODEC_ID_WEBVTT:
|
||||
return "text/vtt";
|
||||
case AV_CODEC_ID_HDMV_PGS_SUBTITLE:
|
||||
return "application/pgs";
|
||||
case AV_CODEC_ID_DVD_SUBTITLE:
|
||||
// media3's VobsubParser takes the .idx text (Matroska CodecPrivate,
|
||||
// surfaced verbatim as extradata) as initialization data and the raw
|
||||
// SPU payloads as samples — exactly what this path delivers.
|
||||
return "application/vobsub";
|
||||
default:
|
||||
// DVB subtitles are deliberately dropped: media3's DvbParser expects
|
||||
// the TS/PMT 5-byte config, not Matroska CodecPrivate, and DVB rides
|
||||
// TS in practice — which stays on media3's extractor.
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps an attachment ordinal to its stream index. Attachment payloads
|
||||
// (embedded fonts) ride extradata and are fully parsed during
|
||||
// avformat_open_input; these streams never produce packets.
|
||||
int attachmentStreamIndex(AVFormatContext* format, jint ordinal) {
|
||||
jint seen = 0;
|
||||
for (unsigned i = 0; i < format->nb_streams; i++) {
|
||||
AVCodecParameters* params = format->streams[i]->codecpar;
|
||||
if (params->codec_type != AVMEDIA_TYPE_ATTACHMENT) continue;
|
||||
if (params->extradata == nullptr || params->extradata_size <= 0) continue;
|
||||
if (seen++ == ordinal) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeProbeFormat(JNIEnv* env, jobject thiz, jbyteArray header) {
|
||||
StateLock lock;
|
||||
jsize size = env->GetArrayLength(header);
|
||||
std::vector<uint8_t> padded(static_cast<size_t>(size) + AVPROBE_PADDING_SIZE, 0);
|
||||
env->GetByteArrayRegion(header, 0, size, reinterpret_cast<jbyte*>(padded.data()));
|
||||
|
||||
AVProbeData probe{};
|
||||
probe.buf = padded.data();
|
||||
probe.buf_size = size;
|
||||
probe.filename = "";
|
||||
int score = 0;
|
||||
const AVInputFormat* format = av_probe_input_format2(&probe, 1, &score);
|
||||
if (format == nullptr || score <= 0) return nullptr;
|
||||
return env->NewStringUTF(format->name);
|
||||
}
|
||||
|
||||
// Returns 0 on success or a negative AVERROR (ERR_JAVA when the byte source
|
||||
// itself threw). The stream count is NOT the return value: positive counts
|
||||
// would share the result-code namespace, so callers use nativeStreamCount().
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeOpen(JNIEnv* env, jobject thiz, jobject input) {
|
||||
StateLock lock;
|
||||
if (gVm == nullptr) {
|
||||
env->GetJavaVM(&gVm);
|
||||
av_log_set_level(AV_LOG_ERROR);
|
||||
}
|
||||
if (gState == nullptr) {
|
||||
gState = new DemuxState();
|
||||
gState->vm = gVm;
|
||||
}
|
||||
DemuxState* s = gState;
|
||||
|
||||
// FindClass on the declared interface (not GetObjectClass on the concrete
|
||||
// proxy): JNI dispatches interface method IDs virtually, and the named
|
||||
// class is what the shrinker keep in proguard-rules.pro pins.
|
||||
jclass inputClass = env->FindClass("com/edde746/plezy/exoplayer/FfmpegDemuxerJni$Input");
|
||||
if (inputClass == nullptr) {
|
||||
javaClear(env);
|
||||
return ERR_NOT_OPEN;
|
||||
}
|
||||
jmethodID midPosition = env->GetMethodID(inputClass, "position", "()J");
|
||||
jmethodID midRead = env->GetMethodID(inputClass, "read", "([BI)I");
|
||||
jmethodID midReadAt = env->GetMethodID(inputClass, "readAt", "(J[BI)I");
|
||||
jmethodID midLength = env->GetMethodID(inputClass, "length", "()J");
|
||||
env->DeleteLocalRef(inputClass);
|
||||
if (midPosition == nullptr || midRead == nullptr || midReadAt == nullptr || midLength == nullptr) {
|
||||
javaClear(env);
|
||||
return ERR_NOT_OPEN;
|
||||
}
|
||||
|
||||
// A fresh open replaces any previous proxy binding.
|
||||
if (s->input != nullptr) {
|
||||
if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer);
|
||||
env->DeleteGlobalRef(s->input);
|
||||
s->input = nullptr;
|
||||
s->readBuffer = nullptr;
|
||||
}
|
||||
s->input = env->NewGlobalRef(input);
|
||||
s->midPosition = midPosition;
|
||||
s->midRead = midRead;
|
||||
s->midReadAt = midReadAt;
|
||||
s->midLength = midLength;
|
||||
jbyteArray readBuffer = env->NewByteArray(kReadChunkBytes);
|
||||
s->readBuffer = static_cast<jbyteArray>(env->NewGlobalRef(readBuffer));
|
||||
env->DeleteLocalRef(readBuffer);
|
||||
|
||||
if (s->packet == nullptr) {
|
||||
s->packet = av_packet_alloc();
|
||||
s->filteredPacket = av_packet_alloc();
|
||||
if (s->packet == nullptr || s->filteredPacket == nullptr) return AVERROR(ENOMEM);
|
||||
}
|
||||
|
||||
// One AVIOContext per item: a stale buffer or a sticky error from the
|
||||
// previous item must not leak into this open, and reallocating is cheaper
|
||||
// than reasoning about which of libavformat's internal fields to reset.
|
||||
freeAvio(s);
|
||||
constexpr int kAvioBufferSize = 64 * 1024;
|
||||
unsigned char* avioBuffer = static_cast<unsigned char*>(av_malloc(kAvioBufferSize));
|
||||
if (avioBuffer == nullptr) return AVERROR(ENOMEM);
|
||||
s->avio = avio_alloc_context(
|
||||
avioBuffer, kAvioBufferSize, /*write_flag=*/0, s, avioReadPacket,
|
||||
/*write_packet=*/nullptr, avioSeek);
|
||||
if (s->avio == nullptr) {
|
||||
av_freep(&avioBuffer);
|
||||
return AVERROR(ENOMEM);
|
||||
}
|
||||
|
||||
s->javaError = false;
|
||||
s->packetPending = false;
|
||||
s->logicalPos = 0;
|
||||
// Nothing is ever resumed across calls now: every read succeeds at the
|
||||
// position libavformat asked for, so open_input and find_stream_info each
|
||||
// run once, to completion.
|
||||
avformat_close_input(&s->format);
|
||||
s->opened = false;
|
||||
|
||||
s->format = avformat_alloc_context();
|
||||
if (s->format == nullptr) return AVERROR(ENOMEM);
|
||||
s->format->pb = s->avio;
|
||||
// Generate missing PTS: some raw streams (MPEG-TS after a seek) present
|
||||
// packets without timestamps that media3 cannot schedule.
|
||||
s->format->flags |= AVFMT_FLAG_GENPTS;
|
||||
|
||||
int err = avformat_open_input(&s->format, "", nullptr, nullptr);
|
||||
if (s->javaError) return ERR_JAVA;
|
||||
if (err < 0) {
|
||||
LOGW("avformat_open_input failed: %d", err);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = avformat_find_stream_info(s->format, nullptr);
|
||||
if (s->javaError) return ERR_JAVA;
|
||||
if (err < 0) {
|
||||
LOGW("avformat_find_stream_info failed: %d", err);
|
||||
return err;
|
||||
}
|
||||
// Zero-base the timeline: delivered pts subtract this so media3 sees
|
||||
// [0, duration] even when the container starts at a nonzero PCR/PTS.
|
||||
s->startTimeUs = s->format->start_time != AV_NOPTS_VALUE ? s->format->start_time : 0;
|
||||
s->opened = true;
|
||||
|
||||
bool anySupported = false;
|
||||
for (unsigned i = 0; i < s->format->nb_streams; i++) {
|
||||
AVStream* stream = s->format->streams[i];
|
||||
AVCodecID codecId = stream->codecpar->codec_id;
|
||||
jint pcmEncoding;
|
||||
bool supported = videoMime(codecId) != nullptr || audioMime(codecId, &pcmEncoding) != nullptr ||
|
||||
subtitleMime(codecId) != nullptr;
|
||||
// ID3/MKV attached pictures surface as video streams; media3 cannot
|
||||
// render them as tracks.
|
||||
if (stream->attached_pic.size > 0) supported = false;
|
||||
if (!supported) {
|
||||
LOGW("Dropping stream %u: unsupported codec %s", i, avcodec_get_name(codecId));
|
||||
stream->discard = AVDISCARD_ALL;
|
||||
} else {
|
||||
anySupported = true;
|
||||
}
|
||||
}
|
||||
if (!anySupported) {
|
||||
// Fail fast so the session falls back (mpv decodes WMV and friends)
|
||||
// instead of buffering an empty track list forever.
|
||||
LOGW("No supported streams; refusing container");
|
||||
avformat_close_input(&s->format);
|
||||
s->opened = false;
|
||||
return AVERROR(ENOSYS);
|
||||
}
|
||||
// MediaCodec requires Annex-B start-code framing for H264/HEVC, while the
|
||||
// matroska/mp4 family stores length-prefixed NAL units and libavformat
|
||||
// hands those through verbatim (ffmpeg muxers insert the same
|
||||
// *_mp4toannexb bitstream filters in this situation). Filter those streams
|
||||
// here so every packet leaving the shim is decoder-ready, and adopt the
|
||||
// filter's rewritten parameters so extradata surfaces as Annex-B csd too.
|
||||
if (s->bsf != nullptr) {
|
||||
for (unsigned i = 0; i < s->nbStreams; i++) {
|
||||
if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]);
|
||||
}
|
||||
av_freep(&s->bsf);
|
||||
}
|
||||
s->nbStreams = s->format->nb_streams;
|
||||
s->bsf = static_cast<AVBSFContext**>(av_calloc(s->nbStreams, sizeof(*s->bsf)));
|
||||
if (s->bsf == nullptr) return AVERROR(ENOMEM);
|
||||
for (unsigned i = 0; i < s->nbStreams; i++) {
|
||||
AVCodecID codecId = s->format->streams[i]->codecpar->codec_id;
|
||||
const char* filterName = codecId == AV_CODEC_ID_H264 ? "h264_mp4toannexb"
|
||||
: codecId == AV_CODEC_ID_HEVC ? "hevc_mp4toannexb"
|
||||
// AVI-family MPEG-4 ASP commonly packs several VOPs into one chunk;
|
||||
// hardware decoders silently drop those stacks (observed as a decoder
|
||||
// hang with zero output), so unpack them like every ffmpeg pipeline.
|
||||
: codecId == AV_CODEC_ID_MPEG4 ? "mpeg4_unpack_bframes"
|
||||
: nullptr;
|
||||
const AVBitStreamFilter* filter = av_bsf_get_by_name(filterName);
|
||||
AVBSFContext* ctx = nullptr;
|
||||
if (filter == nullptr || av_bsf_alloc(filter, &ctx) < 0 ||
|
||||
avcodec_parameters_copy(ctx->par_in, s->format->streams[i]->codecpar) < 0) {
|
||||
if (ctx != nullptr) av_bsf_free(&ctx);
|
||||
continue;
|
||||
}
|
||||
ctx->time_base_in = s->format->streams[i]->time_base;
|
||||
if (av_bsf_init(ctx) < 0) {
|
||||
av_bsf_free(&ctx);
|
||||
continue;
|
||||
}
|
||||
avcodec_parameters_copy(s->format->streams[i]->codecpar, ctx->par_out);
|
||||
s->format->streams[i]->time_base = ctx->time_base_out;
|
||||
s->bsf[i] = ctx;
|
||||
}
|
||||
|
||||
return 0; // Success; prepareTracks() reads the count via nativeStreamCount().
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeClose(JNIEnv* env, jobject thiz) {
|
||||
StateLock lock;
|
||||
closeState();
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamCount(JNIEnv* env, jobject thiz) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr) return ERR_NOT_OPEN;
|
||||
return static_cast<jint>(gState->format->nb_streams);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeDurationUs(JNIEnv* env, jobject thiz) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr) return -1;
|
||||
int64_t duration = gState->format->duration;
|
||||
return duration == AV_NOPTS_VALUE ? -1 : duration;
|
||||
}
|
||||
|
||||
// Returns true when the stream is usable and filled the out arrays.
|
||||
JNIEXPORT jboolean JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamInfo(
|
||||
JNIEnv* env, jobject thiz, jint index, jlongArray numbers, jobjectArray strings) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr || index < 0 ||
|
||||
index >= static_cast<jint>(gState->format->nb_streams)) {
|
||||
return JNI_FALSE;
|
||||
}
|
||||
AVStream* stream = gState->format->streams[index];
|
||||
AVCodecParameters* params = stream->codecpar;
|
||||
|
||||
jint trackType;
|
||||
const char* mime = nullptr;
|
||||
jint pcmEncoding = PCM_ENCODING_NONE;
|
||||
switch (params->codec_type) {
|
||||
case AVMEDIA_TYPE_VIDEO:
|
||||
trackType = 0;
|
||||
mime = videoMime(params->codec_id);
|
||||
break;
|
||||
case AVMEDIA_TYPE_AUDIO:
|
||||
trackType = 1;
|
||||
mime = audioMime(params->codec_id, &pcmEncoding);
|
||||
break;
|
||||
case AVMEDIA_TYPE_SUBTITLE:
|
||||
trackType = 2;
|
||||
mime = subtitleMime(params->codec_id);
|
||||
break;
|
||||
default:
|
||||
return JNI_FALSE;
|
||||
}
|
||||
if (mime == nullptr) return JNI_FALSE;
|
||||
|
||||
jlong info[INFO_LENGTH] = {};
|
||||
info[INFO_TRACK_TYPE] = trackType;
|
||||
info[INFO_WIDTH] = params->width;
|
||||
info[INFO_HEIGHT] = params->height;
|
||||
AVRational par = params->sample_aspect_ratio;
|
||||
if (par.num <= 0 || par.den <= 0) par = stream->sample_aspect_ratio;
|
||||
info[INFO_PAR_NUM] = par.num > 0 ? par.num : 1;
|
||||
info[INFO_PAR_DEN] = par.num > 0 ? par.den : 1;
|
||||
AVRational fps = stream->avg_frame_rate;
|
||||
info[INFO_FPS_NUM] = fps.num > 0 ? fps.num : 0;
|
||||
info[INFO_FPS_DEN] = fps.num > 0 ? fps.den : 0;
|
||||
info[INFO_SAMPLE_RATE] = params->sample_rate > 0 ? params->sample_rate : -1;
|
||||
info[INFO_CHANNELS] = params->ch_layout.nb_channels > 0 ? params->ch_layout.nb_channels : -1;
|
||||
info[INFO_PCM_ENCODING] = pcmEncoding;
|
||||
info[INFO_LATM] = params->codec_id == AV_CODEC_ID_AAC_LATM ? 1 : 0;
|
||||
|
||||
jint rotation = 0;
|
||||
AVPacketSideData* sideData = nullptr;
|
||||
for (int i = 0; i < params->nb_coded_side_data; i++) {
|
||||
if (params->coded_side_data[i].type == AV_PKT_DATA_DISPLAYMATRIX) {
|
||||
sideData = ¶ms->coded_side_data[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sideData != nullptr) {
|
||||
double angle = av_display_rotation_get(reinterpret_cast<const int32_t*>(sideData->data));
|
||||
if (!std::isnan(angle)) rotation = static_cast<jint>(-lround(angle));
|
||||
}
|
||||
info[INFO_ROTATION] = rotation;
|
||||
|
||||
jint selectionFlags = 0;
|
||||
jint roleFlags = 0;
|
||||
if (stream->disposition & AV_DISPOSITION_DEFAULT) selectionFlags |= SELECTION_FLAG_DEFAULT;
|
||||
if (stream->disposition & AV_DISPOSITION_FORCED) selectionFlags |= SELECTION_FLAG_FORCED;
|
||||
if (stream->disposition & AV_DISPOSITION_HEARING_IMPAIRED) roleFlags |= ROLE_FLAG_HARD_OF_HEARING;
|
||||
if (stream->disposition & AV_DISPOSITION_VISUAL_IMPAIRED) roleFlags |= ROLE_FLAG_DESCRIPTION;
|
||||
if (selectionFlags == 0 && trackType != 2) roleFlags |= ROLE_FLAG_MAIN;
|
||||
info[INFO_SELECTION_FLAGS] = selectionFlags;
|
||||
info[INFO_ROLE_FLAGS] = roleFlags;
|
||||
info[INFO_BITRATE] = params->bit_rate > 0 ? params->bit_rate : -1;
|
||||
|
||||
// Dolby Vision configuration (dvcC/dvvC), surfaced by libavformat as an
|
||||
// unpacked AVDOVIDecoderConfigurationRecord — one byte per field, not the
|
||||
// bit-packed ISOBMFF box layout. Kotlin mirrors media3's DolbyVisionConfig
|
||||
// to publish video/dolby-vision plus the RFC 6381 codecs string.
|
||||
info[INFO_DOVI_PROFILE] = -1;
|
||||
info[INFO_DOVI_LEVEL] = -1;
|
||||
for (int i = 0; i < params->nb_coded_side_data; i++) {
|
||||
AVPacketSideData* sd = ¶ms->coded_side_data[i];
|
||||
if (sd->type == AV_PKT_DATA_DOVI_CONF && sd->size >= sizeof(AVDOVIDecoderConfigurationRecord)) {
|
||||
const AVDOVIDecoderConfigurationRecord* dovi =
|
||||
reinterpret_cast<const AVDOVIDecoderConfigurationRecord*>(sd->data);
|
||||
info[INFO_DOVI_PROFILE] = dovi->dv_profile;
|
||||
info[INFO_DOVI_LEVEL] = dovi->dv_level;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
env->SetLongArrayRegion(numbers, 0, INFO_LENGTH, info);
|
||||
|
||||
const char* language = nullptr;
|
||||
const char* label = nullptr;
|
||||
if (stream->metadata != nullptr) {
|
||||
AVDictionaryEntry* entry = av_dict_get(stream->metadata, "language", nullptr, 0);
|
||||
if (entry != nullptr) language = entry->value;
|
||||
entry = av_dict_get(stream->metadata, "title", nullptr, 0);
|
||||
if (entry != nullptr) label = entry->value;
|
||||
}
|
||||
env->SetObjectArrayElement(strings, 0, env->NewStringUTF(mime));
|
||||
env->SetObjectArrayElement(strings, 1, language != nullptr ? env->NewStringUTF(language) : nullptr);
|
||||
env->SetObjectArrayElement(strings, 2, label != nullptr ? env->NewStringUTF(label) : nullptr);
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamExtradata(JNIEnv* env, jobject thiz, jint index) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr || index < 0 ||
|
||||
index >= static_cast<jint>(gState->format->nb_streams)) {
|
||||
return nullptr;
|
||||
}
|
||||
AVCodecParameters* params = gState->format->streams[index]->codecpar;
|
||||
// libavformat carries the AVI stream's WAVEFORMATEX header as MP3
|
||||
// extradata. MediaCodec mp3 decoders expect either no csd or the two-byte
|
||||
// ISO header, and report a WAVEFORMATEX blob as corrupt input
|
||||
// (C2_CORRUPTED on the first work item), so drop it.
|
||||
if (params->codec_id == AV_CODEC_ID_MP3) return nullptr;
|
||||
if (params->extradata == nullptr || params->extradata_size <= 0) return nullptr;
|
||||
jbyteArray result = env->NewByteArray(params->extradata_size);
|
||||
env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast<const jbyte*>(params->extradata));
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentCount(JNIEnv* env, jobject thiz) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr) return 0;
|
||||
jint count = 0;
|
||||
while (attachmentStreamIndex(gState->format, count) >= 0) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// Fills strings with [filename, mimetype] and returns the payload size in
|
||||
// bytes, or -1 when the ordinal is out of range.
|
||||
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentInfo(
|
||||
JNIEnv* env, jobject thiz, jint ordinal, jobjectArray strings) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr) return -1;
|
||||
int index = attachmentStreamIndex(gState->format, ordinal);
|
||||
if (index < 0) return -1;
|
||||
AVStream* stream = gState->format->streams[index];
|
||||
const char* filename = nullptr;
|
||||
const char* mimetype = nullptr;
|
||||
if (stream->metadata != nullptr) {
|
||||
AVDictionaryEntry* entry = av_dict_get(stream->metadata, "filename", nullptr, 0);
|
||||
if (entry != nullptr) filename = entry->value;
|
||||
entry = av_dict_get(stream->metadata, "mimetype", nullptr, 0);
|
||||
if (entry != nullptr) mimetype = entry->value;
|
||||
}
|
||||
env->SetObjectArrayElement(strings, 0, filename != nullptr ? env->NewStringUTF(filename) : nullptr);
|
||||
env->SetObjectArrayElement(strings, 1, mimetype != nullptr ? env->NewStringUTF(mimetype) : nullptr);
|
||||
return stream->codecpar->extradata_size;
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentData(JNIEnv* env, jobject thiz, jint ordinal) {
|
||||
StateLock lock;
|
||||
if (gState == nullptr || gState->format == nullptr) return nullptr;
|
||||
int index = attachmentStreamIndex(gState->format, ordinal);
|
||||
if (index < 0) return nullptr;
|
||||
AVCodecParameters* params = gState->format->streams[index]->codecpar;
|
||||
jbyteArray result = env->NewByteArray(params->extradata_size);
|
||||
if (result == nullptr) return nullptr;
|
||||
env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast<const jbyte*>(params->extradata));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Reads one packet. out[0] carries the result code; on CODE_PACKET the packet
|
||||
// bytes were written into the byte array and out carries
|
||||
// [1]=streamIndex [2]=ptsUs (start-time normalized; packets without any
|
||||
// timestamp are dropped, media3 cannot schedule them) [3]=flags [4]=size
|
||||
// [5]=packet position in input coordinates [6]=durationUs (-1 unknown).
|
||||
JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeReadPacket(
|
||||
JNIEnv* env, jobject thiz, jbyteArray buffer, jlongArray out) {
|
||||
StateLock lock;
|
||||
DemuxState* s = gState;
|
||||
if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
|
||||
|
||||
jint capacity = env->GetArrayLength(buffer);
|
||||
jlong result[7] = {};
|
||||
s->javaError = false;
|
||||
while (true) {
|
||||
if (s->packetPending) {
|
||||
// Oversized packet held across a CODE_GROW round trip: deliver it now
|
||||
// instead of reading (and losing) a new frame.
|
||||
s->packetPending = false;
|
||||
} else {
|
||||
av_packet_unref(s->packet);
|
||||
int err = av_read_frame(s->format, s->packet);
|
||||
if (err == AVERROR_EOF) {
|
||||
result[0] = CODE_EOF;
|
||||
env->SetLongArrayRegion(out, 0, 7, result);
|
||||
return CODE_EOF;
|
||||
}
|
||||
if (err < 0 || s->javaError) {
|
||||
result[0] = s->javaError ? ERR_JAVA : err;
|
||||
env->SetLongArrayRegion(out, 0, 7, result);
|
||||
return s->javaError ? ERR_JAVA : err;
|
||||
}
|
||||
if (s->packet->size <= 0) continue;
|
||||
|
||||
if (s->format->streams[s->packet->stream_index]->discard >= AVDISCARD_ALL) continue;
|
||||
|
||||
AVBSFContext* bsf = s->bsf != nullptr && s->packet->stream_index < static_cast<int>(s->nbStreams)
|
||||
? s->bsf[s->packet->stream_index]
|
||||
: nullptr;
|
||||
if (bsf != nullptr) {
|
||||
// send consumes the packet reference; move the filtered result back so
|
||||
// the delivery code below keeps a single code path.
|
||||
int filterErr = av_bsf_send_packet(bsf, s->packet);
|
||||
if (filterErr >= 0) {
|
||||
av_packet_unref(s->packet);
|
||||
filterErr = av_bsf_receive_packet(bsf, s->filteredPacket);
|
||||
if (filterErr == AVERROR(EAGAIN)) continue;
|
||||
if (filterErr < 0) {
|
||||
result[0] = filterErr;
|
||||
env->SetLongArrayRegion(out, 0, 7, result);
|
||||
return filterErr;
|
||||
}
|
||||
av_packet_move_ref(s->packet, s->filteredPacket);
|
||||
} else {
|
||||
result[0] = filterErr;
|
||||
env->SetLongArrayRegion(out, 0, 7, result);
|
||||
return filterErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
int size = s->packet->size;
|
||||
if (size > capacity) {
|
||||
s->packetPending = true;
|
||||
result[0] = CODE_GROW;
|
||||
result[4] = size;
|
||||
env->SetLongArrayRegion(out, 0, 7, result);
|
||||
return CODE_GROW;
|
||||
}
|
||||
env->SetByteArrayRegion(buffer, 0, size, reinterpret_cast<const jbyte*>(s->packet->data));
|
||||
|
||||
AVStream* stream = s->format->streams[s->packet->stream_index];
|
||||
int64_t ptsUs;
|
||||
if (s->packet->pts != AV_NOPTS_VALUE) {
|
||||
ptsUs = av_rescale_q(s->packet->pts, stream->time_base, AVRational{1, 1000000});
|
||||
} else if (s->packet->dts != AV_NOPTS_VALUE) {
|
||||
ptsUs = av_rescale_q(s->packet->dts, stream->time_base, AVRational{1, 1000000});
|
||||
} else {
|
||||
// No timestamp even after GENPTS: media3 cannot schedule the sample,
|
||||
// and delivering data without metadata would corrupt the sample queue.
|
||||
continue;
|
||||
}
|
||||
ptsUs -= s->startTimeUs;
|
||||
|
||||
result[0] = CODE_PACKET;
|
||||
result[1] = s->packet->stream_index;
|
||||
result[2] = ptsUs;
|
||||
result[3] = (s->packet->flags & AV_PKT_FLAG_KEY) != 0 ? 1 : 0;
|
||||
result[4] = size;
|
||||
result[5] = s->packet->pos >= 0 ? s->packet->pos : 0;
|
||||
result[6] =
|
||||
s->packet->duration > 0 ? av_rescale_q(s->packet->duration, stream->time_base, AVRational{1, 1000000}) : -1;
|
||||
env->SetLongArrayRegion(out, 0, 7, result);
|
||||
return CODE_PACKET;
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeLogicalPosition(JNIEnv* env, jobject thiz) {
|
||||
StateLock lock;
|
||||
DemuxState* s = gState;
|
||||
if (s == nullptr || !s->opened) return -1;
|
||||
return static_cast<jlong>(s->logicalPos);
|
||||
}
|
||||
|
||||
// Seeks to a presentation time (media3 timeline microseconds, start-time
|
||||
// normalized). Called on the loader thread, where blocking IO belongs:
|
||||
// libavformat reads its own index through the random-access source, so this
|
||||
// either lands on the target keyframe or reports that the container has no
|
||||
// usable index.
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeSeek(JNIEnv* env, jobject thiz, jlong timeUs) {
|
||||
StateLock lock;
|
||||
DemuxState* s = gState;
|
||||
if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
|
||||
s->javaError = false;
|
||||
// Any packet parked for a CODE_GROW retry belongs to the pre-seek stream.
|
||||
s->packetPending = false;
|
||||
if (s->bsf != nullptr) {
|
||||
for (unsigned i = 0; i < s->nbStreams; i++) {
|
||||
if (s->bsf[i] != nullptr) av_bsf_flush(s->bsf[i]);
|
||||
}
|
||||
}
|
||||
const int64_t ts = timeUs + s->startTimeUs;
|
||||
const int err = avformat_seek_file(s->format, -1, INT64_MIN, ts, ts, 0);
|
||||
if (s->javaError) return ERR_JAVA;
|
||||
if (err < 0) {
|
||||
LOGW("seek to %lld failed: %d", (long long)ts, err);
|
||||
return err;
|
||||
}
|
||||
LOGD("seek to %lld: logical=%lld", (long long)ts, (long long)s->logicalPos);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -28,6 +28,12 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
private const val API_VLC_RESULT_POSITION = "extra_position"
|
||||
private const val API_VLC_RESULT_DURATION = "extra_duration"
|
||||
|
||||
// Honored by VLC and the native Zidoo player (com.android.gallery3d /
|
||||
// com.zidoo.player). Without it, a launch with no resume point lets the
|
||||
// player consult its own bookmark store, which on Zidoo collides across
|
||||
// Plex items because every part URL ends in the same `file.<ext>` (#2223).
|
||||
private const val API_VLC_FROM_START = "from_start"
|
||||
|
||||
private const val API_VIMU_TITLE = "forcename"
|
||||
private const val API_VIMU_SEEK_POSITION = "startfrom"
|
||||
private const val API_VIMU_RESUME = "forceresume"
|
||||
@@ -150,7 +156,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
internal data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
|
||||
private fun resolveSource(filePath: String): Source {
|
||||
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||
@@ -168,7 +174,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
return Source(uri, grantRead = true, fileName = file.name)
|
||||
}
|
||||
|
||||
private fun buildIntent(
|
||||
internal fun buildIntent(
|
||||
source: Source,
|
||||
packageName: String?,
|
||||
startPositionMs: Long,
|
||||
@@ -181,6 +187,9 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
if (startPosition > 0) {
|
||||
putExtra(API_MX_RESULT_POSITION, startPosition)
|
||||
putExtra(API_VIMU_SEEK_POSITION, startPosition)
|
||||
putExtra(API_VLC_FROM_START, false)
|
||||
} else {
|
||||
putExtra(API_VLC_FROM_START, true)
|
||||
}
|
||||
putExtra(API_MX_RETURN_RESULT, true)
|
||||
putExtra(API_MX_SECURE_URI, true)
|
||||
|
||||
@@ -31,7 +31,9 @@ import com.edde746.plezy.car.CarRestrictionsMonitor
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.shared.AssistiveTechnologyMonitor
|
||||
import com.edde746.plezy.shared.DeviceQuirks
|
||||
import com.edde746.plezy.shared.MediaCodecQuery
|
||||
import com.edde746.plezy.shared.ThemeHelper
|
||||
import com.edde746.plezy.watchnext.WatchNextPlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
@@ -95,9 +97,12 @@ class MainActivity : FlutterActivity() {
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
||||
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
|
||||
private val ASSISTIVE_TECHNOLOGY_CHANNEL = "com.plezy/assistive_technology"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var carRestrictions: CarRestrictionsMonitor? = null
|
||||
private var carRestrictionsChannel: MethodChannel? = null
|
||||
private var assistiveTechnology: AssistiveTechnologyMonitor? = null
|
||||
private var assistiveTechnologyChannel: MethodChannel? = null
|
||||
private var nativeTextInputFocused = false
|
||||
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
|
||||
private var imeShowAttempts = 0
|
||||
@@ -592,6 +597,9 @@ class MainActivity : FlutterActivity() {
|
||||
carRestrictions?.release()
|
||||
carRestrictions = null
|
||||
carRestrictionsChannel = null
|
||||
assistiveTechnology?.release()
|
||||
assistiveTechnology = null
|
||||
assistiveTechnologyChannel = null
|
||||
activityStarted = false
|
||||
flutterSurfaceReconnectPending = false
|
||||
flutterTextureView = null
|
||||
@@ -735,6 +743,7 @@ class MainActivity : FlutterActivity() {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getVideoDecodeCapabilities" -> result.success(MediaCodecQuery.hardwareVideoDecodeSupport())
|
||||
"getBackgroundWorkSignals" -> result.success(
|
||||
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
|
||||
)
|
||||
@@ -779,6 +788,19 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val assistiveChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, ASSISTIVE_TECHNOLOGY_CHANNEL)
|
||||
assistiveTechnologyChannel = assistiveChannel
|
||||
val assistiveMonitor = assistiveTechnology ?: AssistiveTechnologyMonitor(applicationContext).also {
|
||||
assistiveTechnology = it
|
||||
}
|
||||
assistiveMonitor.start { runOnUiThread { assistiveTechnologyChannel?.invokeMethod("onChanged", null) } }
|
||||
assistiveChannel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getSignals" -> result.success(assistiveMonitor.signals())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
handleDeviceAdjustmentCall(call.method, call.arguments, result)
|
||||
}
|
||||
|
||||
@@ -76,58 +76,93 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv `audio-spdif` codec names and the exact platform encoding a route must
|
||||
* advertise to carry that bitstream.
|
||||
*
|
||||
* Only the codecs mpv's audiotrack AO can physically carry are listed. That AO opens every
|
||||
* spdif format as a stereo `ENCODING_IEC61937` track and clamps the track rate to
|
||||
* `getNativeOutputSampleRate` (the 48kHz mixer rate), so E-AC3 (a 192kHz burst), TrueHD
|
||||
* (192kHz/8ch MAT) and DTS-HD MA (192kHz/8ch) can never leave that AO intact. Naming one
|
||||
* anyway force-passes the codec into a track that cannot carry it (#1991); those streams
|
||||
* decode to PCM instead, like every other unnamed codec.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
"ac3" to C.ENCODING_AC3,
|
||||
"dts" to C.ENCODING_DTS
|
||||
/** The IEC 61937 track shape a codec's spdif burst rides. */
|
||||
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
|
||||
|
||||
private class MpvSpdifCodec(
|
||||
val name: String,
|
||||
val encoding: Int,
|
||||
val shape: MpvIecShape,
|
||||
/** Whether the fork's audiotrack AO opens this codec as a raw bitstream track first. */
|
||||
val raw: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
|
||||
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
|
||||
* bitstream, and the track shape the fork's `ao_audiotrack` opens for it.
|
||||
*
|
||||
* Since the raw-passthrough patch (0102), the AO feeds AC3, E-AC3 and the DTS core to a raw
|
||||
* `ENCODING_AC3`/`E_AC3`/`DTS` track at 48kHz/stereo — the transport ExoPlayer and Kodi use —
|
||||
* and only falls back to the IEC 61937 carrier when the route rejects the raw encoding
|
||||
* outright. Raw tracks keep the platform's Dolby/DTS transcoder in the path; a pre-packed IEC
|
||||
* track bypasses it and drains into silence on routes whose sink cannot decode the codec
|
||||
* itself (#2177's Shield in front of a Dolby-Digital-only Sonos). TrueHD (MAT) and DTS-HD MA
|
||||
* stay on the 8-channel 192kHz IEC carrier the multichannel patch (0101) added: their raw
|
||||
* forms are not recoverable from the burst stream.
|
||||
*
|
||||
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
|
||||
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
|
||||
* still chosen per file for tracks that are not HD (`ad_spdif.c:240-249`, `:400-418`). HRA rides
|
||||
* a 2ch/192kHz burst under the same name, so gating it on the 8-channel carrier is the
|
||||
* conservative choice.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
|
||||
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K, raw = true),
|
||||
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
|
||||
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
|
||||
* advertises the codec's encoding *and* a transport the AO's ladder can open exists —
|
||||
* [supportsRawTrack] for the raw bitstream track it tries first, or [supportsShape] for the
|
||||
* IEC 61937 burst it falls back to. Plain `dts` is dropped whenever `dts-hd` qualifies, which
|
||||
* already covers the core burst.
|
||||
*
|
||||
* mpv force-passes through every codec named here and has no decode fallback, so an
|
||||
* unsupported name leaves the file rendering video against a dead audio output (#1703).
|
||||
*
|
||||
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
|
||||
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
|
||||
* channel counts above the route's PCM maximum, neither of which describes what an IEC
|
||||
* 61937 track carries.
|
||||
* channel counts above the route's PCM maximum, neither of which describes what a
|
||||
* passthrough track carries.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
|
||||
.filter { (_, encoding) -> supportsEncoding(encoding) }
|
||||
.joinToString(",") { (codec, _) -> codec }
|
||||
internal fun mpvSpdifCodecs(
|
||||
supportsEncoding: (Int) -> Boolean,
|
||||
supportsShape: (MpvIecShape) -> Boolean,
|
||||
supportsRawTrack: (Int) -> Boolean = { false }
|
||||
): String {
|
||||
val carried = MPV_SPDIF_CODECS.filter {
|
||||
supportsEncoding(it.encoding) &&
|
||||
((it.raw && supportsRawTrack(it.encoding)) || supportsShape(it.shape))
|
||||
}
|
||||
val dtsHd = carried.any { it.name == "dts-hd" }
|
||||
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
|
||||
*
|
||||
* Two conditions, both required:
|
||||
* - The route must accept the exact track shape mpv opens for spdif output — stereo
|
||||
* IEC 61937 at the 48kHz mixer rate ([supportsMpvIecShape]). Advertising the raw AC3/DTS
|
||||
* encodings only says the receiver decodes them, not that the HAL takes an IEC 61937
|
||||
* AudioTrack: #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv
|
||||
* spdif attempt strands playback on a dead audio output, leaving nothing but AAC playable.
|
||||
* Two conditions per codec, both required:
|
||||
* - The route must accept a track shape the AO's ladder actually opens. For AC3, E-AC3 and the
|
||||
* DTS core that is the raw bitstream track probed by [supportsMpvRawTrack], with the IEC
|
||||
* stereo shapes ([supportsMpvIecShape], [supportsMpvHighRateIecShape]) as the AO's fallback
|
||||
* transport; TrueHD and DTS-HD MA need the 192kHz/7.1 carrier ([supportsIecCarrier]).
|
||||
* Advertising the raw encoding only says the receiver decodes it, not that the HAL takes the
|
||||
* track: #1991's Shield strands playback on every mpv IEC attempt while bitstreaming AC3 raw.
|
||||
* The probes are independent, so none of them may veto the whole list: a route that takes the
|
||||
* 192kHz carrier but no raw track still bitstreams TrueHD and DTS-HD MA.
|
||||
* - The receiver must decode the codec itself — the raw encoding on the current
|
||||
* [AudioCapabilities] — because IEC 61937 is transport, not transcoding.
|
||||
* [AudioCapabilities] — because passthrough is transport, not transcoding. (On a raw track
|
||||
* the platform may additionally transcode, which is exactly why the AO prefers it.)
|
||||
*/
|
||||
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
|
||||
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
|
||||
@Suppress("DEPRECATION")
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
if (!supportsMpvIecShape(context)) {
|
||||
Log.i(TAG, "Route takes no stereo IEC 61937 track; mpv will decode instead of bitstreaming")
|
||||
return ""
|
||||
}
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
@@ -138,12 +173,32 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
|
||||
return ""
|
||||
}
|
||||
return mpvSpdifCodecs(capabilities::supportsEncoding)
|
||||
// Every probe costs real route calls and may be shared by more than one codec, so probe once.
|
||||
val shapeProbed = HashMap<MpvIecShape, Boolean>(3)
|
||||
val rawProbed = HashMap<Int, Boolean>(3)
|
||||
val codecs = mpvSpdifCodecs(
|
||||
capabilities::supportsEncoding,
|
||||
{ shape -> shapeProbed.getOrPut(shape) { routeTakesIecShape(context, shape) } },
|
||||
{ encoding -> rawProbed.getOrPut(encoding) { supportsMpvRawTrack(context, encoding) } }
|
||||
)
|
||||
if (codecs.isEmpty()) {
|
||||
Log.i(TAG, "Route takes no passthrough track mpv can fill; mpv will decode instead of bitstreaming")
|
||||
} else {
|
||||
Log.i(TAG, "mpv will bitstream: $codecs")
|
||||
}
|
||||
return codecs
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */
|
||||
private fun routeTakesIecShape(context: Context, shape: MpvIecShape): Boolean = when (shape) {
|
||||
MpvIecShape.STEREO_48K -> supportsMpvIecShape(context)
|
||||
MpvIecShape.STEREO_192K -> supportsMpvHighRateIecShape(context)
|
||||
MpvIecShape.SURROUND_192K -> supportsIecCarrier(context)
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for an AC3 or DTS-core burst: stereo at the mixer rate. */
|
||||
private const val MPV_IEC_SAMPLE_RATE = 48_000
|
||||
private const val MPV_IEC_CHANNEL_COUNT = 2
|
||||
private const val MPV_IEC_HIGH_SAMPLE_RATE = 192_000
|
||||
|
||||
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
@@ -161,6 +216,47 @@ internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the route takes a raw bitstream `AudioTrack` for [encoding] at the shape the fork's
|
||||
* `ao_audiotrack` opens first: the codec frame rate with a stereo mask (Kodi's raw shape; the
|
||||
* HAL reads the real channel layout from the bitstream). Same probe tiering as the IEC shapes,
|
||||
* except below API 29, where no runtime oracle exists for raw tracks and media3 gates its raw
|
||||
* path on the advertised encoding alone — which [supportedMpvSpdifCodecs] already requires via
|
||||
* [AudioCapabilities]. #1991's API 28 Shield bitstreams AC3 exactly this way.
|
||||
*/
|
||||
internal fun supportsMpvRawTrack(context: Context, encoding: Int): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeDirectBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, encoding) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
|
||||
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
|
||||
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_HIGH_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
|
||||
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
|
||||
@@ -226,8 +322,10 @@ internal fun iecRouteSupported(
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
|
||||
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
@@ -272,8 +370,10 @@ private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCou
|
||||
}
|
||||
|
||||
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
|
||||
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(encoding)
|
||||
.setChannelMask(channelMask)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.SeekPoint
|
||||
import androidx.media3.extractor.TrackAwareSeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
|
||||
*
|
||||
* Two repairs, in priority order:
|
||||
* - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a
|
||||
* proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking.
|
||||
* - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska
|
||||
* seek map while parsing Cues, which for files whose Tracks element follows the Clusters is
|
||||
* before any track is known — the primary seek track stays unset even though the Cues parsed):
|
||||
* wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on
|
||||
* this output.
|
||||
*/
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
class CuelessSeekExtractorWrapper(
|
||||
private val delegate: Extractor
|
||||
) : Extractor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CuelessSeek"
|
||||
|
||||
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
|
||||
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
|
||||
private const val SCAN_BUFFER_SIZE = 8192
|
||||
|
||||
// Max bytes to scan for a Cluster boundary before giving up
|
||||
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
|
||||
}
|
||||
|
||||
private var inputLength: Long = C.LENGTH_UNSET.toLong()
|
||||
private var needsClusterResync = false
|
||||
private var isApproximateSeeking = false
|
||||
private var pendingSeekTimeUs: Long = C.TIME_UNSET
|
||||
|
||||
/** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */
|
||||
private val registeredTracks = CopyOnWriteArrayList<Pair<Int, Int>>()
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(SeekInterceptingOutput(output))
|
||||
}
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
if (inputLength == C.LENGTH_UNSET.toLong()) {
|
||||
inputLength = input.length
|
||||
}
|
||||
if (needsClusterResync) {
|
||||
needsClusterResync = false
|
||||
return scanForCluster(input, seekPosition)
|
||||
}
|
||||
return delegate.read(input, seekPosition)
|
||||
}
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
if (isApproximateSeeking && position > 0) {
|
||||
needsClusterResync = true
|
||||
pendingSeekTimeUs = timeUs
|
||||
}
|
||||
delegate.seek(position, timeUs)
|
||||
}
|
||||
|
||||
override fun release() = delegate.release()
|
||||
|
||||
/**
|
||||
* Scan forward from the current input position to find the next MKV Cluster
|
||||
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
|
||||
* byte position so ExoPlayer repositions the DataSource there.
|
||||
*/
|
||||
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
val buffer = ByteArray(SCAN_BUFFER_SIZE)
|
||||
var totalScanned = 0L
|
||||
// Carry over last 3 bytes across buffer boundaries to detect split IDs
|
||||
var carry = ByteArray(0)
|
||||
|
||||
while (totalScanned < MAX_SCAN_BYTES) {
|
||||
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
|
||||
val bytesRead: Int
|
||||
try {
|
||||
bytesRead = input.read(buffer, 0, toRead)
|
||||
} catch (_: Exception) {
|
||||
break
|
||||
}
|
||||
if (bytesRead == C.RESULT_END_OF_INPUT) break
|
||||
|
||||
// Combine carry + new data for scanning
|
||||
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
|
||||
|
||||
for (i in 0..scanData.size - 4) {
|
||||
if (scanData[i] == CLUSTER_ID[0] &&
|
||||
scanData[i + 1] == CLUSTER_ID[1] &&
|
||||
scanData[i + 2] == CLUSTER_ID[2] &&
|
||||
scanData[i + 3] == CLUSTER_ID[3]
|
||||
) {
|
||||
// Compute the absolute byte position of this Cluster
|
||||
val clusterPosition = input.position - bytesRead - carry.size + i
|
||||
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
|
||||
seekPosition.position = clusterPosition
|
||||
delegate.seek(clusterPosition, pendingSeekTimeUs)
|
||||
pendingSeekTimeUs = C.TIME_UNSET
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
}
|
||||
|
||||
// Keep last 3 bytes as carry for next iteration
|
||||
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
|
||||
totalScanned += bytesRead
|
||||
}
|
||||
|
||||
// Failed to find a Cluster — fall back to position 0
|
||||
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
|
||||
seekPosition.position = 0
|
||||
delegate.seek(0, 0)
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
|
||||
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
|
||||
*/
|
||||
private inner class SeekInterceptingOutput(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
if (registeredTracks.none { it.first == id }) {
|
||||
registeredTracks.add(id to type)
|
||||
}
|
||||
return delegate.track(id, type)
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
if (seekMap is SeekMap.Unseekable) {
|
||||
val durationUs = seekMap.durationUs
|
||||
if (durationUs != C.TIME_UNSET && durationUs > 0) {
|
||||
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
|
||||
isApproximateSeeking = true
|
||||
delegate.seekMap(ApproximateSeekMap(durationUs))
|
||||
return
|
||||
}
|
||||
} else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) {
|
||||
Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking")
|
||||
isApproximateSeeking = false
|
||||
delegate.seekMap(TrackCueSeekMap(seekMap))
|
||||
return
|
||||
}
|
||||
// File has real Cues or unknown duration — pass through
|
||||
isApproximateSeeking = false
|
||||
delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate SeekMap that estimates byte positions proportionally.
|
||||
* Used when the MKV has no Cues but has a known duration.
|
||||
*/
|
||||
private inner class ApproximateSeekMap(
|
||||
private val durationUs: Long
|
||||
) : SeekMap {
|
||||
|
||||
override fun isSeekable(): Boolean = true
|
||||
|
||||
override fun getDurationUs(): Long = durationUs
|
||||
|
||||
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
|
||||
val length = inputLength
|
||||
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
|
||||
return SeekMap.SeekPoints(SeekPoint(0, 0))
|
||||
}
|
||||
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
|
||||
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
|
||||
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports
|
||||
* unseekable overall. The per-track queries read the live cue data (populated once the Cues
|
||||
* element parsed), so they resolve correctly even when the map was constructed before the
|
||||
* Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377).
|
||||
*/
|
||||
private inner class TrackCueSeekMap(
|
||||
private val delegate: TrackAwareSeekMap
|
||||
) : SeekMap {
|
||||
|
||||
override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null
|
||||
|
||||
override fun getDurationUs(): Long = delegate.durationUs
|
||||
|
||||
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
|
||||
if (delegate.isSeekable) return delegate.getSeekPoints(timeUs)
|
||||
val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs)
|
||||
return delegate.getSeekPoints(timeUs, trackId)
|
||||
}
|
||||
|
||||
/** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */
|
||||
private fun seekableTrackId(): Int? {
|
||||
var audio: Int? = null
|
||||
var fallback: Int? = null
|
||||
for ((id, type) in registeredTracks) {
|
||||
if (!delegate.isSeekable(id)) continue
|
||||
when (type) {
|
||||
C.TRACK_TYPE_VIDEO -> return id
|
||||
C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id
|
||||
else -> if (fallback == null) fallback = id
|
||||
}
|
||||
}
|
||||
return audio ?: fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,17 @@ object DoviBridge {
|
||||
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device can natively render single-layer Dolby Vision
|
||||
* Profile 5 (IPT-PQ-c2): it needs both a decoder advertising DvheStn and a
|
||||
* Dolby Vision display pipeline. P5 has no compatible base layer, so a
|
||||
* device that fails either check decodes it as plain HEVC with garbage
|
||||
* colors; callers route those sessions to software decode + gpu-next,
|
||||
* where libplacebo applies the RPU reshaping instead.
|
||||
*/
|
||||
fun canPlayDolbyVisionP5(context: Context): Boolean = deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn) &&
|
||||
displaySupportsDolbyVision(context)
|
||||
|
||||
fun displaySupportsDolbyVision(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
|
||||
@@ -145,6 +156,17 @@ object DoviBridge {
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Whether the active display advertises any HDR output type. */
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val display = getCurrentDisplay(context) ?: return false
|
||||
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
|
||||
Log.w(TAG, "Display HDR support: failed to query HDR types", error)
|
||||
return false
|
||||
}
|
||||
return hdrTypes.isNotEmpty()
|
||||
}
|
||||
|
||||
fun describeDisplayHdrCapabilities(context: Context): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
|
||||
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* RFC 6381 codecs strings for Dolby Vision tracks, mirroring media3's
|
||||
* `DolbyVisionConfig.parse` profile table so the FFmpeg demux path publishes
|
||||
* the same format media3's own MP4/Matroska extractors would: recognized
|
||||
* profiles get `video/dolby-vision` plus this string, unrecognized profiles
|
||||
* keep the base codec's format untouched.
|
||||
*/
|
||||
internal object DoviCodecs {
|
||||
|
||||
/**
|
||||
* Codecs string for a DOVIDecoderConfigurationRecord's [profile]/[level],
|
||||
* or null when media3 does not recognize the profile (the track then plays
|
||||
* as its base codec).
|
||||
*/
|
||||
fun rfc6381(profile: Int, level: Int): String? {
|
||||
val prefix = when (profile) {
|
||||
4, 5, 7, 8 -> "dvhe"
|
||||
9 -> "dvav"
|
||||
10 -> "dav1"
|
||||
else -> return null
|
||||
}
|
||||
return "%s.%02d.%02d".format(prefix, profile, level)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,6 @@ import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
|
||||
import androidx.media3.extractor.mp4.Mp4Extractor
|
||||
import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput
|
||||
import androidx.media3.extractor.ts.TsExtractor
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
@@ -72,7 +71,6 @@ import androidx.media3.ui.SubtitleView
|
||||
import com.edde746.plezy.AndroidRuntimeDiagnostics
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
|
||||
import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.DeviceQuirks
|
||||
@@ -563,18 +561,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
@Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null
|
||||
|
||||
// The FFmpeg extractor from the current session; getStats reads its
|
||||
// measured audio bitrate for the playing item.
|
||||
@Volatile private var activeFfmpegExtractor: FfmpegExtractor? = null
|
||||
|
||||
// Container-demuxer placement; read per media item when extractors are built.
|
||||
@Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.FFMPEG
|
||||
|
||||
fun setDemuxerMode(mode: String) {
|
||||
demuxerPreference = FfmpegDemuxerPolicy.fromWire(mode)
|
||||
emitLog("info", "init", "demuxer mode: ${demuxerPreference.wireName}")
|
||||
}
|
||||
|
||||
private fun getConfiguredDvMode(): DvConversionMode {
|
||||
val override = debugDvModeOverride
|
||||
if (override != null) return override
|
||||
@@ -585,17 +571,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
fun initialize(
|
||||
bufferSizeBytes: Int? = null,
|
||||
bufferSizeAuto: Boolean = false,
|
||||
tunnelingEnabled: Boolean = true,
|
||||
audioPassthroughEnabled: Boolean = false,
|
||||
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
|
||||
// is internal and this function is not; unrecognised names resolve to Auto, which is also
|
||||
// the default (#1816).
|
||||
bufferTier: String = "auto",
|
||||
// Whether FFmpeg demuxes ahead of media3's extractors. Wire values from
|
||||
// [FfmpegDemuxerPolicy.Preference]; unrecognised resolves to FFmpeg.
|
||||
demuxerMode: String = "ffmpeg"
|
||||
bufferTier: String = "auto"
|
||||
): Boolean {
|
||||
if (isInitialized) {
|
||||
Log.d(TAG, "Already initialized")
|
||||
@@ -604,7 +585,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
tunnelingUserEnabled = tunnelingEnabled
|
||||
this.audioPassthroughEnabled = audioPassthroughEnabled
|
||||
demuxerPreference = FfmpegDemuxerPolicy.fromWire(demuxerMode)
|
||||
this.dvMode = getConfiguredDvMode()
|
||||
DoviBridge.logSupportSummary(activity)
|
||||
Log.i(
|
||||
@@ -746,20 +726,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
httpDataSourceFactory = httpFactory
|
||||
dataSourceFactory = DefaultDataSource.Factory(activity, httpFactory)
|
||||
// libavformat owns its own seeking, so the demuxer reads through a
|
||||
// random-access source (FfmpegRandomAccessSource). Recording the DataSpec
|
||||
// media3 opens lets it clone that spec — same URI, cache key, flags and
|
||||
// request headers — instead of re-deriving one.
|
||||
val recordingFactory = RecordingDataSourceFactory(dataSourceFactory!!)
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
// High-bitrate Plex DVR MPEG-TS recordings can have sparse PCR packets; the default
|
||||
// 600-packet window may leave duration unknown and seeking disabled.
|
||||
.setTsExtractorTimestampSearchBytes(TS_TIMESTAMP_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE)
|
||||
// Raw subtitle samples from MatroskaExtractor: the external text
|
||||
// pipeline below feeds libass dialogue first and then re-adds cue
|
||||
// transcoding — with the internal wrapper enabled the dialogue bytes
|
||||
// would already be consumed before our wrapper could see them.
|
||||
.setMatroskaExtractorFlags(MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA)
|
||||
|
||||
// Inline buildWithAssSupport to retain AssHandler reference for font scale control.
|
||||
val handler = AssHandler()
|
||||
@@ -769,82 +739,42 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
// composition object and blanks palette-only fade updates (#1953).
|
||||
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
|
||||
|
||||
// Wrap extractors: FFmpeg demuxes the containers media3 is weak at
|
||||
// (primary, before the list) and anything media3 cannot sniff (catch-all,
|
||||
// behind it); give MatroskaExtractor the same libass/transcoding text
|
||||
// pipeline so the media3 fallback (ffmpeg unavailable, media3-only
|
||||
// preference) keeps ASS rendering; wrap MKV/MP4 extractors with the DV
|
||||
// converter when enabled.
|
||||
// Reads this.dvMode and this.demuxerPreference each time (not captured) so
|
||||
// DV7→8.1 retry and demuxer-mode changes can reload without
|
||||
// reinitializing the player.
|
||||
val wrappedExtractorsFactory = object : androidx.media3.extractor.ExtractorsFactory {
|
||||
// media3's progressive path always supplies the item URI. Without one
|
||||
// the demuxer cannot be handed random access, so FFmpeg stays out
|
||||
// rather than failing later on its first index jump.
|
||||
override fun createExtractors(): Array<androidx.media3.extractor.Extractor> {
|
||||
Log.w(TAG, "[init] extractors: no item uri, ffmpeg demuxer unavailable")
|
||||
return buildExtractors(null)
|
||||
}
|
||||
|
||||
override fun createExtractors(
|
||||
uri: Uri,
|
||||
responseHeaders: Map<String, List<String>>
|
||||
): Array<androidx.media3.extractor.Extractor> = buildExtractors(uri)
|
||||
|
||||
private fun buildExtractors(uri: Uri?): Array<androidx.media3.extractor.Extractor> {
|
||||
val currentDvMode = this@ExoPlayerCore.dvMode
|
||||
val doviEnabled = currentDvMode != DvConversionMode.DISABLED
|
||||
val currentDemuxerPreference = this@ExoPlayerCore.demuxerPreference
|
||||
Log.i(TAG, "[init] extractors: demuxer=${currentDemuxerPreference.wireName}")
|
||||
// media3 invokes the factory once per item and reuses the extractor
|
||||
// instances, so the sniff reads the live preference instead of a
|
||||
// captured one.
|
||||
val liveDemuxerPreference = { this@ExoPlayerCore.demuxerPreference }
|
||||
val ffmpegFirst = listOfNotNull(
|
||||
uri?.let { itemUri ->
|
||||
val io = FfmpegRandomAccessSource(dataSourceFactory!!) { recordingFactory.specFor(itemUri) }
|
||||
FfmpegExtractor.create(
|
||||
liveDemuxerPreference,
|
||||
currentDvMode,
|
||||
subtitleParserFactory,
|
||||
handler,
|
||||
io
|
||||
)?.also { activeFfmpegExtractor = it }
|
||||
// Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM
|
||||
// variant, wrap MP4 extractors with the DV converter when enabled.
|
||||
// Reads this.dvMode each time (not captured) so DV7→8.1 retry can
|
||||
// change mode and reload without reinitializing the player.
|
||||
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
|
||||
val currentDvMode = this.dvMode
|
||||
val doviEnabled = currentDvMode != DvConversionMode.DISABLED
|
||||
extractorsFactory.createExtractors().map { extractor ->
|
||||
when {
|
||||
extractor is MatroskaExtractor -> {
|
||||
val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler)
|
||||
val inner = if (doviEnabled) {
|
||||
DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
|
||||
emitLog(level, prefix, message)
|
||||
}.also {
|
||||
activeDoviMkvWrapper = it
|
||||
}
|
||||
} else {
|
||||
assExtractor
|
||||
}
|
||||
// Wrap with approximate seeking for MKV files without Cues
|
||||
CuelessSeekExtractorWrapper(inner)
|
||||
}
|
||||
)
|
||||
return (
|
||||
ffmpegFirst + extractorsFactory.createExtractors().map { extractor ->
|
||||
when {
|
||||
extractor is MatroskaExtractor -> {
|
||||
val withTextPipeline = OutputWrappingExtractor(extractor) { out ->
|
||||
AssSubtitleExtractorOutput(SubtitleTranscodingExtractorOutput(out, subtitleParserFactory), handler)
|
||||
}
|
||||
if (doviEnabled) {
|
||||
DoviExtractorWrapper(withTextPipeline, currentDvMode) { level, prefix, message ->
|
||||
emitLog(level, prefix, message)
|
||||
}.also {
|
||||
activeDoviMkvWrapper = it
|
||||
}
|
||||
} else {
|
||||
withTextPipeline
|
||||
}
|
||||
}
|
||||
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
|
||||
DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
|
||||
emitLog(level, prefix, message)
|
||||
}.also {
|
||||
activeDoviMp4Wrapper = it
|
||||
}
|
||||
}
|
||||
else -> extractor
|
||||
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
|
||||
DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
|
||||
emitLog(level, prefix, message)
|
||||
}.also {
|
||||
activeDoviMp4Wrapper = it
|
||||
}
|
||||
}
|
||||
).toTypedArray()
|
||||
}
|
||||
else -> extractor
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(recordingFactory, wrappedExtractorsFactory)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
|
||||
.setSubtitleParserFactory(subtitleParserFactory)
|
||||
playbackMediaSourceFactory = mediaSourceFactory
|
||||
|
||||
@@ -855,21 +785,15 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
// Buffer budget. `bufferSizeBytes` carries the user's explicit Buffer Size choice; on
|
||||
// Auto it still arrives (Dart derives it for mpv's demuxer, which shares the property)
|
||||
// but `bufferSizeAuto` says to ignore it here, because mpv's demuxer and ExoPlayer's
|
||||
// sample allocator have different shapes and different failure modes.
|
||||
// Buffer budget. Derived natively from device memory (LoadControlPolicy);
|
||||
// mpv's demuxer sizes itself the same way in MpvPlayerCore.
|
||||
val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
val availableMB = (memoryInfo.availMem / (1024 * 1024)).toInt()
|
||||
val largeHeapMB = activityManager.largeMemoryClass
|
||||
|
||||
val targetBufferBytes = if (!bufferSizeAuto && bufferSizeBytes != null && bufferSizeBytes > 0) {
|
||||
bufferSizeBytes
|
||||
} else {
|
||||
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
}
|
||||
val targetBufferBytes = LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
|
||||
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
|
||||
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
|
||||
@@ -892,8 +816,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
emitLog(
|
||||
"info",
|
||||
"init",
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
|
||||
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit " +
|
||||
"(heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
|
||||
"(${resolvedTier.name.lowercase()}), " +
|
||||
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
|
||||
@@ -3799,7 +3723,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
dv7RetryAttempted = override != null
|
||||
activeDoviMkvWrapper = null
|
||||
activeDoviMp4Wrapper = null
|
||||
activeFfmpegExtractor = null
|
||||
val debugMode = override?.name ?: "AUTO"
|
||||
emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)")
|
||||
reloadCurrentMediaForDvMode()
|
||||
@@ -3836,7 +3759,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
lastDvPlaybackInfo = null
|
||||
activeDoviMkvWrapper = null
|
||||
activeDoviMp4Wrapper = null
|
||||
activeFfmpegExtractor = null
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
@@ -4212,7 +4134,13 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
// HDR content on an HDR display means the decoder's dataspace put the
|
||||
// display into HDR signaling; defer the rate restore past the HDR exit
|
||||
// (see FrameRateManager.clearVideoFrameRate).
|
||||
val transfer = currentVideoFormat?.colorInfo?.colorTransfer
|
||||
val hdrActive = (transfer == C.COLOR_TRANSFER_ST2084 || transfer == C.COLOR_TRANSFER_HLG) &&
|
||||
DoviBridge.displaySupportsHdr(activity)
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrActive)
|
||||
}
|
||||
|
||||
private fun computeFrameRate(timestamps: LongArray): Float {
|
||||
@@ -4303,12 +4231,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
"audioMimeType" to audioFormat?.sampleMimeType,
|
||||
"audioSampleRate" to audioFormat?.sampleRate,
|
||||
"audioChannels" to audioFormat?.channelCount,
|
||||
// Container-declared bitrate when present; otherwise the FFmpeg
|
||||
// demuxer's measured value (Matroska rarely carries BPS tags — #2063).
|
||||
"audioBitrate" to (
|
||||
audioFormat?.bitrate?.takeIf { it > 0 }
|
||||
?: audioFormat?.id?.let { activeFfmpegExtractor?.measuredAudioBitrateBps(it) }
|
||||
),
|
||||
"audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 },
|
||||
"audioDecoderName" to audioDecoderInitName,
|
||||
"audioOutputEncoding" to audioTrackConfig?.encoding,
|
||||
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.mpv.MpvPlayerCore
|
||||
@@ -40,6 +38,11 @@ class ExoPlayerPlugin :
|
||||
private val mainHandler get() = channels.mainHandler
|
||||
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||
private var playerCore: ExoPlayerCore? = null
|
||||
|
||||
// The Dart instanceId that created the current core. A `dispose` carrying a
|
||||
// different token lost the ownership race to a successor and must not tear
|
||||
// down that successor's session; it is acknowledged without touching it.
|
||||
private var coreInstanceId: Long? = null
|
||||
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
|
||||
private var usingMpvFallback: Boolean = false
|
||||
private var fallbackInProgress: Boolean = false
|
||||
@@ -108,8 +111,6 @@ class ExoPlayerPlugin :
|
||||
|
||||
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
|
||||
|
||||
private var configuredBufferSizeBytes: Int? = null
|
||||
|
||||
private var sessionGeneration = 0
|
||||
private var mediaGeneration = 0
|
||||
private var fallbackMediaGeneration: Int? = null
|
||||
@@ -119,7 +120,8 @@ class ExoPlayerPlugin :
|
||||
private var mpvForwardGeneration: Int? = null
|
||||
private var mpvSignalGate: MpvSignalGate? = null
|
||||
private var mpvCoreNeedsReplacement = false
|
||||
internal var createMpvCore: (Activity) -> MpvPlayerCore = { MpvPlayerCore(it) }
|
||||
internal var createMpvCore: (Activity) -> MpvPlayerCore =
|
||||
{ MpvPlayerCore(it, hardwareDecoding = fallbackHardwareDecoding()) }
|
||||
internal var initializeMpvCore: (MpvPlayerCore, (Boolean) -> Unit) -> Unit = { core, onInitialized ->
|
||||
core.initialize(onInitialized)
|
||||
}
|
||||
@@ -139,6 +141,12 @@ class ExoPlayerPlugin :
|
||||
// every codec in audio-spdif with no decode fallback, so the fallback core's value is
|
||||
// derived from the audio route at the moment mpv actually starts (#1703).
|
||||
private var audioPassthroughRequested = false
|
||||
|
||||
// `dv-conversion-mode` is not an mpv property and never reaches
|
||||
// pendingMpvProperties: Dart routes it through setDvConversionMode (see
|
||||
// PlayerAndroid.setProperty), so the fallback core has to be seeded from the
|
||||
// last configured value or it loses the fork's P7/P5 handling entirely.
|
||||
private var dvConversionMode = "auto"
|
||||
private var currentExternalSubtitles: List<Map<String, Any?>>? = null
|
||||
|
||||
// FlutterPlugin
|
||||
@@ -158,6 +166,7 @@ class ExoPlayerPlugin :
|
||||
val exoCore = playerCore
|
||||
val fallbackCore = mpvCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
mpvCore = null
|
||||
usingMpvFallback = false
|
||||
fallbackInProgress = false
|
||||
@@ -174,6 +183,7 @@ class ExoPlayerPlugin :
|
||||
currentExternalSubtitles = null
|
||||
pendingMpvProperties.clear()
|
||||
audioPassthroughRequested = false
|
||||
dvConversionMode = "auto"
|
||||
if (clearActivity) {
|
||||
activity = null
|
||||
activityBinding = null
|
||||
@@ -224,7 +234,7 @@ class ExoPlayerPlugin :
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"open" -> handleOpen(call, result)
|
||||
"play" -> handlePlay(result)
|
||||
"pause" -> handlePause(result)
|
||||
@@ -250,15 +260,10 @@ class ExoPlayerPlugin :
|
||||
)
|
||||
"getStats" -> handleGetStats(result)
|
||||
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
|
||||
"getHeapSize" -> {
|
||||
val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
result.success(am?.largeMemoryClass ?: 0)
|
||||
}
|
||||
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
|
||||
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
|
||||
"setVideoZoom" -> handleSetVideoZoom(call, result)
|
||||
"setDvConversionMode" -> handleSetDvConversionMode(call, result)
|
||||
"setDemuxerMode" -> handleSetDemuxerMode(call, result)
|
||||
"setAudioNormalization" -> handleSetAudioNormalization(call, result)
|
||||
"setAudioPassthrough" -> handleSetAudioPassthrough(call, result)
|
||||
"setAudioDownmix" -> handleSetAudioDownmix(call, result)
|
||||
@@ -292,21 +297,14 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
|
||||
// Auto sizing is decided natively (LoadControlPolicy). `bufferSizeBytes` still arrives
|
||||
// on Auto because Dart derives one for mpv's demuxer, which shares the property, and
|
||||
// the fallback replay below needs it.
|
||||
val bufferSizeAuto = call.argument<Boolean>("bufferSizeAuto") ?: false
|
||||
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: false
|
||||
val dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
|
||||
dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
|
||||
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
|
||||
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
|
||||
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
|
||||
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
|
||||
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
|
||||
val demuxerMode = call.argument<String>("demuxerMode") ?: "ffmpeg"
|
||||
configuredBufferSizeBytes = bufferSizeBytes
|
||||
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
|
||||
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
|
||||
audioPassthroughRequested = audioPassthroughEnabled
|
||||
@@ -345,13 +343,11 @@ class ExoPlayerPlugin :
|
||||
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
|
||||
}
|
||||
playerCore = core
|
||||
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
|
||||
val success = core.initialize(
|
||||
bufferSizeBytes = bufferSizeBytes,
|
||||
bufferSizeAuto = bufferSizeAuto,
|
||||
tunnelingEnabled = tunnelingEnabled,
|
||||
audioPassthroughEnabled = audioPassthroughEnabled,
|
||||
bufferTier = bufferTier,
|
||||
demuxerMode = demuxerMode
|
||||
bufferTier = bufferTier
|
||||
)
|
||||
if (!success) {
|
||||
if (playerCore === core) playerCore = null
|
||||
@@ -376,8 +372,15 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<Number>("instanceId")?.toLong()
|
||||
runOnMain {
|
||||
val owner = coreInstanceId
|
||||
if ((playerCore != null || mpvCore != null) && token != null && owner != null && token != owner) {
|
||||
Log.d(TAG, "Ignoring stale dispose (token=$token, core owner=$owner)")
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
teardownSession(clearActivity = false)
|
||||
Log.d(TAG, "Disposed")
|
||||
result.success(null)
|
||||
@@ -1131,12 +1134,24 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
if (usingMpvFallback) {
|
||||
result.success(false)
|
||||
// The fallback core owns the property now; dropping the write would
|
||||
// strand it on the mode ExoPlayer started with.
|
||||
val core = mpvCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
dvConversionMode = mode
|
||||
core.setProperty("dv-conversion-mode", mode) { outcome ->
|
||||
completeMpvPropertyResult(result, outcome, successValue = true)
|
||||
}
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
val handled = playerCore?.setDebugDvConversionMode(mode) == true
|
||||
if (handled) {
|
||||
// Keep the fallback seed on the value ExoPlayer is actually running.
|
||||
dvConversionMode = mode
|
||||
result.success(true)
|
||||
} else {
|
||||
result.error("INVALID_ARGS", "Invalid DV conversion mode: $mode", null)
|
||||
@@ -1144,18 +1159,6 @@ class ExoPlayerPlugin :
|
||||
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
}
|
||||
|
||||
private fun handleSetDemuxerMode(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mode = call.argument<String>("mode")
|
||||
if (mode == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'mode'", null)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
playerCore?.setDemuxerMode(mode)
|
||||
result.success(null)
|
||||
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
}
|
||||
|
||||
private fun handleSetAudioNormalization(call: MethodCall, result: MethodChannel.Result) {
|
||||
val enabled = call.argument<Boolean>("enabled")
|
||||
if (enabled == null) {
|
||||
@@ -1321,6 +1324,16 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode intent for a fallback mpv core, read from the `hwdec` value Dart
|
||||
* already wrote for this session. It picks the core's initial vo chain
|
||||
* ([MpvPlayerCore.initialVideoOutput]), so a software-decoding session must
|
||||
* not inherit the hardware default: only gpu-next applies Dolby Vision RPU
|
||||
* reshaping, and a session that asked for software decode is usually the
|
||||
* one that needs it.
|
||||
*/
|
||||
private fun fallbackHardwareDecoding(): Boolean = pendingMpvProperties["hwdec"]?.let { it != "no" } ?: true
|
||||
|
||||
/**
|
||||
* Configure a freshly initialized MPV fallback core: replay the properties
|
||||
* and observers Dart registered against the ExoPlayer session, then resume
|
||||
@@ -1330,17 +1343,13 @@ class ExoPlayerPlugin :
|
||||
private fun prepareMpvFallback(core: MpvPlayerCore) {
|
||||
val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList()
|
||||
val observedProps = observedProperties.toList()
|
||||
val bufferSize = configuredBufferSizeBytes
|
||||
|
||||
// vo is owned by MpvPlayerCore's init: the fallback core hardware-decodes
|
||||
// (hwdec below), so it gets the legacy gpu VO — gpu-next under mediacodec
|
||||
// fails every frame on Tegra (#2010) and reshapes no DV anyway.
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
|
||||
// hwdec is not seeded here — Dart's write in pendingMpvProperties is the
|
||||
// single source of the fallback core's chain.
|
||||
core.setProperty("ao", "audiotrack")
|
||||
|
||||
if (bufferSize != null && bufferSize > 0) {
|
||||
core.setProperty("demuxer-max-bytes", bufferSize.toString())
|
||||
}
|
||||
// Dart routes dv-conversion-mode through setDvConversionMode, so unlike
|
||||
// hwdec it never reaches pendingMpvProperties.
|
||||
core.setProperty("dv-conversion-mode", dvConversionMode)
|
||||
|
||||
for ((propName, propValue) in pendingProps) {
|
||||
core.setProperty(propName, propValue) { outcome ->
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.container.OpusUtil
|
||||
|
||||
/**
|
||||
* Shapes ffmpeg codec extradata into the per-codec initializationData layout
|
||||
* media3's audio decode paths consume. MediaCodecAudioRenderer maps the list
|
||||
* entries verbatim to MediaCodec csd-0/1/2 and the bundled FfmpegAudioDecoder
|
||||
* rebuilds avcodec extradata from the same entries, so both expect the shapes
|
||||
* media3's own extractors emit — not raw ffmpeg extradata:
|
||||
*
|
||||
* - Opus: [OpusHead, codec delay ns, seek pre-roll ns]. Android's Opus
|
||||
* decoders consume their first three input buffers as exactly this
|
||||
* configuration; given only OpusHead they read the first two real packets
|
||||
* as delay/pre-roll and then silently discard every decoded sample, which
|
||||
* stalled the audio clock at 0 until the MPV fallback fired (#2088).
|
||||
* - Vorbis: [identification header, setup header], split out of the
|
||||
* Xiph-laced blob the way MatroskaExtractor's parseVorbisCodecPrivate does.
|
||||
* - FLAC: the "fLaC"-marked STREAMINFO stream MediaCodec documents; ffmpeg
|
||||
* surfaces a bare or block-headed STREAMINFO depending on the container.
|
||||
*
|
||||
* Everything else (AAC ASC, ALAC cookie, passthrough codecs) already matches
|
||||
* media3's single-entry shape and passes through. MP3's WAVEFORMATEX blob is
|
||||
* dropped on the JNI side before it reaches this shaping.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal object FfmpegAudioCsd {
|
||||
|
||||
// RFC 7845 section 5.1: the identification header is at least 19 bytes;
|
||||
// OpusUtil reads the pre-skip field at offsets 10-11.
|
||||
private const val OPUS_HEADER_MIN_BYTES = 19
|
||||
|
||||
private val FLAC_STREAM_MARKER = byteArrayOf(0x66, 0x4C, 0x61, 0x43) // "fLaC"
|
||||
private const val FLAC_STREAM_INFO_SIZE = 34
|
||||
private const val FLAC_BLOCK_HEADER_SIZE = 4
|
||||
private const val FLAC_LAST_BLOCK_FLAG = 0x80
|
||||
|
||||
fun initializationData(mime: String, extradata: ByteArray): List<ByteArray> = when (mime) {
|
||||
MimeTypes.AUDIO_OPUS -> opus(extradata)
|
||||
MimeTypes.AUDIO_VORBIS -> vorbis(extradata)
|
||||
MimeTypes.AUDIO_FLAC -> flac(extradata)
|
||||
else -> listOf(extradata)
|
||||
}
|
||||
|
||||
private fun opus(extradata: ByteArray): List<ByteArray> {
|
||||
if (extradata.size < OPUS_HEADER_MIN_BYTES) {
|
||||
throw malformed("Opus extradata is not an identification header: ${extradata.size} bytes")
|
||||
}
|
||||
// [OpusHead, pre-skip ns, 80 ms default seek pre-roll ns] — the same
|
||||
// shape media3's Matroska/Ogg/MP4 extractors publish.
|
||||
return OpusUtil.buildInitializationData(extradata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the Xiph-laced header blob (identification, comment, setup) into
|
||||
* media3's [identification header, setup header] pair, mirroring media3
|
||||
* 1.11.0 MatroskaExtractor.parseVorbisCodecPrivate.
|
||||
*/
|
||||
private fun vorbis(extradata: ByteArray): List<ByteArray> {
|
||||
try {
|
||||
if (extradata[0].toInt() != 0x02) throw malformed("Vorbis extradata is not Xiph-laced headers")
|
||||
var offset = 1
|
||||
var idHeaderLength = 0
|
||||
while (extradata[offset].toInt() and 0xFF == 0xFF) {
|
||||
idHeaderLength += 0xFF
|
||||
offset++
|
||||
}
|
||||
idHeaderLength += extradata[offset++].toInt() and 0xFF
|
||||
var commentHeaderLength = 0
|
||||
while (extradata[offset].toInt() and 0xFF == 0xFF) {
|
||||
commentHeaderLength += 0xFF
|
||||
offset++
|
||||
}
|
||||
commentHeaderLength += extradata[offset++].toInt() and 0xFF
|
||||
|
||||
if (extradata[offset].toInt() != 0x01) throw malformed("Vorbis identification header not found")
|
||||
val idHeader = extradata.copyOfRange(offset, offset + idHeaderLength)
|
||||
offset += idHeaderLength
|
||||
if (extradata[offset].toInt() != 0x03) throw malformed("Vorbis comment header not found")
|
||||
offset += commentHeaderLength
|
||||
if (extradata[offset].toInt() != 0x05) throw malformed("Vorbis setup header not found")
|
||||
return listOf(idHeader, extradata.copyOfRange(offset, extradata.size))
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
throw malformed("Vorbis extradata truncated")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes to the "fLaC"-marked metadata stream. MediaCodec's decoder
|
||||
* feeds csd-0 straight into libFLAC, which requires the stream marker;
|
||||
* ffmpeg's canonical FLAC extradata is the bare 34-byte STREAMINFO, and
|
||||
* some demuxers keep the 4-byte metadata block header in front of it.
|
||||
*/
|
||||
private fun flac(extradata: ByteArray): List<ByteArray> {
|
||||
if (startsWithFlacMarker(extradata)) return listOf(extradata)
|
||||
if (extradata.size == FLAC_STREAM_INFO_SIZE) {
|
||||
val blockHeader = byteArrayOf(
|
||||
(FLAC_LAST_BLOCK_FLAG or 0).toByte(), // last-metadata-block, type 0 = STREAMINFO
|
||||
0x00,
|
||||
0x00,
|
||||
FLAC_STREAM_INFO_SIZE.toByte()
|
||||
)
|
||||
return listOf(FLAC_STREAM_MARKER + blockHeader + extradata)
|
||||
}
|
||||
val declaredLength = if (extradata.size >= FLAC_BLOCK_HEADER_SIZE) {
|
||||
((extradata[1].toInt() and 0xFF) shl 16) or
|
||||
((extradata[2].toInt() and 0xFF) shl 8) or
|
||||
(extradata[3].toInt() and 0xFF)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
if (extradata[0].toInt() and 0x7F == 0 &&
|
||||
declaredLength == FLAC_STREAM_INFO_SIZE &&
|
||||
extradata.size >= FLAC_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_SIZE
|
||||
) {
|
||||
return listOf(FLAC_STREAM_MARKER + extradata)
|
||||
}
|
||||
throw malformed("FLAC extradata is not a STREAMINFO block: ${extradata.size} bytes")
|
||||
}
|
||||
|
||||
private fun startsWithFlacMarker(extradata: ByteArray): Boolean {
|
||||
if (extradata.size < FLAC_STREAM_MARKER.size) return false
|
||||
for (i in FLAC_STREAM_MARKER.indices) {
|
||||
if (extradata[i] != FLAC_STREAM_MARKER[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
||||
/**
|
||||
* JNI surface for the libavformat demuxer shim (`cpp/media3_ffmpeg_demuxer`).
|
||||
*
|
||||
* One demuxer exists per process, owned by whichever [FfmpegExtractor] is
|
||||
* currently open — Plezy plays at most one progressive source at a time, and
|
||||
* every other playback mode (HLS/DASH, music) stays on media3's own sources.
|
||||
*
|
||||
* The long[] layouts mirror the constants documented in
|
||||
* `ffmpeg_demuxer_jni.cc`; keep both sides in sync.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal object FfmpegDemuxerJni {
|
||||
/** Byte source the AVIO callbacks read through while a native call is in flight. */
|
||||
interface Input {
|
||||
/** Current byte position of the loader's [androidx.media3.extractor.ExtractorInput]. */
|
||||
fun position(): Long
|
||||
|
||||
/**
|
||||
* Reads up to [length] bytes from the loader's input at its current
|
||||
* position. Returns the number of bytes read, `0` at end of input, or `-1`
|
||||
* after storing the failure in [lastError].
|
||||
*/
|
||||
fun read(buf: ByteArray, length: Int): Int
|
||||
|
||||
/**
|
||||
* Reads up to [length] bytes at absolute [position], wherever the loader
|
||||
* stands. Same return contract as [read]. This is what lets libavformat
|
||||
* seek its own index without the shim unwinding the in-flight call.
|
||||
*/
|
||||
fun readAt(position: Long, buf: ByteArray, length: Int): Int
|
||||
|
||||
/** Total input length in bytes, or `-1` when unknown. */
|
||||
fun length(): Long
|
||||
|
||||
var lastError: String?
|
||||
}
|
||||
|
||||
// readPacket out[] indices.
|
||||
const val OUT_CODE = 0
|
||||
const val OUT_STREAM_INDEX = 1
|
||||
const val OUT_PTS_US = 2
|
||||
const val OUT_FLAGS = 3
|
||||
const val OUT_SIZE = 4
|
||||
const val OUT_POSITION = 5
|
||||
const val OUT_DURATION_US = 6
|
||||
const val OUT_LENGTH = 7
|
||||
|
||||
// Result codes written to out[OUT_CODE]; negative values are raw AVERRORs.
|
||||
const val CODE_PACKET = 0
|
||||
const val CODE_EOF = 1
|
||||
const val CODE_GROW = 2
|
||||
const val ERR_JAVA = -102
|
||||
|
||||
// streamInfo long[] layout.
|
||||
const val INFO_TRACK_TYPE = 0
|
||||
const val INFO_WIDTH = 1
|
||||
const val INFO_HEIGHT = 2
|
||||
const val INFO_PAR_NUM = 3
|
||||
const val INFO_PAR_DEN = 4
|
||||
const val INFO_FPS_NUM = 5
|
||||
const val INFO_FPS_DEN = 6
|
||||
const val INFO_SAMPLE_RATE = 7
|
||||
const val INFO_CHANNELS = 8
|
||||
const val INFO_PCM_ENCODING = 9
|
||||
const val INFO_ROTATION = 10
|
||||
const val INFO_SELECTION_FLAGS = 11
|
||||
const val INFO_ROLE_FLAGS = 12
|
||||
const val INFO_BITRATE = 13
|
||||
const val INFO_DOVI_PROFILE = 14
|
||||
const val INFO_DOVI_LEVEL = 15
|
||||
const val INFO_LATM = 16
|
||||
const val INFO_LENGTH = 17
|
||||
|
||||
val available: Boolean = try {
|
||||
System.loadLibrary("ffmpegJNI")
|
||||
true
|
||||
} catch (_: UnsatisfiedLinkError) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Probes container bytes; returns the ffmpeg short name ("avi", "asf", …) or null. */
|
||||
external fun nativeProbeFormat(header: ByteArray): String?
|
||||
|
||||
/**
|
||||
* Opens the demuxer against [input]. Returns 0 on success or a negative
|
||||
* AVERROR ([ERR_JAVA] when the input itself failed). The stream count is read
|
||||
* separately via [nativeStreamCount]; it must not ride the same namespace as
|
||||
* result codes.
|
||||
*/
|
||||
external fun nativeOpen(input: Input): Int
|
||||
|
||||
external fun nativeClose()
|
||||
|
||||
external fun nativeStreamCount(): Int
|
||||
|
||||
/** Total duration in microseconds, or -1 when unknown. */
|
||||
external fun nativeDurationUs(): Long
|
||||
|
||||
/** Fills [numbers]/[strings] for [index]; false when the stream is unusable. */
|
||||
external fun nativeStreamInfo(index: Int, numbers: LongArray, strings: Array<String?>): Boolean
|
||||
|
||||
/** Codec initialization data (extradata), or null. */
|
||||
external fun nativeStreamExtradata(index: Int): ByteArray?
|
||||
|
||||
/** Number of attachment streams (embedded fonts and friends) with payloads. */
|
||||
external fun nativeAttachmentCount(): Int
|
||||
|
||||
/**
|
||||
* Fills [strings] with [filename, mimetype] for attachment [ordinal] and
|
||||
* returns the payload size in bytes, or -1 when out of range. Lets Kotlin
|
||||
* enforce font budgets before copying any bytes across the boundary.
|
||||
*/
|
||||
external fun nativeAttachmentInfo(ordinal: Int, strings: Array<String?>): Long
|
||||
|
||||
/** Attachment payload bytes, or null. */
|
||||
external fun nativeAttachmentData(ordinal: Int): ByteArray?
|
||||
|
||||
/**
|
||||
* Reads one packet into [buffer], filling [out]. See the shim header
|
||||
* comment for the result contract.
|
||||
*/
|
||||
external fun nativeReadPacket(buffer: ByteArray, out: LongArray): Int
|
||||
|
||||
/** Byte position libavformat will read next, or -1 when not open. */
|
||||
external fun nativeLogicalPosition(): Long
|
||||
|
||||
/**
|
||||
* Seeks to [timeUs] (media3 timeline microseconds, i.e. start-time
|
||||
* normalized). The demuxer resolves the byte position itself through its own
|
||||
* index. Returns 0 on success, or a negative AVERROR when the container has
|
||||
* no usable index and libavformat refused the seek.
|
||||
*/
|
||||
external fun nativeSeek(timeUs: Long): Int
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Decides whether the FFmpeg demuxer sits ahead of media3's own extractors.
|
||||
*
|
||||
* FFmpeg demuxes every progressive container by default. media3's bundled
|
||||
* extractors were weak exactly where this app kept accumulating container
|
||||
* patches — AVI (XviD packed-bitstream timestamps, missing VOL csd — issue
|
||||
* #2052), ASF/WMV (no extractor at all), MPEG-PS/VOB (no extractor), and
|
||||
* Matroska (zlib-compressed subtitles, LOAS/LATM audio as A_MS/ACM, cueless
|
||||
* seeking, font attachments) — and libavformat handles all of it natively.
|
||||
* media3's extractors stay behind FFmpeg in the list, so a container FFmpeg
|
||||
* fails to sniff still reaches media3's parsers. [Preference.MEDIA3_ONLY] is
|
||||
* the user-facing escape hatch in case a file misbehaves on the FFmpeg path.
|
||||
*/
|
||||
internal object FfmpegDemuxerPolicy {
|
||||
enum class Preference(val wireName: String) {
|
||||
FFMPEG("ffmpeg"),
|
||||
MEDIA3_ONLY("media3")
|
||||
}
|
||||
|
||||
/** Unknown wire values (including the retired "auto") resolve to FFmpeg. */
|
||||
fun fromWire(value: String?): Preference = Preference.entries.firstOrNull { it.wireName == value } ?: Preference.FFMPEG
|
||||
|
||||
/** Whether the FFmpeg extractor placed before media3's list demuxes at all. */
|
||||
fun enabled(preference: Preference): Boolean = preference == Preference.FFMPEG
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.SeekPoint
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput
|
||||
import com.edde746.plezy.libass.media.AssFonts
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Demuxes progressive containers with libavformat and feeds the packets to
|
||||
* media3's renderers, so FFmpeg's container coverage replaces media3's on the
|
||||
* paths where it is weak (AVI/XviD timestamps and VOL csd — #2052 — ASF/WMV,
|
||||
* MPEG-PS) and where the app accumulated Matroska patches (zlib-compressed
|
||||
* subtitles, LOAS/LATM audio, cueless seeking) without giving up any of the
|
||||
* renderer-side work (passthrough carriers, DV sanitizing, libass rendering
|
||||
* with embedded fonts, subtitle latency calibration).
|
||||
*
|
||||
* Text tracks are emitted in the exact sample shape media3's
|
||||
* MatroskaExtractor writes (see [FfmpegSubtitleSamples]); the output chain
|
||||
* composed in [init] then feeds libass dialogue and transcodes cues the same
|
||||
* way the media3 Matroska path does.
|
||||
*
|
||||
* Seeking: libavformat resolves seeks itself, reading its own index through
|
||||
* [FfmpegRandomAccessSource]. media3's byte hints are ignored, nothing is ever
|
||||
* unwound, and the loader is merely nudged to follow along so that sample
|
||||
* delivery keeps streaming on its connection (see [alignLoader]).
|
||||
*
|
||||
* The instance sits before media3's extractors and sniffs everything FFmpeg
|
||||
* can probe while [FfmpegDemuxerPolicy] enables it; media3's list behind it
|
||||
* is the fallback for anything FFmpeg cannot open.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class FfmpegExtractor private constructor(
|
||||
private val preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference,
|
||||
private val dvMode: DvConversionMode,
|
||||
private val subtitleParserFactory: SubtitleParser.Factory,
|
||||
private val assHandler: AssHandler,
|
||||
private val io: FfmpegRandomAccessSource
|
||||
) : Extractor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FfmpegExtractor"
|
||||
private const val SNIFF_BYTES = 64 * 1024
|
||||
private const val INITIAL_PACKET_BUFFER_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/** Null when the native library is unavailable. */
|
||||
fun create(
|
||||
preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference,
|
||||
dvMode: DvConversionMode,
|
||||
subtitleParserFactory: SubtitleParser.Factory,
|
||||
assHandler: AssHandler,
|
||||
io: FfmpegRandomAccessSource
|
||||
): FfmpegExtractor? = if (FfmpegDemuxerJni.available) {
|
||||
FfmpegExtractor(preferenceSupplier, dvMode, subtitleParserFactory, assHandler, io)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var output: ExtractorOutput
|
||||
private var currentInput: ExtractorInput? = null
|
||||
private val inputProxy = object : FfmpegDemuxerJni.Input {
|
||||
override fun position(): Long = currentInput?.position ?: 0L
|
||||
|
||||
override fun read(buf: ByteArray, length: Int): Int {
|
||||
val input = currentInput ?: return 0
|
||||
lastError = null
|
||||
return try {
|
||||
val read = input.read(buf, 0, length)
|
||||
if (read == C.RESULT_END_OF_INPUT) 0 else read
|
||||
} catch (e: IOException) {
|
||||
lastError = e.message ?: "demuxer input failed"
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
override fun readAt(position: Long, buf: ByteArray, length: Int): Int {
|
||||
lastError = null
|
||||
return try {
|
||||
io.readAt(position, buf, length)
|
||||
} catch (e: IOException) {
|
||||
lastError = e.message ?: "demuxer random access read failed"
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
override fun length(): Long {
|
||||
val fromLoader = currentInput?.length ?: C.LENGTH_UNSET.toLong()
|
||||
if (fromLoader != C.LENGTH_UNSET.toLong()) return fromLoader
|
||||
// Length-less loader open (progressive live stream): fall back to
|
||||
// whatever a random-access open already resolved.
|
||||
val resolved = io.length()
|
||||
return if (resolved == C.LENGTH_UNSET.toLong()) -1L else resolved
|
||||
}
|
||||
|
||||
override var lastError: String? = null
|
||||
}
|
||||
|
||||
private var opened = false
|
||||
private var doviOutputWrapper: DoviExtractorOutputWrapper? = null
|
||||
private var durationUs = C.TIME_UNSET
|
||||
private var trackOutputs: Array<TrackOutput?> = emptyArray()
|
||||
private var packetBytes = ByteArray(INITIAL_PACKET_BUFFER_BYTES)
|
||||
private val packetParsable = ParsableByteArray()
|
||||
private val packetOut = LongArray(FfmpegDemuxerJni.OUT_LENGTH)
|
||||
private val prefixParsable = ParsableByteArray()
|
||||
|
||||
// Per-stream text handling picked in prepareTracks; parallel to trackOutputs.
|
||||
private var subtitleKinds: Array<SubtitleKind> = emptyArray()
|
||||
private val latmOutputs = ArrayList<LatmTrackOutput>()
|
||||
|
||||
// Per-audio-stream measured bitrate, parallel to trackOutputs. Written by
|
||||
// the loader thread, read by ExoPlayerCore.getStats on another thread.
|
||||
@Volatile private var bitrateMeters: Array<StreamBitrateMeter?> = emptyArray()
|
||||
|
||||
// Set by seek(), applied by the next read(): media3 calls both on the loader
|
||||
// thread, but only read() is allowed to throw IOException and the demuxer's
|
||||
// seek reads its index.
|
||||
private var pendingSeekUs = C.TIME_UNSET
|
||||
private var deliveredAnyPacket = false
|
||||
|
||||
private val sniffScratch = ByteArray(SNIFF_BYTES)
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean {
|
||||
// media3 materializes the extractor array once per player session, so the
|
||||
// policy must be read live at sniff time — a preference captured at
|
||||
// construction would freeze for the session.
|
||||
if (!FfmpegDemuxerPolicy.enabled(preferenceSupplier())) return false
|
||||
val probed = probe(input) ?: return false
|
||||
Log.i(TAG, "sniff accepted $probed")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun probe(input: ExtractorInput): String? {
|
||||
// create() only builds instances when the native library loaded.
|
||||
input.resetPeekPosition()
|
||||
return try {
|
||||
val filled =
|
||||
input.peekFully(
|
||||
sniffScratch,
|
||||
/* offset= */
|
||||
0,
|
||||
sniffScratch.size,
|
||||
/* allowEndOfInput= */
|
||||
true
|
||||
)
|
||||
if (!filled) return null
|
||||
FfmpegDemuxerJni.nativeProbeFormat(sniffScratch)?.lowercase()
|
||||
} catch (_: Throwable) {
|
||||
// A sniffer must never take playback down: media3 escalates anything
|
||||
// that is not an IOException into a fatal loader error.
|
||||
null
|
||||
} finally {
|
||||
input.resetPeekPosition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
// media3 reuses extractor instances across sources; drop the previous
|
||||
// item's demuxer and byte source so this open starts clean.
|
||||
releaseDemuxer()
|
||||
// Same composition as the MP4/MKV paths: DoviConvertingTrackOutput
|
||||
// inspects each video track's codecs string and passes non-DV through.
|
||||
val doviWrapped = if (dvMode != DvConversionMode.DISABLED) {
|
||||
// The wrapper forwards emitLog into each converting track output; the
|
||||
// playback-info hook stays wired to the MKV/MP4 wrappers, so nothing to
|
||||
// capture here.
|
||||
DoviExtractorOutputWrapper(
|
||||
output,
|
||||
dvMode,
|
||||
emitLog = { level, prefix, message ->
|
||||
Log.println(if (level == "error") Log.ERROR else Log.INFO, TAG, "$prefix $message")
|
||||
},
|
||||
onVideoTrackWrapped = { _ -> }
|
||||
).also { doviOutputWrapper = it }
|
||||
} else {
|
||||
doviOutputWrapper = null
|
||||
output
|
||||
}
|
||||
// Text pipeline, extractor-outward: raw media3-shape samples first hit
|
||||
// AssTrackOutput (libass dialogue feed), then the transcoding wrapper
|
||||
// parses them into cue samples. media3 does NOT wrap third-party
|
||||
// extractors' outputs; without this wrapper no text track would ever
|
||||
// reach the renderer as cues.
|
||||
this.output = AssSubtitleExtractorOutput(
|
||||
SubtitleTranscodingExtractorOutput(doviWrapped, subtitleParserFactory),
|
||||
assHandler
|
||||
)
|
||||
}
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
currentInput = input
|
||||
try {
|
||||
if (!opened) openStreams()
|
||||
applyPendingSeek()
|
||||
alignLoader(input)?.let { position ->
|
||||
seekPosition.position = position
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
// Aligned: the loader serves the samples, so nothing needs a second
|
||||
// handle open until the demuxer jumps again.
|
||||
if (io.isOpen) io.close()
|
||||
return readPacket()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "read threw at input=${input.position}", e)
|
||||
throw e
|
||||
} finally {
|
||||
currentInput = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte position the loader should move to, or null when it already stands
|
||||
* where libavformat reads.
|
||||
*
|
||||
* Reads outside the loader's window are served by [io] regardless, so this
|
||||
* is an optimization, never a correctness requirement: it keeps sample
|
||||
* delivery on media3's own connection, which is what drives its byte
|
||||
* accounting, back-pressure (ProgressiveMediaPeriod watches the input
|
||||
* position) and load-error policy. Nothing here needs a retry budget.
|
||||
*/
|
||||
private fun alignLoader(input: ExtractorInput): Long? {
|
||||
val demuxerPosition = FfmpegDemuxerJni.nativeLogicalPosition()
|
||||
if (demuxerPosition < 0 || demuxerPosition == input.position) return null
|
||||
val length = input.length
|
||||
// Opening a range at or past the end is a failed request, not an
|
||||
// alignment; let the read report end of input instead.
|
||||
if (length != C.LENGTH_UNSET.toLong() && demuxerPosition >= length) return null
|
||||
return demuxerPosition
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the target; [read] performs it. media3's byte position is its
|
||||
* SeekMap guess and is deliberately ignored — libavformat picks the byte
|
||||
* position from its own index.
|
||||
*/
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
Log.i(TAG, "extractor seek timeUs=$timeUs opened=$opened")
|
||||
doviOutputWrapper?.resetTracks()
|
||||
// Buffered LOAS bytes belong to the pre-seek stream; the StreamMuxConfig
|
||||
// survives so audio resumes without waiting for the next config.
|
||||
for (latm in latmOutputs) latm.reset()
|
||||
pendingSeekUs = timeUs
|
||||
}
|
||||
|
||||
private fun applyPendingSeek() {
|
||||
val target = pendingSeekUs
|
||||
if (target == C.TIME_UNSET) return
|
||||
pendingSeekUs = C.TIME_UNSET
|
||||
// media3 issues seek(0) before the first read of every item; a demuxer
|
||||
// that has not delivered a packet yet is already at the start, and
|
||||
// seeking would throw away find_stream_info's buffered packets and
|
||||
// re-read the header region.
|
||||
if (target == 0L && !deliveredAnyPacket) return
|
||||
val code = FfmpegDemuxerJni.nativeSeek(target)
|
||||
if (code == FfmpegDemuxerJni.ERR_JAVA) {
|
||||
throw IOException(inputProxy.lastError ?: "demuxer input failed")
|
||||
}
|
||||
if (code != 0) {
|
||||
// No usable index in this container: libavformat keeps delivering from
|
||||
// where it stands and the renderers decode-discard toward the target,
|
||||
// which is what media3's own extractors do for a cueless file.
|
||||
Log.w(TAG, "demuxer refused seek to ${target}us: $code")
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
releaseDemuxer()
|
||||
}
|
||||
|
||||
private fun releaseDemuxer() {
|
||||
if (opened) {
|
||||
opened = false
|
||||
FfmpegDemuxerJni.nativeClose()
|
||||
}
|
||||
io.close()
|
||||
pendingSeekUs = C.TIME_UNSET
|
||||
deliveredAnyPacket = false
|
||||
// media3's extractors may demux a later item while this instance keeps
|
||||
// the previous one's stream layout; a stale measurement must not leak
|
||||
// into stats.
|
||||
bitrateMeters = emptyArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured average bitrate (bps) of the audio stream whose index matches
|
||||
* [formatId] (this extractor sets `Format.id` to the stream index), or null
|
||||
* when this instance is not demuxing that stream. Fallback for containers
|
||||
* that declare no per-track bitrate (#2063).
|
||||
*/
|
||||
fun measuredAudioBitrateBps(formatId: String?): Int? {
|
||||
val index = formatId?.toIntOrNull() ?: return null
|
||||
return bitrateMeters.getOrNull(index)?.bitrateBps()
|
||||
}
|
||||
|
||||
private fun openStreams() {
|
||||
when (val code = FfmpegDemuxerJni.nativeOpen(inputProxy)) {
|
||||
0 -> {
|
||||
prepareTracks()
|
||||
opened = true
|
||||
Log.i(TAG, "ffmpeg demuxer ready: ${trackOutputs.count { it != null }} tracks")
|
||||
}
|
||||
FfmpegDemuxerJni.ERR_JAVA -> throw IOException(inputProxy.lastError ?: "demuxer input failed")
|
||||
else -> throw malformed("ffmpeg demuxer open failed: $code")
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SubtitleKind { NONE, SSA, SUBRIP, VTT }
|
||||
|
||||
private fun prepareTracks() {
|
||||
val count = FfmpegDemuxerJni.nativeStreamCount()
|
||||
durationUs = FfmpegDemuxerJni.nativeDurationUs().takeIf { it >= 0 } ?: C.TIME_UNSET
|
||||
trackOutputs = arrayOfNulls(count)
|
||||
subtitleKinds = Array(count) { SubtitleKind.NONE }
|
||||
bitrateMeters = arrayOfNulls(count)
|
||||
latmOutputs.clear()
|
||||
// Fonts go in before any text track exists so AssHandler's store flushes
|
||||
// them into libass when the first ASS track is created.
|
||||
deliverFontAttachments()
|
||||
val numbers = LongArray(FfmpegDemuxerJni.INFO_LENGTH)
|
||||
val strings = arrayOfNulls<String>(3)
|
||||
var primaryVideo = -1
|
||||
var primaryAudio = -1
|
||||
|
||||
for (index in 0 until count) {
|
||||
if (!FfmpegDemuxerJni.nativeStreamInfo(index, numbers, strings)) continue
|
||||
val mime = strings[0] ?: continue
|
||||
val trackType = when (numbers[FfmpegDemuxerJni.INFO_TRACK_TYPE]) {
|
||||
0L -> C.TRACK_TYPE_VIDEO
|
||||
1L -> C.TRACK_TYPE_AUDIO
|
||||
2L -> C.TRACK_TYPE_TEXT
|
||||
else -> continue
|
||||
}
|
||||
|
||||
val builder = Format.Builder()
|
||||
.setId(index.toString())
|
||||
.setSampleMimeType(mime)
|
||||
.setLanguage(strings[1])
|
||||
.setLabel(strings[2])
|
||||
.setSelectionFlags(numbers[FfmpegDemuxerJni.INFO_SELECTION_FLAGS].toInt())
|
||||
.setRoleFlags(numbers[FfmpegDemuxerJni.INFO_ROLE_FLAGS].toInt())
|
||||
.setAverageBitrate(numbers[FfmpegDemuxerJni.INFO_BITRATE].toInt())
|
||||
|
||||
val extradata = FfmpegDemuxerJni.nativeStreamExtradata(index)
|
||||
if (extradata != null) {
|
||||
// Audio csd is codec-specific in media3 (see FfmpegAudioCsd); raw
|
||||
// extradata is only correct for video (already Annex-B via the JNI
|
||||
// bitstream filter) and text tracks.
|
||||
builder.setInitializationData(
|
||||
if (trackType == C.TRACK_TYPE_AUDIO) {
|
||||
FfmpegAudioCsd.initializationData(mime, extradata)
|
||||
} else {
|
||||
listOf(extradata)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
when (trackType) {
|
||||
C.TRACK_TYPE_VIDEO -> {
|
||||
// A Dolby Vision config record rides the stream as side data.
|
||||
// Mirror media3's MP4/Matroska extractors: recognized profiles
|
||||
// publish video/dolby-vision with the RFC 6381 codecs string, so
|
||||
// DoviConvertingTrackOutput's P7 conversion, the P8 passthrough
|
||||
// path, and the renderer's HEVC fallback engage exactly as they do
|
||||
// on the media3 demux path.
|
||||
val doviProfile = numbers[FfmpegDemuxerJni.INFO_DOVI_PROFILE].toInt()
|
||||
if (doviProfile >= 0) {
|
||||
val doviLevel = numbers[FfmpegDemuxerJni.INFO_DOVI_LEVEL].toInt().coerceAtLeast(0)
|
||||
val doviCodecs = DoviCodecs.rfc6381(doviProfile, doviLevel)
|
||||
if (doviCodecs != null) {
|
||||
builder.setCodecs(doviCodecs)
|
||||
builder.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
|
||||
}
|
||||
Log.i(TAG, "video track $index: doviProfile=$doviProfile doviLevel=$doviLevel codecs=$doviCodecs baseMime=$mime")
|
||||
}
|
||||
val width = numbers[FfmpegDemuxerJni.INFO_WIDTH].toInt()
|
||||
val height = numbers[FfmpegDemuxerJni.INFO_HEIGHT].toInt()
|
||||
builder
|
||||
.setWidth(width)
|
||||
.setHeight(height)
|
||||
.setRotationDegrees(numbers[FfmpegDemuxerJni.INFO_ROTATION].toInt())
|
||||
val parNum = numbers[FfmpegDemuxerJni.INFO_PAR_NUM]
|
||||
val parDen = numbers[FfmpegDemuxerJni.INFO_PAR_DEN]
|
||||
if (parNum > 0 && parDen > 0 && parNum != parDen) {
|
||||
builder.setPixelWidthHeightRatio(parNum.toFloat() / parDen.toFloat())
|
||||
}
|
||||
val fpsNum = numbers[FfmpegDemuxerJni.INFO_FPS_NUM]
|
||||
val fpsDen = numbers[FfmpegDemuxerJni.INFO_FPS_DEN]
|
||||
if (fpsNum > 0 && fpsDen > 0) {
|
||||
builder.setFrameRate(fpsNum.toFloat() / fpsDen.toFloat())
|
||||
}
|
||||
if (primaryVideo < 0) {
|
||||
primaryVideo = index
|
||||
// libass needs the storage size before the first render; the
|
||||
// Matroska path published it from the Tracks element the same way.
|
||||
if (width > 0 && height > 0) assHandler.setVideoSize(width, height)
|
||||
}
|
||||
}
|
||||
C.TRACK_TYPE_AUDIO -> {
|
||||
builder
|
||||
.setSampleRate(numbers[FfmpegDemuxerJni.INFO_SAMPLE_RATE].toInt())
|
||||
.setChannelCount(numbers[FfmpegDemuxerJni.INFO_CHANNELS].toInt())
|
||||
val pcmEncoding = numbers[FfmpegDemuxerJni.INFO_PCM_ENCODING].toInt()
|
||||
if (pcmEncoding >= 0) builder.setPcmEncoding(pcmEncoding)
|
||||
if (primaryAudio < 0) primaryAudio = index
|
||||
bitrateMeters[index] = StreamBitrateMeter()
|
||||
}
|
||||
C.TRACK_TYPE_TEXT -> {
|
||||
val kind = when (mime) {
|
||||
MimeTypes.TEXT_SSA -> SubtitleKind.SSA
|
||||
MimeTypes.APPLICATION_SUBRIP -> SubtitleKind.SUBRIP
|
||||
MimeTypes.TEXT_VTT -> SubtitleKind.VTT
|
||||
else -> SubtitleKind.NONE
|
||||
}
|
||||
subtitleKinds[index] = kind
|
||||
if (kind == SubtitleKind.SSA) {
|
||||
// Embedded ASS reaches libass as per-sample dialogue, never as a
|
||||
// whole file: AssSubtitleParserFactory keys embedded handling off
|
||||
// the Matroska container mime, and AssHeaderParser reads the
|
||||
// header from initializationData[1] — the exact layout media3's
|
||||
// MatroskaExtractor publishes.
|
||||
builder.setContainerMimeType(MimeTypes.VIDEO_MATROSKA)
|
||||
if (extradata != null) {
|
||||
builder.setInitializationData(
|
||||
listOf(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT, extradata)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val rawOutput = output.track(index, trackType)
|
||||
val trackOutput = if (trackType == C.TRACK_TYPE_AUDIO && numbers[FfmpegDemuxerJni.INFO_LATM] == 1L) {
|
||||
// LOAS/LATM-framed AAC: unwrap to raw access units; the wrapper
|
||||
// swallows this placeholder Format and emits the real one parsed from
|
||||
// the StreamMuxConfig.
|
||||
LatmTrackOutput(rawOutput, index).also { latmOutputs.add(it) }
|
||||
} else {
|
||||
rawOutput
|
||||
}
|
||||
trackOutput.format(builder.build())
|
||||
trackOutputs[index] = trackOutput
|
||||
}
|
||||
|
||||
output.endTracks()
|
||||
output.seekMap(SeekMapImpl())
|
||||
}
|
||||
|
||||
/** Hands embedded font attachments to libass, under the shared budgets. */
|
||||
private fun deliverFontAttachments() {
|
||||
val count = FfmpegDemuxerJni.nativeAttachmentCount()
|
||||
if (count <= 0) return
|
||||
var acceptedBytes = 0L
|
||||
val meta = arrayOfNulls<String>(2)
|
||||
for (ordinal in 0 until count) {
|
||||
val size = FfmpegDemuxerJni.nativeAttachmentInfo(ordinal, meta)
|
||||
if (size <= 0) continue
|
||||
val name = meta[0] ?: continue
|
||||
val mime = meta[1] ?: continue
|
||||
if (mime !in AssFonts.fontMimeTypes) continue
|
||||
val rejectionReason = AssFonts.rejectionReason(size, acceptedBytes)
|
||||
if (rejectionReason != null) {
|
||||
Log.w(TAG, "Skipping embedded font: $rejectionReason (bytes=$size, accepted=$acceptedBytes)")
|
||||
continue
|
||||
}
|
||||
val data = FfmpegDemuxerJni.nativeAttachmentData(ordinal) ?: continue
|
||||
acceptedBytes += size
|
||||
assHandler.addFont(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPacket(): Int {
|
||||
while (true) {
|
||||
val code = FfmpegDemuxerJni.nativeReadPacket(packetBytes, packetOut)
|
||||
when (code) {
|
||||
FfmpegDemuxerJni.CODE_PACKET -> {
|
||||
val streamIndex = packetOut[FfmpegDemuxerJni.OUT_STREAM_INDEX].toInt()
|
||||
val size = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt()
|
||||
val trackOutput = trackOutputs.getOrNull(streamIndex) ?: continue
|
||||
val ptsUs = packetOut[FfmpegDemuxerJni.OUT_PTS_US]
|
||||
val isKeyframe = packetOut[FfmpegDemuxerJni.OUT_FLAGS] and 1L != 0L
|
||||
val subtitleKind = subtitleKinds.getOrNull(streamIndex) ?: SubtitleKind.NONE
|
||||
if (subtitleKind != SubtitleKind.NONE) {
|
||||
// Text samples carry their duration inside the sample text, in
|
||||
// media3's MatroskaExtractor shape; a sample without a duration
|
||||
// cannot be displayed (same skip media3 performs).
|
||||
val sampleDurationUs = packetOut[FfmpegDemuxerJni.OUT_DURATION_US]
|
||||
if (sampleDurationUs <= 0) {
|
||||
Log.w(TAG, "Skipping subtitle sample with no duration (stream=$streamIndex)")
|
||||
continue
|
||||
}
|
||||
val prefix = when (subtitleKind) {
|
||||
SubtitleKind.SSA -> FfmpegSubtitleSamples.ssaPrefix(sampleDurationUs)
|
||||
SubtitleKind.SUBRIP -> FfmpegSubtitleSamples.subripPrefix(sampleDurationUs)
|
||||
else -> FfmpegSubtitleSamples.vttPrefix(sampleDurationUs)
|
||||
}
|
||||
prefixParsable.reset(prefix, prefix.size)
|
||||
trackOutput.sampleData(prefixParsable, prefix.size)
|
||||
packetParsable.reset(packetBytes, size)
|
||||
trackOutput.sampleData(packetParsable, size)
|
||||
trackOutput.sampleMetadata(
|
||||
ptsUs,
|
||||
C.BUFFER_FLAG_KEY_FRAME,
|
||||
prefix.size + size,
|
||||
/* offset= */
|
||||
0,
|
||||
/* cryptoData= */
|
||||
null
|
||||
)
|
||||
return Extractor.RESULT_CONTINUE
|
||||
}
|
||||
packetParsable.reset(packetBytes, size)
|
||||
trackOutput.sampleData(packetParsable, size)
|
||||
// Native drops packets without any timestamp, so ptsUs is always real.
|
||||
trackOutput.sampleMetadata(
|
||||
ptsUs,
|
||||
if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0,
|
||||
size,
|
||||
/* offset= */
|
||||
0,
|
||||
/* cryptoData= */
|
||||
null
|
||||
)
|
||||
deliveredAnyPacket = true
|
||||
bitrateMeters.getOrNull(streamIndex)?.onPacket(ptsUs, size)
|
||||
return Extractor.RESULT_CONTINUE
|
||||
}
|
||||
FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT
|
||||
FfmpegDemuxerJni.CODE_GROW -> {
|
||||
val required = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt()
|
||||
packetBytes = ByteArray(required + 64 * 1024)
|
||||
}
|
||||
FfmpegDemuxerJni.ERR_JAVA ->
|
||||
throw IOException(inputProxy.lastError ?: "demuxer input failed")
|
||||
else -> throw malformed("ffmpeg demuxer read failed: $code")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null)
|
||||
|
||||
private inner class SeekMapImpl : SeekMap {
|
||||
override fun isSeekable(): Boolean = this@FfmpegExtractor.durationUs != C.TIME_UNSET
|
||||
|
||||
/**
|
||||
* The demuxer owns seek resolution: it reads its own index and lands on
|
||||
* the right keyframe by itself, so there is no second index here to keep
|
||||
* consistent. The byte position exists only to satisfy the interface —
|
||||
* [alignLoader] moves the loader to wherever libavformat actually ended
|
||||
* up, on the read that follows the seek.
|
||||
*/
|
||||
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints = SeekMap.SeekPoints(SeekPoint(timeUs, 0))
|
||||
|
||||
// Qualified on purpose: bare `durationUs` binds to the inherited Java
|
||||
// getter as a synthetic property (this.getDurationUs()) and recurses.
|
||||
override fun getDurationUs(): Long = this@FfmpegExtractor.durationUs
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Random-access byte source for the libavformat demuxer.
|
||||
*
|
||||
* libavformat demuxers own their own IO: they jump to the end of the file for an
|
||||
* index (matroska Cues, MP4 moov, AVI idx1), back to the header, and binary
|
||||
* search (MPEG-PS) whenever they please. media3's [
|
||||
* androidx.media3.extractor.ExtractorInput] is forward-only — an extractor may
|
||||
* only ask the loader to re-open elsewhere and be invoked again — so serving
|
||||
* libavformat from the loader alone means every backward jump has to unwind the
|
||||
* in-flight libavformat call and replay it, which is what the demuxer shim used
|
||||
* to do (and what made a matroska Cues read unrecoverable, #2096).
|
||||
*
|
||||
* This class removes the mismatch instead of working around it: reads outside
|
||||
* the loader's window open a second [DataSource] from the same factory media3
|
||||
* uses, positioned exactly where libavformat asked. Cronet, download caches,
|
||||
* SAF and the item's own request headers therefore still apply, because the
|
||||
* [DataSpec] is cloned from the one media3 opened for this item.
|
||||
*
|
||||
* Sequential sample delivery deliberately does not come through here: it stays
|
||||
* on the loader's connection so media3 keeps its byte accounting, back-pressure
|
||||
* and load-error policy (see [FfmpegExtractor]). This source is opened for
|
||||
* header/index bursts and closed again as soon as the loader is back in step.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class FfmpegRandomAccessSource(
|
||||
private val factory: DataSource.Factory,
|
||||
private val specProvider: () -> DataSpec?
|
||||
) {
|
||||
private var source: DataSource? = null
|
||||
|
||||
/** Absolute position the open handle returns next; [C.INDEX_UNSET] when closed. */
|
||||
private var handlePosition = C.INDEX_UNSET.toLong()
|
||||
|
||||
private var resolvedLength = C.LENGTH_UNSET.toLong()
|
||||
|
||||
private var opens = 0
|
||||
|
||||
/** Handles opened so far; the tests assert an index seek does not walk the file. */
|
||||
val openCount: Int
|
||||
get() = synchronized(this) { opens }
|
||||
|
||||
val isOpen: Boolean
|
||||
get() = synchronized(this) { source != null }
|
||||
|
||||
/**
|
||||
* Reads up to [length] bytes at absolute [position]. Returns the number of
|
||||
* bytes read, or 0 at end of input. Reopens the underlying source whenever
|
||||
* the requested position is not where the current handle stands, which is the
|
||||
* only way to move backwards on a byte stream.
|
||||
*/
|
||||
fun readAt(position: Long, buffer: ByteArray, length: Int): Int = synchronized(this) {
|
||||
if (length <= 0) return 0
|
||||
val handle = handleAt(position)
|
||||
val read = handle.read(buffer, 0, length)
|
||||
if (read == C.RESULT_END_OF_INPUT) return 0
|
||||
handlePosition = position + read
|
||||
read
|
||||
}
|
||||
|
||||
/**
|
||||
* Total resource length once some open has resolved it, else
|
||||
* [C.LENGTH_UNSET]. Never performs IO of its own: the extractor prefers the
|
||||
* length media3 already resolved for the loader.
|
||||
*/
|
||||
fun length(): Long = synchronized(this) { resolvedLength }
|
||||
|
||||
fun close() = synchronized(this) {
|
||||
closeHandle()
|
||||
handlePosition = C.INDEX_UNSET.toLong()
|
||||
}
|
||||
|
||||
private fun handleAt(position: Long): DataSource {
|
||||
val existing = source
|
||||
if (existing != null && handlePosition == position) return existing
|
||||
closeHandle()
|
||||
val spec = specProvider() ?: throw IOException("ffmpeg demuxer has no data spec for random access")
|
||||
// Clone rather than subrange: the recorded spec carries the loader's own
|
||||
// position, and subrange() is relative to it.
|
||||
val positioned = spec.buildUpon()
|
||||
.setPosition(position)
|
||||
.setLength(C.LENGTH_UNSET.toLong())
|
||||
.build()
|
||||
val created = factory.createDataSource()
|
||||
val remaining = try {
|
||||
created.open(positioned)
|
||||
} catch (e: Throwable) {
|
||||
closeQuietly(created)
|
||||
throw e
|
||||
}
|
||||
source = created
|
||||
handlePosition = position
|
||||
opens++
|
||||
if (remaining != C.LENGTH_UNSET.toLong()) resolvedLength = position + remaining
|
||||
return created
|
||||
}
|
||||
|
||||
private fun closeHandle() {
|
||||
source?.let { closeQuietly(it) }
|
||||
source = null
|
||||
}
|
||||
|
||||
private fun closeQuietly(dataSource: DataSource) {
|
||||
try {
|
||||
dataSource.close()
|
||||
} catch (_: IOException) {
|
||||
// Closing a handle we are abandoning cannot fail the read that replaces it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [DataSource.Factory] that remembers the [DataSpec] media3 opened for each
|
||||
* item, so [FfmpegRandomAccessSource] can clone it — same URI, cache key, flags
|
||||
* and request headers — instead of re-deriving one and silently losing, say, a
|
||||
* download's cache key.
|
||||
*
|
||||
* Keyed by URI because ExoPlayer prepares the next item's period while the
|
||||
* current one is still loading; the demuxer must never read the next item's
|
||||
* bytes.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class RecordingDataSourceFactory(private val delegate: DataSource.Factory) : DataSource.Factory {
|
||||
|
||||
private val specs = object : LinkedHashMap<Uri, DataSpec>(8, 0.75f, false) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Uri, DataSpec>): Boolean = size > MAX_TRACKED_ITEMS
|
||||
}
|
||||
|
||||
fun specFor(uri: Uri): DataSpec? = synchronized(specs) { specs[uri] }
|
||||
|
||||
override fun createDataSource(): DataSource = RecordingDataSource(delegate.createDataSource())
|
||||
|
||||
private fun record(dataSpec: DataSpec) {
|
||||
synchronized(specs) { specs[dataSpec.uri] = dataSpec }
|
||||
}
|
||||
|
||||
private inner class RecordingDataSource(private val inner: DataSource) : DataSource {
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
record(dataSpec)
|
||||
return inner.open(dataSpec)
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int = inner.read(buffer, offset, length)
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener)
|
||||
|
||||
override fun getUri(): Uri? = inner.uri
|
||||
|
||||
override fun getResponseHeaders(): Map<String, List<String>> = inner.responseHeaders
|
||||
|
||||
override fun close() = inner.close()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_TRACKED_ITEMS = 4
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Builds text subtitle samples in the exact shape media3's MatroskaExtractor
|
||||
* writes, so the same downstream consumers work behind the FFmpeg demuxer:
|
||||
* the bundled SsaParser/SubripParser/WebvttParser (via
|
||||
* SubtitleTranscodingExtractorOutput) and AssTrackOutput's dialogue feed.
|
||||
*
|
||||
* MatroskaExtractor prefixes each raw payload with a synthetic header whose
|
||||
* *second* timecode carries the sample duration (BlockDuration), while the
|
||||
* first stays zero; parsers reconstruct absolute times as sampleTimeUs +
|
||||
* parsedStart. FFmpeg hands us the same raw payloads (an ASS packet is the
|
||||
* MKV block: "ReadOrder,Layer,Style,...,Text"; SubRip/WebVTT packets are the
|
||||
* bare cue text), so only the prefix needs synthesizing.
|
||||
*/
|
||||
internal object FfmpegSubtitleSamples {
|
||||
|
||||
val SSA_DIALOGUE_FORMAT: ByteArray =
|
||||
"Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** `Dialogue: 0:00:00:00,H:MM:SS:CC,` — centiseconds, ':' separator (media3's SSA shape). */
|
||||
fun ssaPrefix(durationUs: Long): ByteArray = "Dialogue: 0:00:00:00,${timecode(durationUs, "%01d:%02d:%02d:%02d", 10_000)},"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** `1\n00:00:00,000 --> HH:MM:SS,mmm\n` — a complete SRT cue header. */
|
||||
fun subripPrefix(durationUs: Long): ByteArray = "1\n00:00:00,000 --> ${timecode(durationUs, "%02d:%02d:%02d,%03d", 1_000)}\n"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** `WEBVTT\n\n00:00:00.000 --> HH:MM:SS.mmm\n` — a one-cue WebVTT document header. */
|
||||
fun vttPrefix(durationUs: Long): ByteArray = "WEBVTT\n\n00:00:00.000 --> ${timecode(durationUs, "%02d:%02d:%02d.%03d", 1_000)}\n"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
|
||||
private fun timecode(timeUs: Long, format: String, lastValueDivisor: Long): String {
|
||||
val hours = timeUs / 3_600_000_000L
|
||||
var remainder = timeUs - hours * 3_600_000_000L
|
||||
val minutes = remainder / 60_000_000L
|
||||
remainder -= minutes * 60_000_000L
|
||||
val seconds = remainder / 1_000_000L
|
||||
remainder -= seconds * 1_000_000L
|
||||
val lastValue = remainder / lastValueDivisor
|
||||
return String.format(Locale.US, format, hours, minutes, seconds, lastValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
|
||||
/** MKV CodecID for Microsoft ACM compatibility mode. */
|
||||
private const val CODEC_ID_ACM = "A_MS/ACM"
|
||||
|
||||
/**
|
||||
* MatroskaExtractor.init is final and extractorOutput private; subclasses swap
|
||||
* in wrapping outputs via reflection once the Segment element starts (shared
|
||||
* with ZlibMatroskaExtractor).
|
||||
*/
|
||||
internal val matroskaExtractorOutputField by lazy {
|
||||
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
}
|
||||
|
||||
/** WAVEFORMATEX format tag for LOAS/LATM-wrapped AAC (WAVE_FORMAT_MPEG_LOAS). */
|
||||
private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
|
||||
|
||||
/**
|
||||
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's
|
||||
* fallback mapping for aac_latm, which Matroska has no native codec ID for.
|
||||
* The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate.
|
||||
*/
|
||||
fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
|
||||
codecPrivate != null &&
|
||||
codecPrivate.size >= 2 &&
|
||||
((codecPrivate[0].toInt() and 0xFF) or ((codecPrivate[1].toInt() and 0xFF) shl 8)) == WAVE_FORMAT_MPEG_LOAS
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that wraps marked tracks with [LatmTrackOutput].
|
||||
* Call [markNextTrackLatm] before the parent extractor creates the track
|
||||
* (i.e. before super.endMasterElement(ID_TRACK_ENTRY)).
|
||||
*/
|
||||
class LatmExtractorOutputWrapper(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
private var nextTrackIsLatm = false
|
||||
private val latmOutputs = mutableListOf<LatmTrackOutput>()
|
||||
|
||||
fun markNextTrackLatm() {
|
||||
nextTrackIsLatm = true
|
||||
}
|
||||
|
||||
/** Resets LATM parser state after an extractor seek. */
|
||||
fun resetTracks() {
|
||||
latmOutputs.forEach { it.reset() }
|
||||
}
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
if (!nextTrackIsLatm) return original
|
||||
nextTrackIsLatm = false
|
||||
return LatmTrackOutput(original, id).also { latmOutputs.add(it) }
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
|
||||
/**
|
||||
* Delegates everything to [delegate] but hands it a wrapped [ExtractorOutput].
|
||||
* Used to compose the text pipeline (libass dialogue feed + subtitle cue
|
||||
* transcoding) around media3's raw-subtitle MatroskaExtractor without
|
||||
* subclassing or reflection.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal class OutputWrappingExtractor(
|
||||
private val delegate: Extractor,
|
||||
private val wrap: (ExtractorOutput) -> ExtractorOutput
|
||||
) : Extractor {
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) = delegate.init(wrap(output))
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
|
||||
|
||||
override fun release() = delegate.release()
|
||||
|
||||
override fun getUnderlyingImplementation(): Extractor = delegate.underlyingImplementation
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Rolling average bitrate of one demuxed stream, measured from packet sizes
|
||||
* over their presentation-time span — the same source of truth mpv uses for
|
||||
* its `audio-bitrate` property. It exists because containers frequently
|
||||
* declare no per-track bitrate (Matroska without BPS statistics tags), which
|
||||
* left the performance overlay with nothing to show (#2063).
|
||||
*
|
||||
* Fed from the loader thread ([onPacket]) and read from the stats path
|
||||
* ([bitrateBps]); both are synchronized. The measurement follows the demux
|
||||
* position, which runs ahead of the playhead by the buffered duration —
|
||||
* acceptable for a diagnostics overlay.
|
||||
*/
|
||||
internal class StreamBitrateMeter(private val windowUs: Long = DEFAULT_WINDOW_US) {
|
||||
|
||||
private val ptsUsQueue = ArrayDeque<Long>()
|
||||
private val sizeQueue = ArrayDeque<Int>()
|
||||
private var windowBytes = 0L
|
||||
|
||||
@Synchronized
|
||||
fun onPacket(ptsUs: Long, sizeBytes: Int) {
|
||||
if (sizeBytes <= 0) return
|
||||
// A backwards pts jump is a seek; packets on either side of it do not
|
||||
// form a contiguous span, so the window restarts.
|
||||
val last = ptsUsQueue.lastOrNull()
|
||||
if (last != null && ptsUs < last) reset()
|
||||
ptsUsQueue.addLast(ptsUs)
|
||||
sizeQueue.addLast(sizeBytes)
|
||||
windowBytes += sizeBytes
|
||||
while (ptsUsQueue.size > 1 && ptsUsQueue.last() - ptsUsQueue.first() > windowUs) {
|
||||
ptsUsQueue.removeFirst()
|
||||
windowBytes -= sizeQueue.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Average bitrate in bits per second over the current window, or null until
|
||||
* enough packets span a measurable interval.
|
||||
*/
|
||||
@Synchronized
|
||||
fun bitrateBps(): Int? {
|
||||
if (ptsUsQueue.size < MIN_PACKETS) return null
|
||||
val spanUs = ptsUsQueue.last() - ptsUsQueue.first()
|
||||
if (spanUs <= 0) return null
|
||||
// The pts span covers the durations of every packet except the last one,
|
||||
// whose bytes are therefore excluded — a constant-rate stream measures
|
||||
// exactly its rate.
|
||||
val bytes = windowBytes - sizeQueue.last()
|
||||
if (bytes <= 0) return null
|
||||
return (bytes * 8_000_000L / spanUs).toInt()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
ptsUsQueue.clear()
|
||||
sizeQueue.clear()
|
||||
windowBytes = 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_WINDOW_US = 10_000_000L
|
||||
private const val MIN_PACKETS = 16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Inflater
|
||||
|
||||
/**
|
||||
* TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0).
|
||||
* Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample
|
||||
* between sampleData() and sampleMetadata() calls.
|
||||
*
|
||||
* All buffers are reused across samples to minimize GC pressure on the hot path.
|
||||
*/
|
||||
class ZlibInflatingTrackOutput(
|
||||
delegate: TrackOutput
|
||||
) : BufferedTransformingTrackOutput(
|
||||
delegate,
|
||||
INITIAL_BUFFER_SIZE,
|
||||
INFLATE_CHUNK,
|
||||
MAX_COMPRESSED_SAMPLE_SIZE
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val INITIAL_BUFFER_SIZE = 256 * 1024
|
||||
private const val INFLATE_CHUNK = 64 * 1024
|
||||
private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
|
||||
private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
|
||||
private const val MAX_COMPRESSION_RATIO = 1024L
|
||||
private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
|
||||
}
|
||||
|
||||
var active = false
|
||||
|
||||
private val inflater = Inflater()
|
||||
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private val overflowProbe = ByteArray(1)
|
||||
|
||||
override val transformEnabled: Boolean
|
||||
get() = active
|
||||
|
||||
override val transformedBuffer: ByteArray
|
||||
get() = inflateBuf
|
||||
|
||||
override fun transformSample(inputLength: Int, flags: Int): Int {
|
||||
inflater.reset()
|
||||
inflater.setInput(inputBuffer, 0, inputLength)
|
||||
var written = 0
|
||||
val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (written == inflateBuf.size) {
|
||||
if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
|
||||
val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
|
||||
inflateBuf = inflateBuf.copyOf(nextSize)
|
||||
} else {
|
||||
val overflow = inflater.inflate(overflowProbe, 0, 1)
|
||||
if (overflow > 0) {
|
||||
throw malformed("Inflated sample exceeds the maximum size")
|
||||
}
|
||||
if (inflater.finished()) return written
|
||||
throw stalledInflate()
|
||||
}
|
||||
}
|
||||
|
||||
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
||||
written += count
|
||||
if (written.toLong() > ratioBound) {
|
||||
throw malformed("Inflated sample exceeds the maximum compression ratio")
|
||||
}
|
||||
if (inflater.finished()) return written
|
||||
if (count == 0) throw stalledInflate()
|
||||
}
|
||||
} catch (_: DataFormatException) {
|
||||
// Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
|
||||
// by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
|
||||
if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
|
||||
inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
|
||||
return inputLength
|
||||
}
|
||||
}
|
||||
|
||||
private fun stalledInflate(): ParserException = when {
|
||||
inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
|
||||
inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
|
||||
else -> malformed("Zlib decompressor made no progress")
|
||||
}
|
||||
|
||||
private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
import java.io.EOFException
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Inflater
|
||||
|
||||
/**
|
||||
* Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
|
||||
*
|
||||
* ContentCompAlgo 0 (zlib) — media3 only supports ContentCompAlgo 3 (header
|
||||
* stripping). This subclass intercepts the compression algorithm during track
|
||||
* header parsing:
|
||||
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
|
||||
* - Skips ContentCompSettings for zlib tracks (not applicable)
|
||||
* - For text subtitle tracks, inflates the block payload *before* the parent
|
||||
* parses it (see below). For every other zlib track, wraps TrackOutputs with
|
||||
* ZlibInflatingTrackOutput to decompress per-sample data.
|
||||
*
|
||||
* Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput
|
||||
* level: MatroskaExtractor rewrites their samples in-place before any TrackOutput
|
||||
* runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to
|
||||
* the still-compressed payload and truncates the sample at the first NUL byte,
|
||||
* which deflate streams routinely contain (#2023). AssTrackOutput additionally
|
||||
* feeds that same internal buffer straight to libass. So for zlib text tracks the
|
||||
* frame payload is inflated at the block level, before the parent's sample
|
||||
* assembly, and the TrackOutput wrapper is left inactive.
|
||||
*
|
||||
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
|
||||
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
|
||||
* which unwraps LOAS frames to raw AAC for direct-playing Matroska files.
|
||||
*/
|
||||
class ZlibMatroskaExtractor(
|
||||
subtitleParserFactory: SubtitleParser.Factory,
|
||||
assHandler: AssHandler
|
||||
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ZlibMkvExtractor"
|
||||
|
||||
// Matroska EBML element IDs
|
||||
private const val ID_SEGMENT = 0x18538067
|
||||
private const val ID_TRACK_ENTRY = 0xAE
|
||||
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
|
||||
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
|
||||
private const val ID_CONTENT_COMPRESSION = 0x5034
|
||||
private const val ID_SIMPLE_BLOCK = 0xA3
|
||||
private const val ID_BLOCK = 0xA1
|
||||
|
||||
/**
|
||||
* Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix
|
||||
* plus NUL truncation) before any TrackOutput runs — the authority is the
|
||||
* prefix list in MatroskaExtractor.writeSampleData.
|
||||
*/
|
||||
private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT")
|
||||
|
||||
/** Subtitle blocks are tiny; anything above this passes through untouched. */
|
||||
private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024
|
||||
}
|
||||
|
||||
private var zlibOutput: ZlibExtractorOutputWrapper? = null
|
||||
private var latmOutput: LatmExtractorOutputWrapper? = null
|
||||
private var currentTrackUsesZlib = false
|
||||
|
||||
/** Track numbers whose blocks are inflated before the parent parses them. */
|
||||
private val zlibTextTrackNumbers = mutableSetOf<Int>()
|
||||
private val blockInflater = Inflater()
|
||||
private var blockBuf = ByteArray(0)
|
||||
private val peekBuf = ByteArray(8)
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
|
||||
// ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for
|
||||
// zlib tracks entirely — the mere presence of ContentCompression means zlib
|
||||
// until an explicit ContentCompAlgo says otherwise. media3 ignores an empty
|
||||
// ContentCompression and would silently emit compressed samples.
|
||||
if (id == ID_CONTENT_COMPRESSION) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise")
|
||||
}
|
||||
|
||||
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
|
||||
// LATM layers (zlib outermost so inflation runs before LATM parsing).
|
||||
if (id == ID_SEGMENT && zlibOutput == null) {
|
||||
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
|
||||
val latmWrapper = LatmExtractorOutputWrapper(currentOutput)
|
||||
latmOutput = latmWrapper
|
||||
val wrapper = ZlibExtractorOutputWrapper(latmWrapper)
|
||||
zlibOutput = wrapper
|
||||
matroskaExtractorOutputField.set(this, wrapper)
|
||||
Log.d(TAG, "Installed zlib+LATM ExtractorOutput wrapper")
|
||||
}
|
||||
}
|
||||
|
||||
override fun integerElement(id: Int, value: Long) {
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM) {
|
||||
if (value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
}
|
||||
// Explicit non-zlib algorithm: header stripping (3) is handled by the
|
||||
// parent; anything else makes the parent throw, matching stock behavior.
|
||||
currentTrackUsesZlib = false
|
||||
}
|
||||
super.integerElement(id, value)
|
||||
}
|
||||
|
||||
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
|
||||
// Skip ContentCompSettings for zlib tracks — parent would store these as
|
||||
// sampleStrippedBytes and prepend them to every sample, corrupting output.
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) &&
|
||||
zlibTextTrackNumbers.isNotEmpty() &&
|
||||
contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES &&
|
||||
peekBlockTrackNumber(input) in zlibTextTrackNumbers
|
||||
) {
|
||||
inflateTextBlock(id, contentSize, input)
|
||||
return
|
||||
}
|
||||
super.binaryElement(id, contentSize, input)
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
var zlibTextTrackNumber: Int? = null
|
||||
if (id == ID_TRACK_ENTRY) {
|
||||
// Must inspect before super — the track output is created inside super's
|
||||
// endMasterElement, and the current track is cleared afterwards.
|
||||
val track = getCurrentTrack(id)
|
||||
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
|
||||
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
|
||||
latmOutput?.markNextTrackLatm()
|
||||
}
|
||||
if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) {
|
||||
zlibTextTrackNumber = track.number
|
||||
}
|
||||
}
|
||||
|
||||
val wasZlib = currentTrackUsesZlib
|
||||
super.endMasterElement(id)
|
||||
|
||||
if (id == ID_TRACK_ENTRY && wasZlib) {
|
||||
currentTrackUsesZlib = false
|
||||
if (zlibTextTrackNumber != null) {
|
||||
// Text subtitle samples are rewritten inside the parent before any
|
||||
// TrackOutput runs, so the TrackOutput wrapper stays inactive and the
|
||||
// block payload is inflated in binaryElement instead.
|
||||
zlibTextTrackNumbers.add(zlibTextTrackNumber)
|
||||
Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level")
|
||||
} else {
|
||||
zlibOutput?.activateLast()
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
latmOutput?.resetTracks()
|
||||
zlibOutput?.resetTracks()
|
||||
super.seek(position, timeUs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Peeks the EBML varint at the block start — the block's track number — without
|
||||
* consuming input. Returns null for malformed varints or truncated input; the
|
||||
* parent then produces the canonical failure for the untouched stream.
|
||||
*/
|
||||
private fun peekBlockTrackNumber(input: ExtractorInput): Int? {
|
||||
try {
|
||||
input.peekFully(peekBuf, 0, 1)
|
||||
val first = peekBuf[0].toInt() and 0xFF
|
||||
if (first == 0) return null
|
||||
val length = Integer.numberOfLeadingZeros(first) - 23
|
||||
var value = (first and (0xFF ushr length)).toLong()
|
||||
if (length > 1) {
|
||||
input.peekFully(peekBuf, 1, length - 1)
|
||||
for (i in 1 until length) {
|
||||
value = (value shl 8) or (peekBuf[i].toLong() and 0xFF)
|
||||
}
|
||||
}
|
||||
return if (value <= Int.MAX_VALUE) value.toInt() else null
|
||||
} catch (_: EOFException) {
|
||||
return null
|
||||
} finally {
|
||||
input.resetPeekPosition()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers one text-subtitle block, inflates its frame payload, and hands the
|
||||
* parent a block whose payload is plaintext. A block that cannot be rewritten
|
||||
* (laced, corrupt, or over-bound) passes through byte-identical.
|
||||
*/
|
||||
private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
val basePosition = input.position
|
||||
if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2))
|
||||
input.readFully(blockBuf, 0, contentSize)
|
||||
val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater)
|
||||
if (rewritten == null) {
|
||||
Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)")
|
||||
super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition))
|
||||
} else {
|
||||
super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition))
|
||||
}
|
||||
}
|
||||
|
||||
private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit)
|
||||
|
||||
private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader {
|
||||
private var position = 0
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position == limit) return C.RESULT_END_OF_INPUT
|
||||
val count = minOf(length, limit - position)
|
||||
System.arraycopy(data, position, buffer, offset, count)
|
||||
position += count
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
|
||||
* Tracks are created inactive; activateLast() enables inflation for the most recently
|
||||
* created track (called when we know a track uses zlib compression).
|
||||
*/
|
||||
private class ZlibExtractorOutputWrapper(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
|
||||
private val trackOutputs = mutableListOf<ZlibInflatingTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
return ZlibInflatingTrackOutput(original).also {
|
||||
trackOutputs.add(it)
|
||||
lastCreatedWrapper = it
|
||||
}
|
||||
}
|
||||
|
||||
fun activateLast() {
|
||||
lastCreatedWrapper?.active = true
|
||||
}
|
||||
|
||||
fun resetTracks() {
|
||||
trackOutputs.forEach { it.resetBufferedData() }
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */
|
||||
internal const val MIN_BLOCK_BYTES = 5
|
||||
|
||||
/**
|
||||
* Rewrites one unlaced Matroska block whose frame payload is zlib-compressed:
|
||||
* returns the unchanged block header followed by the inflated payload, or null
|
||||
* when the block must pass through unchanged — lacing (never produced for text
|
||||
* subtitle tracks), a truncated or corrupt deflate stream, or an inflated size
|
||||
* beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening.
|
||||
*/
|
||||
internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? {
|
||||
if (limit < MIN_BLOCK_BYTES) return null
|
||||
val first = data[0].toInt() and 0xFF
|
||||
if (first == 0) return null // track-number varint longer than 8 bytes
|
||||
val varintLength = Integer.numberOfLeadingZeros(first) - 23
|
||||
val headerLength = varintLength + 3 // varint + 2-byte timecode + flags
|
||||
if (limit <= headerLength) return null
|
||||
if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced
|
||||
|
||||
val payloadLength = limit - headerLength
|
||||
val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024)
|
||||
val maxInflatedBytes = 16 * 1024 * 1024
|
||||
inflater.reset()
|
||||
inflater.setInput(data, headerLength, payloadLength)
|
||||
var buf = ByteArray(maxOf(4096, payloadLength * 4))
|
||||
var written = 0
|
||||
try {
|
||||
while (true) {
|
||||
if (written == buf.size) {
|
||||
if (buf.size >= maxInflatedBytes) return null
|
||||
buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2))
|
||||
}
|
||||
val count = inflater.inflate(buf, written, buf.size - written)
|
||||
written += count
|
||||
if (written > ratioBound) return null
|
||||
if (inflater.finished()) break
|
||||
if (count == 0) return null // truncated stream or preset-dictionary request
|
||||
}
|
||||
} catch (_: DataFormatException) {
|
||||
return null
|
||||
}
|
||||
return ByteArray(headerLength + written).also {
|
||||
System.arraycopy(data, 0, it, 0, headerLength)
|
||||
System.arraycopy(buf, 0, it, headerLength, written)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
/**
|
||||
* Demuxer cache budget derived from the device heap class.
|
||||
*
|
||||
* mpv has no device-memory awareness: `demuxer-max-bytes` defaults to a fixed
|
||||
* 150 MiB forward (+50 MiB back) on every device and the demuxer fills
|
||||
* whatever it is allowed, which crowds a 1 GB TV box until Android's
|
||||
* low-memory killer takes the whole app. Tiered off
|
||||
* [android.app.ActivityManager.getLargeMemoryClass], the same signal
|
||||
* `stream_buffer_sizing.dart` and ExoPlayer's `LoadControlPolicy` use.
|
||||
*
|
||||
* Applied as pre-init *options* in [MpvPlayerCore], so a `demuxer-max-bytes`
|
||||
* line in the user's mpv.conf still wins.
|
||||
*/
|
||||
data class DemuxerBudget(val aheadBytes: Long, val backBytes: Long) {
|
||||
companion object {
|
||||
private const val MIB = 1024L * 1024L
|
||||
|
||||
/** Null for an unknown class (<= 0): callers keep mpv's own defaults. */
|
||||
fun forHeapClassMB(largeMemoryClassMB: Int): DemuxerBudget? = when {
|
||||
largeMemoryClassMB <= 0 -> null
|
||||
largeMemoryClassMB <= 256 -> DemuxerBudget(aheadBytes = 32 * MIB, backBytes = 16 * MIB)
|
||||
largeMemoryClassMB <= 512 -> DemuxerBudget(aheadBytes = 64 * MIB, backBytes = 32 * MIB)
|
||||
else -> DemuxerBudget(aheadBytes = 100 * MIB, backBytes = 48 * MIB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.opengl.EGL14
|
||||
|
||||
/**
|
||||
* Which mpv `egl-output-format` this device can pair with a BT.2020 PQ window
|
||||
* surface, or null when HDR GL output is unavailable. 10-bit fixed point is
|
||||
* preferred; fp16 is the fallback because some drivers (Tegra among them)
|
||||
* expose the PQ colorspace but no 1010102 window config.
|
||||
*
|
||||
* Both halves are required by the mpv side: the fork's android GL context asks
|
||||
* for `EGL_EXT_gl_colorspace_bt2020_pq` on the window surface, and the app
|
||||
* pairs it with an `egl-output-format` that makes mpv's EGL config selection
|
||||
* fail outright when no matching config exists - so this probe must be
|
||||
* consulted before those options are ever set.
|
||||
*/
|
||||
internal object EglHdrCaps {
|
||||
private const val EGL_COLOR_COMPONENT_TYPE_EXT = 0x3339
|
||||
private const val EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT = 0x333B
|
||||
|
||||
private object Unprobed
|
||||
|
||||
@Volatile private var cached: Any? = Unprobed
|
||||
|
||||
fun pqOutputFormat(): String? {
|
||||
val value = cached
|
||||
if (value !== Unprobed) return value as String?
|
||||
return probe().also { cached = it }
|
||||
}
|
||||
|
||||
private fun probe(): String? {
|
||||
val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (display == EGL14.EGL_NO_DISPLAY) return null
|
||||
val version = IntArray(2)
|
||||
// Deliberately no eglTerminate: the default display is process-global and
|
||||
// Flutter's renderer shares it; terminating would invalidate its state.
|
||||
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
|
||||
val extensions = EGL14.eglQueryString(display, EGL14.EGL_EXTENSIONS) ?: return null
|
||||
if (!extensions.contains("EGL_EXT_gl_colorspace_bt2020_pq")) return null
|
||||
if (hasWindowConfig(display, intArrayOf(EGL14.EGL_RED_SIZE, 10, EGL14.EGL_GREEN_SIZE, 10, EGL14.EGL_BLUE_SIZE, 10, EGL14.EGL_ALPHA_SIZE, 2))) {
|
||||
return "rgb10_a2"
|
||||
}
|
||||
if (extensions.contains("EGL_EXT_pixel_format_float") &&
|
||||
hasWindowConfig(
|
||||
display,
|
||||
intArrayOf(
|
||||
EGL14.EGL_RED_SIZE,
|
||||
16,
|
||||
EGL14.EGL_GREEN_SIZE,
|
||||
16,
|
||||
EGL14.EGL_BLUE_SIZE,
|
||||
16,
|
||||
EGL_COLOR_COMPONENT_TYPE_EXT,
|
||||
EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "rgba16f"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasWindowConfig(display: android.opengl.EGLDisplay, extra: IntArray): Boolean {
|
||||
val attribs = intArrayOf(
|
||||
EGL14.EGL_SURFACE_TYPE,
|
||||
EGL14.EGL_WINDOW_BIT,
|
||||
EGL14.EGL_RENDERABLE_TYPE,
|
||||
EGL14.EGL_OPENGL_ES2_BIT
|
||||
) + extra + intArrayOf(EGL14.EGL_NONE)
|
||||
val numConfigs = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(display, attribs, 0, null, 0, 0, numConfigs, 0)) return false
|
||||
return numConfigs[0] > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
/**
|
||||
* Pure policy for when an mpv session must leave the video plane
|
||||
* (vo=mediacodec) for a GL video output, and which one. Kept free of player
|
||||
* and platform state so the routing matrix is unit-testable.
|
||||
*/
|
||||
internal object GpuVoPolicy {
|
||||
/**
|
||||
* Single-layer Dolby Vision Profile 5 (IPT-PQ-c2) has no compatible base
|
||||
* layer: on a device without native DV support it decodes as plain HEVC
|
||||
* with garbage colors, and the video plane applies no reshaping. gpu-next
|
||||
* (libplacebo) under software decode is the only Android path that
|
||||
* composites the RPU metadata (#1902). [dvProfile] comes from mpv's
|
||||
* track-list — the bitstream's DOVI configuration record — never from
|
||||
* server metadata, which mis-tags DV routinely. Only `auto` routes; the
|
||||
* other modes are explicit user choices.
|
||||
*/
|
||||
fun needsDvReshaping(dvProfile: Long?, conversionMode: String, canPlayP5Natively: Boolean): Boolean = dvProfile == 5L && conversionMode == "auto" && !canPlayP5Natively
|
||||
|
||||
/**
|
||||
* Whether an HDR signal has nowhere to tone-map: the video plane hands
|
||||
* PQ/HLG straight to a display pipeline that advertises no HDR output, so
|
||||
* it renders washed out (#2121). The GL vo tone-maps in the render chain.
|
||||
*/
|
||||
fun needsHdrToneMapping(gamma: String?, displaySupportsHdr: Boolean): Boolean = (gamma == "pq" || gamma == "hlg") && !displaySupportsHdr
|
||||
|
||||
/**
|
||||
* Whether the decoder is handing mpv software frames, from `hwdec-current`.
|
||||
*
|
||||
* The plane refuses every format but MediaCodec buffers, so a per-file
|
||||
* decode fallback (AV1 on Tegra, Hi10 without a profile match) has to move
|
||||
* to a GL vo. Routing on this gets there before mpv fails the chain and
|
||||
* [REASON_CHAIN_FAILURE] has to catch it.
|
||||
*/
|
||||
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
|
||||
|
||||
/**
|
||||
* The vo a session with these active requirements should run, or null for
|
||||
* the video plane.
|
||||
*
|
||||
* dv-reshape is the only reason that needs gpu-next, since libplacebo is
|
||||
* what composites the RPU. Everything else takes gpu, the battle-tested
|
||||
* GLES renderer on the Android device zoo. (The magenta field gpu-next
|
||||
* used to render for 10-bit software frames on Tegra was its AV1 film
|
||||
* grain shader overrunning the driver's uniform register budget; grain is
|
||||
* decoder-applied now, but gpu-next buys this path nothing over gpu.)
|
||||
*/
|
||||
fun targetFor(reasons: Set<String>): String? = when {
|
||||
reasons.isEmpty() -> null
|
||||
REASON_DV_RESHAPE in reasons -> "gpu-next"
|
||||
else -> "gpu"
|
||||
}
|
||||
|
||||
const val REASON_DV_RESHAPE = "dv-reshape"
|
||||
const val REASON_SHADERS = "shaders"
|
||||
const val REASON_CHAIN_FAILURE = "chain-failure"
|
||||
const val REASON_HDR_SDR = "hdr-sdr"
|
||||
const val REASON_SW_DECODE = "sw-decode"
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import dev.jdtech.mpv.EndFileReason
|
||||
import dev.jdtech.mpv.LogLevel
|
||||
import dev.jdtech.mpv.LogMessage
|
||||
import dev.jdtech.mpv.MpvEvent
|
||||
import com.edde746.plezy.libmpv.EndFileReason
|
||||
import com.edde746.plezy.libmpv.LogLevel
|
||||
import com.edde746.plezy.libmpv.LogMessage
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
|
||||
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
|
||||
internal class MpvEndFileDiagnostics {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.AudioAttributes
|
||||
@@ -15,12 +16,13 @@ import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import com.edde746.plezy.exoplayer.DoviBridge
|
||||
import com.edde746.plezy.libmpv.*
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import dev.jdtech.mpv.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -63,24 +65,41 @@ class MpvPlayerCore private constructor(
|
||||
* The initial `vo` chain, decided by whether this session will hardware-
|
||||
* decode.
|
||||
*
|
||||
* gpu-next (libplacebo) is the only Android path that applies Dolby Vision
|
||||
* RPU reshaping (#1902), but reshaping only ever happens under software
|
||||
* decode: FFmpeg's mediacodec wrapper exports no DOVI side data, so a
|
||||
* hardware-decoded stream renders the untouched base layer on any VO.
|
||||
* Hardware decode is also where gpu-next breaks: it samples the decoder
|
||||
* output as a samplerExternalOES that libplacebo declares in both shader
|
||||
* stages, and the Tegra GLES linker rejects that pair ("struct type
|
||||
* mismatch between shaders for uniform"), failing every frame — a solid
|
||||
* blue screen with audio on the Shield (#2010). The in-chain gpu fallback
|
||||
* cannot catch it because gpu-next initializes fine and only fails
|
||||
* per-frame renders.
|
||||
* Hardware sessions use the fork vo=mediacodec: decoded buffers go from
|
||||
* MediaCodec straight to the compositor with per-frame presentation
|
||||
* timestamps - no GLES pass, 10-bit and the decoder's dataspace
|
||||
* (HDR10/HLG) intact - and subtitles/OSD render on the sibling OSD
|
||||
* surface. The plane takes decoder buffers only and refuses the rest, so
|
||||
* a per-file decode fallback moves to a GL vo
|
||||
* ([GpuVoPolicy.needsSoftwareRender], with the chain-failure watchdog as
|
||||
* the backstop). gpu stays in the chain for preinit failure.
|
||||
*
|
||||
* So gpu-next is offered exactly where it can help — sessions that will
|
||||
* software-decode — and hardware sessions keep the legacy gpu VO. A
|
||||
* mid-session hwdec fallback to software stays on vo=gpu, which renders
|
||||
* software frames correctly (the pre-2.15.0 behavior for every session).
|
||||
* Software sessions run gpu,gpu-next: gpu is the battle-tested GLES
|
||||
* renderer on the Android device zoo, and with film grain applied by the
|
||||
* decoder nothing else on this path needs libplacebo. Dolby Vision RPU
|
||||
* reshaping (#1902) is the one exception - it needs gpu-next, and the
|
||||
* [GpuVoPolicy.REASON_DV_RESHAPE] observer moves the session there when a
|
||||
* DV profile that needs reshaping appears. gpu-next under *hardware*
|
||||
* decode is broken on Tegra (samplerExternalOES double declaration
|
||||
* rejected by the GLES linker, blue screen on the Shield, #2010);
|
||||
* vo=mediacodec sidesteps that entire class by never touching GLES.
|
||||
*/
|
||||
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "gpu" else "gpu-next,gpu"
|
||||
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "mediacodec,gpu" else "gpu,gpu-next"
|
||||
|
||||
/**
|
||||
* The `-append` list-option suffixes are not exposed through the property
|
||||
* interface, so the app's decoder options replace the whole list. FFmpeg
|
||||
* keeps the last duplicate key, so any user mpv.conf entries go first.
|
||||
*/
|
||||
internal fun mergeDecoderOptions(current: String?, ours: String): String = if (current.isNullOrBlank()) ours else "$current,$ours"
|
||||
|
||||
/**
|
||||
* Whether content with this transfer is worth an HDR (BT.2020 PQ) GL
|
||||
* surface. PQ and HLG both render into a PQ target; everything else -
|
||||
* including unknown - stays on the default sRGB surface, which renders
|
||||
* every content correctly (HDR arrives tone-mapped, as before).
|
||||
*/
|
||||
internal fun wantsHdrSurface(transfer: String?): Boolean = transfer == "smpte2084" || transfer == "arib-std-b67"
|
||||
}
|
||||
|
||||
/** Video-only paths. The plugin always constructs video cores with the
|
||||
@@ -89,7 +108,57 @@ class MpvPlayerCore private constructor(
|
||||
get() = context as Activity
|
||||
|
||||
private var surfaceView: SurfaceView? = null
|
||||
private var osdSurfaceView: SurfaceView? = null
|
||||
private var surfaceContainer: android.widget.FrameLayout? = null
|
||||
|
||||
@Volatile private var pendingOsdSurface: Surface? = null
|
||||
|
||||
@Volatile private var attachedOsdSurface: Surface? = null
|
||||
|
||||
/** Active reasons the session must render off the plane. */
|
||||
private val gpuVoReasons = LinkedHashSet<String>()
|
||||
|
||||
/** The GL vo this session is running, or null for the video plane. Non-null
|
||||
* gates off the plane-only machinery: OSD attach, aspect-fitted layout,
|
||||
* chain-failure watchdog. Written under [gpuVoReasons]. */
|
||||
@Volatile private var activeGpuVoTarget: String? = null
|
||||
|
||||
/** Whether the per-file DV policy is holding hwdec at `no`; the session's
|
||||
* own hwdec value is parked in [hwdecBeforeDvReshape] meanwhile. */
|
||||
@Volatile private var dvReshapeActive: Boolean = false
|
||||
|
||||
private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference<String?>()
|
||||
|
||||
/** Last `dv-conversion-mode` Dart applied; input to the per-file DV
|
||||
* routing policy. */
|
||||
@Volatile private var currentDvConversionMode: String = "auto"
|
||||
|
||||
/** Whether this core already decided its GL surface colorspace; set by the
|
||||
* first `content-color-transfer` announcement ([applyContentColorTransfer]). */
|
||||
@Volatile private var hdrSurfaceDecided: Boolean = false
|
||||
|
||||
/** Whether this session outputs HDR to an HDR-capable display — via the PQ
|
||||
* GL surface or the MediaCodec plane's decoder dataspace. Gates the
|
||||
* deferred display-mode restore on teardown (see
|
||||
* [FrameRateManager.clearVideoFrameRate]). */
|
||||
@Volatile private var hdrDisplayActive: Boolean = false
|
||||
|
||||
@Volatile private var videoDisplayWidth: Int = 0
|
||||
|
||||
@Volatile private var videoDisplayHeight: Int = 0
|
||||
|
||||
/** Latest `panscan` (0..1) and `video-zoom` (log2) the app applied. The
|
||||
* plane owns scaling, so these are view geometry here; see
|
||||
* [applyVideoRectLayout]. */
|
||||
@Volatile private var videoPanscan: Float = 0f
|
||||
|
||||
@Volatile private var videoZoomLog2: Float = 0f
|
||||
|
||||
/** Hardware sessions render through the fork vo=mediacodec (see
|
||||
* [initialVideoOutput]); the OSD surface and video-rect layout exist only
|
||||
* there. */
|
||||
private val usesMediaCodecVo: Boolean
|
||||
get() = !audioOnly && hardwareDecoding
|
||||
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
|
||||
@Volatile private var disposing: Boolean = false
|
||||
@@ -260,6 +329,22 @@ class MpvPlayerCore private constructor(
|
||||
lastAppliedSurfaceSize = null
|
||||
lastKnownSurfaceWidth = 0
|
||||
lastKnownSurfaceHeight = 0
|
||||
// Video-output state, so a re-initialized core does not start stranded
|
||||
// off the plane with a stale reason set.
|
||||
synchronized(gpuVoReasons) {
|
||||
gpuVoReasons.clear()
|
||||
activeGpuVoTarget = null
|
||||
}
|
||||
hwdecBeforeDvReshape.set(null)
|
||||
dvReshapeActive = false
|
||||
attachedOsdSurface = null
|
||||
videoDisplayWidth = 0
|
||||
videoDisplayHeight = 0
|
||||
videoPanscan = 0f
|
||||
videoZoomLog2 = 0f
|
||||
currentDvConversionMode = "auto"
|
||||
hdrSurfaceDecided = false
|
||||
hdrDisplayActive = false
|
||||
if (!audioOnly) ensurePlaceholderSurface()
|
||||
|
||||
// Initialize audio focus handling. mpv has none built in, so both modes
|
||||
@@ -287,6 +372,10 @@ class MpvPlayerCore private constructor(
|
||||
surfaceContainer = PlayerSurfaceHost.createContainer(activity)
|
||||
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
|
||||
surfaceContainer!!.addView(surfaceView)
|
||||
if (usesMediaCodecVo) {
|
||||
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
|
||||
surfaceContainer!!.addView(osdSurfaceView)
|
||||
}
|
||||
|
||||
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
|
||||
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
|
||||
@@ -295,6 +384,7 @@ class MpvPlayerCore private constructor(
|
||||
ensureFlutterOverlayOnTop()
|
||||
val sv = surfaceView
|
||||
if (sv != null) applySurfaceSize(sv.width, sv.height)
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
|
||||
|
||||
@@ -308,6 +398,11 @@ class MpvPlayerCore private constructor(
|
||||
return@launch
|
||||
}
|
||||
val displayFpsOverride = currentDisplayFpsOverride()
|
||||
// Both core kinds cap their demuxer cache off the device heap class;
|
||||
// rationale on DemuxerBudget. Null (unknown class) keeps mpv defaults.
|
||||
val demuxerBudget = DemuxerBudget.forHeapClassMB(
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
|
||||
)
|
||||
val p = MpvPlayer.create(context.applicationContext) {
|
||||
if (audioOnly) {
|
||||
// Pure audio core (all set before mpv_initialize, mirroring the
|
||||
@@ -322,15 +417,25 @@ class MpvPlayerCore private constructor(
|
||||
setOption("gapless-audio", "weak")
|
||||
} else {
|
||||
// vo choice is decode-path-dependent; rationale on
|
||||
// initialVideoOutput. Film grain is left on its `auto` default:
|
||||
// applied by the VO under gpu-next, by the decoder under gpu.
|
||||
// initialVideoOutput.
|
||||
setOption("vo", initialVideoOutput(hardwareDecoding))
|
||||
setOption("gpu-context", "android")
|
||||
setOption("opengl-es", "yes")
|
||||
// Keep AV1 film grain inside the decoder (dav1d). `auto` hands it
|
||||
// to any vo claiming VO_CAP_FILM_GRAIN, and gpu-next claims it on
|
||||
// GLES where libplacebo's raster grain fallback fetches luma by
|
||||
// fragcoord (bottom-up) but chroma by uv: the luma renders
|
||||
// upside-down (measured on a Shield Pro; desktop GL is unaffected
|
||||
// because grain runs as a compute pass there).
|
||||
setOption("vd-lavc-film-grain", "cpu")
|
||||
if (displayFpsOverride != null) {
|
||||
setOption("display-fps-override", displayFpsOverride)
|
||||
}
|
||||
}
|
||||
if (demuxerBudget != null) {
|
||||
setOption("demuxer-max-bytes", demuxerBudget.aheadBytes.toString())
|
||||
setOption("demuxer-max-back-bytes", demuxerBudget.backBytes.toString())
|
||||
}
|
||||
setOption("ao", "audiotrack,opensles")
|
||||
// Pause on the last frame at EOF instead of unloading the file, so a
|
||||
// seek after the video ends still works (matches Linux/Windows).
|
||||
@@ -342,6 +447,13 @@ class MpvPlayerCore private constructor(
|
||||
// builtin script during mpv_initialize, hence an option here.
|
||||
setOption("ytdl", "no")
|
||||
}
|
||||
if (demuxerBudget != null) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Demuxer budget: ${demuxerBudget.aheadBytes / (1024 * 1024)}MB ahead, " +
|
||||
"${demuxerBudget.backBytes / (1024 * 1024)}MB back"
|
||||
)
|
||||
}
|
||||
if (displayFpsOverride != null) {
|
||||
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
|
||||
}
|
||||
@@ -361,6 +473,12 @@ class MpvPlayerCore private constructor(
|
||||
collectEvents(p)
|
||||
collectPropertyChanges(p)
|
||||
collectLogMessages(p)
|
||||
if (usesMediaCodecVo) {
|
||||
collectVideoDimensions(p)
|
||||
collectShaderState(p)
|
||||
collectHdrToneMapState(p)
|
||||
collectDecoderState(p)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Initialized successfully")
|
||||
onResult(true)
|
||||
@@ -397,11 +515,28 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
is MpvEvent.StartFile -> {
|
||||
endFileDiagnostics.onStartFile()
|
||||
// The trigger is per-file (an exotic pixel format, a gralloc
|
||||
// refusal for that stream), so give the plane back to the next
|
||||
// file. A genuine failure re-arms it, costing one switch per bad
|
||||
// file instead of the whole session's HDR/10-bit scanout.
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, false)
|
||||
delegate?.onEvent("start-file", null)
|
||||
}
|
||||
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
|
||||
is MpvEvent.FileLoaded -> {
|
||||
if (usesMediaCodecVo) {
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
try {
|
||||
applyDvReshapePolicy(p)
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled DV routing policy")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DV routing policy failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate?.onEvent("file-loaded", null)
|
||||
}
|
||||
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,6 +568,18 @@ class MpvPlayerCore private constructor(
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.logFlow.collect { msg ->
|
||||
endFileDiagnostics.onLogMessage(msg)
|
||||
// A chain-init failure is the one runtime signal that frames cannot
|
||||
// reach the video plane at all (exotic pixel formats, gralloc
|
||||
// refusal). mpv is pinned in the fork, so the log line is a stable
|
||||
// contract.
|
||||
if (usesMediaCodecVo &&
|
||||
activeGpuVoTarget == null &&
|
||||
msg.prefix.startsWith("cplayer") &&
|
||||
msg.text.contains("Could not initialize video chain")
|
||||
) {
|
||||
Log.w(TAG, "Video chain init failed under vo=mediacodec; leaving the video plane")
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, true)
|
||||
}
|
||||
emitLog(msg.level.name.lowercase(), msg.prefix, msg.text)
|
||||
}
|
||||
}
|
||||
@@ -484,6 +631,300 @@ class MpvPlayerCore private constructor(
|
||||
detachSurfaceInternal(reason = "surfaceDestroyed")
|
||||
}
|
||||
|
||||
// OSD surface (the vo=mediacodec subtitle/OSD plane)
|
||||
|
||||
private val osdSurfaceCallback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
if (disposing) return
|
||||
pendingOsdSurface = holder.surface.takeIf { it.isValid }
|
||||
Log.d(TAG, "OSD surface created")
|
||||
if (player != null && hasAttachedRealSurface()) {
|
||||
refreshVideoOutput("osdSurfaceCreated")
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
Log.d(TAG, "OSD surface destroyed")
|
||||
pendingOsdSurface = null
|
||||
val wasAttached = attachedOsdSurface != null
|
||||
attachedOsdSurface = null
|
||||
val p = player ?: return
|
||||
if (disposing || !wasAttached) return
|
||||
// Ordered against every other mpv write: a detach that overtook the
|
||||
// re-attach of a recreated surface would clear the option the attach
|
||||
// just set, and nothing re-arms it — subtitles would stay dead for the
|
||||
// rest of the session.
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
try {
|
||||
p.detachOsdSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to detach OSD surface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the OSD Surface to the vo. The vo reads the option when it is
|
||||
* created, so this has to run before the `vo` or `wid` write that creates
|
||||
* it, never after.
|
||||
*/
|
||||
private fun attachOsdSurfaceIfNeeded(p: MpvPlayer) {
|
||||
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
|
||||
val osd = pendingOsdSurface?.takeIf { it.isValid } ?: return
|
||||
if (osd === attachedOsdSurface) return
|
||||
p.attachOsdSurface(osd)
|
||||
attachedOsdSurface = osd
|
||||
Log.d(TAG, "Attached OSD surface for vo=mediacodec")
|
||||
}
|
||||
|
||||
private fun collectVideoDimensions(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeInt("dwidth").collect { value ->
|
||||
val w = value.toInt()
|
||||
if (w > 0 && w != videoDisplayWidth) {
|
||||
videoDisplayWidth = w
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeInt("dheight").collect { value ->
|
||||
val h = value.toInt()
|
||||
if (h > 0 && h != videoDisplayHeight) {
|
||||
videoDisplayHeight = h
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbiter for this session's video output: the active [GpuVoPolicy] reasons
|
||||
* decide whether the session runs on the video plane or on a GL vo.
|
||||
*/
|
||||
private fun setGpuVoRequirement(reason: String, active: Boolean) {
|
||||
if (!usesMediaCodecVo || disposing) return
|
||||
val transition = synchronized(gpuVoReasons) {
|
||||
val changed = if (active) gpuVoReasons.add(reason) else gpuVoReasons.remove(reason)
|
||||
if (!changed) return
|
||||
val desired = GpuVoPolicy.targetFor(gpuVoReasons)
|
||||
if (desired == activeGpuVoTarget) return
|
||||
val line = "${activeGpuVoTarget ?: "mediacodec"} -> ${desired ?: "mediacodec"} " +
|
||||
"(reasons=[${gpuVoReasons.joinToString(",")}])"
|
||||
activeGpuVoTarget = desired
|
||||
line
|
||||
}
|
||||
Log.i(TAG, "Video output: $transition")
|
||||
applyGpuVoTarget()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the session to whatever the arbiter last decided.
|
||||
*
|
||||
* The decision is atomic under [gpuVoReasons], but the write cannot be:
|
||||
* it has to leave the lock to reach mpv. Reasons are raised from different
|
||||
* threads — per-file DV routing runs on [mpvWriteDispatcher], the gamma,
|
||||
* shader and chain-failure observers on the main thread — so the order
|
||||
* writes are *enqueued* is not the order decisions were *made*. Rather
|
||||
* than trust the target its caller saw, every transition re-reads the
|
||||
* current one here, which makes the last write the right one under any
|
||||
* interleaving. Serialized on [mpvWriteDispatcher], so the paired main
|
||||
* thread work stays in the same order too.
|
||||
*/
|
||||
private fun applyGpuVoTarget() {
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
|
||||
runOnMain {
|
||||
if (disposing) return@runOnMain
|
||||
if (target == null) {
|
||||
osdSurfaceView?.visibility = View.VISIBLE
|
||||
} else {
|
||||
// The gpu VOs draw their own OSD; a stale subtitle frame must not
|
||||
// linger on the overlay plane.
|
||||
osdSurfaceView?.visibility = View.GONE
|
||||
resetVideoSurfaceToFullContainer()
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Before the vo write, which recreates the VO: it reads the OSD
|
||||
// surface option at creation.
|
||||
val p = player
|
||||
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
|
||||
writeProperty("vo", target ?: "mediacodec")
|
||||
if (p == null) return@launch
|
||||
if (target != null) {
|
||||
// A failed conversion chain makes mpv deselect the video track
|
||||
// ("Could not initialize video chain" -> vid=no) before the VO
|
||||
// switch lands. Re-select the explicit track id: mid-file "auto"
|
||||
// resolves to no selection rather than re-running selection.
|
||||
if (p.getString("vid").let { it == null || it == "no" }) {
|
||||
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
|
||||
if (videoTrackId != null) {
|
||||
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
|
||||
writeProperty("vid", videoTrackId.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
applySurfaceSizeInternal(p, force = true)
|
||||
if (target == null) {
|
||||
// Refit the surfaces to the aspect rectangle now that the plane
|
||||
// owns scaling again (the gpu VOs letterboxed within full
|
||||
// containers).
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled vo transition write")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to move the video output to ${target ?: "mediacodec"}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
|
||||
* the DOVI configuration record's profile on the track list (never trust
|
||||
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
|
||||
* every file-loaded, so a following non-P5 file restores hardware decode
|
||||
* and returns to the video plane.
|
||||
*/
|
||||
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
|
||||
val profile = selectedVideoDvProfile(p)
|
||||
val needs = GpuVoPolicy.needsDvReshaping(
|
||||
dvProfile = profile,
|
||||
conversionMode = currentDvConversionMode,
|
||||
canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context)
|
||||
)
|
||||
if (needs == dvReshapeActive) return
|
||||
dvReshapeActive = needs
|
||||
if (needs) {
|
||||
Log.i(TAG, "DV P5 (bitstream) without native support: software decode + gpu-next reshaping")
|
||||
val current = p.getString("hwdec")
|
||||
hwdecBeforeDvReshape.set(current ?: "no")
|
||||
writeProperty("hwdec", "no")
|
||||
} else {
|
||||
val restore = hwdecBeforeDvReshape.getAndSet(null)
|
||||
if (restore != null && restore != "no") writeProperty("hwdec", restore)
|
||||
}
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_DV_RESHAPE, needs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected video track's Dolby Vision profile, or null for non-DV content
|
||||
* (mpv omits the field when the bitstream carries no DOVI configuration
|
||||
* record).
|
||||
*/
|
||||
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
|
||||
?.takeIf { it.has("dolby-vision-profile") }
|
||||
?.getLong("dolby-vision-profile")
|
||||
|
||||
/**
|
||||
* Observed rather than derived from the hardware-decoding setting because
|
||||
* the fallback is decided per file, inside mpv. Why it matters:
|
||||
* [GpuVoPolicy.needsSoftwareRender].
|
||||
*/
|
||||
private fun collectDecoderState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("hwdec-current").collect { value ->
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_SW_DECODE, GpuVoPolicy.needsSoftwareRender(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User shaders need a GL vo; the video plane renders none. Observed
|
||||
* natively so Dart's `glsl-shaders` change-list writes (ShaderService,
|
||||
* ambient lighting) switch the session live, without a channel contract.
|
||||
*/
|
||||
private fun collectShaderState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("glsl-shaders").collect { value ->
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_SHADERS, value.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observed from video-params so the reason follows per-file transfer
|
||||
* changes, and the display is re-queried per change so an HDMI mode switch
|
||||
* mid-session is honoured on the next file. Why it matters:
|
||||
* [GpuVoPolicy.needsHdrToneMapping].
|
||||
*/
|
||||
private fun collectHdrToneMapState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("video-params/gamma").collect { value ->
|
||||
val needsToneMap = GpuVoPolicy.needsHdrToneMapping(
|
||||
gamma = value,
|
||||
displaySupportsHdr = DoviBridge.displaySupportsHdr(context)
|
||||
)
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_HDR_SDR, needsToneMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Video tracks from mpv's track list, selected first; empty on any parse failure. */
|
||||
private suspend fun videoTracks(p: MpvPlayer): List<org.json.JSONObject> {
|
||||
val json = p.getString("track-list") ?: return emptyList()
|
||||
return try {
|
||||
val tracks = org.json.JSONArray(json)
|
||||
(0 until tracks.length())
|
||||
.map { tracks.getJSONObject(it) }
|
||||
.filter { it.optString("type") == "video" }
|
||||
.sortedByDescending { it.optBoolean("selected") }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse track-list", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sizes the video surface to the rectangle the image should occupy, per
|
||||
* [VideoRectPolicy], and lets the container clip the overflow.
|
||||
*
|
||||
* The OSD surface is left full-container: the vo builds its `mp_osd_res`
|
||||
* from the OSD window's own size, so libass keeps the whole window as its
|
||||
* canvas — subtitles sit in the letterbox bars as they did under vo=gpu,
|
||||
* and stay on screen when a zoomed image runs past the container.
|
||||
*/
|
||||
private fun applyVideoRectLayout() {
|
||||
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
|
||||
runOnMain {
|
||||
if (disposing) return@runOnMain
|
||||
val container = surfaceContainer ?: return@runOnMain
|
||||
val size = VideoRectPolicy.sizeFor(
|
||||
containerWidth = container.width,
|
||||
containerHeight = container.height,
|
||||
videoWidth = videoDisplayWidth,
|
||||
videoHeight = videoDisplayHeight,
|
||||
panscan = videoPanscan,
|
||||
videoZoomLog2 = videoZoomLog2
|
||||
) ?: return@runOnMain
|
||||
// The guard matters: this runs from an OnGlobalLayoutListener, so an
|
||||
// unconditional write would re-trigger layout forever.
|
||||
surfaceView?.let { view ->
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
if (lp.width != size.width || lp.height != size.height || lp.gravity != android.view.Gravity.CENTER) {
|
||||
lp.width = size.width
|
||||
lp.height = size.height
|
||||
lp.gravity = android.view.Gravity.CENTER
|
||||
view.layoutParams = lp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetVideoSurfaceToFullContainer() {
|
||||
val view = surfaceView ?: return
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
if (lp.width == android.widget.FrameLayout.LayoutParams.MATCH_PARENT) return
|
||||
lp.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
lp.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
lp.gravity = android.view.Gravity.NO_GRAVITY
|
||||
view.layoutParams = lp
|
||||
}
|
||||
|
||||
private fun rememberSurfaceSize(width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) return
|
||||
lastKnownSurfaceWidth = width
|
||||
@@ -559,10 +1000,15 @@ class MpvPlayerCore private constructor(
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
|
||||
// An unattached OSD Surface forces a wid re-attach: the VO reads the
|
||||
// OSD surface option at creation, and setting wid recreates the VO.
|
||||
// Suppressed off the plane, where the GL vo draws its own OSD.
|
||||
val osdNeedsAttach = activeGpuVoTarget == null && pendingOsdSurface !== attachedOsdSurface
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osdNeedsAttach
|
||||
val wasAttachedToPlaceholder = attachedToPlaceholder
|
||||
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
|
||||
if (needsAttach) {
|
||||
attachOsdSurfaceIfNeeded(p)
|
||||
p.attachSurface(surface)
|
||||
attachedSurface = surface
|
||||
hasAttachedSurface = true
|
||||
@@ -880,12 +1326,136 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "Load pause intent updated: paused=$paused")
|
||||
}
|
||||
|
||||
/**
|
||||
* `dv-conversion-mode` is an app-level property shared with the ExoPlayer
|
||||
* and Apple cores, not an mpv one. It maps onto the fork FFmpeg
|
||||
* hevc_mediacodec decoder options, mirroring the ExoPlayer DoviBridge
|
||||
* decision tree. Single-layer profiles (5/8) use the Dolby Vision decoder
|
||||
* whenever the path is enabled and the decoder advertises the profile.
|
||||
*/
|
||||
private fun applyDvConversionMode(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
val displayDv = DoviBridge.displaySupportsDolbyVision(context)
|
||||
val (dolbyVision, p7Mode) = when (value.trim().lowercase()) {
|
||||
"auto" -> if (displayDv) "1" to "auto" else "0" to "strip"
|
||||
"disabled", "native" -> "1" to "native"
|
||||
"dv81" -> "1" to "convert"
|
||||
"hevc", "hevc_strip" -> "1" to "strip"
|
||||
else -> {
|
||||
onComplete?.invoke(Result.failure(IllegalArgumentException("Invalid DV conversion mode: $value")))
|
||||
return
|
||||
}
|
||||
}
|
||||
currentDvConversionMode = value.trim().lowercase()
|
||||
Log.i(TAG, "DV conversion mode '$value' (displayDV=$displayDv) -> dolby_vision=$dolbyVision dv_p7_mode=$p7Mode")
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val completion = try {
|
||||
val ours = "dolby_vision=$dolbyVision,dv_p7_mode=$p7Mode"
|
||||
val merged = mergeDecoderOptions(player?.getString("vd-lavc-o"), ours)
|
||||
writeProperty("vd-lavc-o", merged)
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "DV conversion mode write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `content-color-transfer` is an app-level property: Dart announces the
|
||||
* selected stream's transfer (server metadata) before playback so an HDR
|
||||
* session can get a BT.2020 PQ 10-bit GL surface instead of tone-mapped
|
||||
* SDR. Consumed by whichever android GL context the session ever creates -
|
||||
* up front for a software session, or at the fallback transition when a
|
||||
* plane session leaves vo=mediacodec (the plane itself carries HDR via the
|
||||
* decoder's dataspace and ignores all of this).
|
||||
*
|
||||
* The first announcement decides for the whole core: the surface colorspace
|
||||
* is fixed at EGL-surface creation, and both latched states stay correct
|
||||
* for later files (a PQ target renders SDR content correctly, an sRGB
|
||||
* surface tone-maps HDR as before) - re-deciding mid-session could pair a
|
||||
* live sRGB surface with a PQ render target, which is wrong everywhere.
|
||||
*/
|
||||
private fun applyContentColorTransfer(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
val transfer = value.trim().lowercase()
|
||||
if (hdrSurfaceDecided) {
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
hdrSurfaceDecided = true
|
||||
val wants = wantsHdrSurface(transfer)
|
||||
val displayHdr = wants && DoviBridge.displaySupportsHdr(context)
|
||||
// Independent of the GL surface outcome: on the MediaCodec plane the
|
||||
// decoder's dataspace carries HDR to the display without a PQ GL surface.
|
||||
hdrDisplayActive = displayHdr
|
||||
val outputFormat = if (wants) EglHdrCaps.pqOutputFormat() else null
|
||||
if (!wants || !displayHdr || outputFormat == null) {
|
||||
if (wants) {
|
||||
Log.i(TAG, "HDR GL surface unavailable (transfer=$transfer displayHdr=$displayHdr eglFormat=$outputFormat)")
|
||||
}
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "HDR GL surface engaged: BT.2020 PQ / $outputFormat for transfer=$transfer")
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val completion = try {
|
||||
writeProperty("android-surface-colorspace", "bt2020-pq")
|
||||
writeProperty("egl-output-format", outputFormat)
|
||||
writeProperty("target-trc", "pq")
|
||||
writeProperty("target-prim", "bt.2020")
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "HDR surface property write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return
|
||||
}
|
||||
|
||||
if (name == "dv-conversion-mode") {
|
||||
applyDvConversionMode(value, onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
if (name == "content-color-transfer") {
|
||||
applyContentColorTransfer(value, onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
// View geometry on the plane (see VideoRectPolicy), but both still fall
|
||||
// through to mpv, which is what makes them work unchanged on the GL vos.
|
||||
if (name == "panscan" || name == "video-zoom") {
|
||||
val parsed = value.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
if (name == "panscan") videoPanscan = parsed else videoZoomLog2 = parsed
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// While the per-file DV policy holds hwdec at `no`, park writes instead
|
||||
// of applying them: a hardware value under gpu-next would lose the RPU
|
||||
// side data (and blue-screen the Tegra class, #2010). The parked value
|
||||
// is restored when a non-P5 file drops the requirement.
|
||||
if (name == "hwdec" && dvReshapeActive) {
|
||||
hwdecBeforeDvReshape.set(value)
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
|
||||
val paused = if (name == "pause") normalizePauseValue(value) else null
|
||||
val pauseIntent = paused?.let {
|
||||
synchronized(publicPauseIntentLock) {
|
||||
@@ -1223,7 +1793,7 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrDisplayActive)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
@@ -1274,11 +1844,15 @@ class MpvPlayerCore private constructor(
|
||||
|
||||
// Capture locals for deferred cleanup (audio-only has no views)
|
||||
val sv = surfaceView
|
||||
val osdSv = osdSurfaceView
|
||||
val container = surfaceContainer
|
||||
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
|
||||
surfaceContainer = null
|
||||
surfaceView = null
|
||||
osdSurfaceView = null
|
||||
pendingOsdSurface = null
|
||||
attachedOsdSurface = null
|
||||
|
||||
// Remove layout listener synchronously
|
||||
overlayLayoutListener?.let { listener ->
|
||||
@@ -1330,6 +1904,7 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "Disposed (native)")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
sv?.holder?.removeCallback(this)
|
||||
osdSv?.holder?.removeCallback(osdSurfaceCallback)
|
||||
if (container?.parent != null) {
|
||||
contentView?.removeView(container)
|
||||
}
|
||||
@@ -1340,6 +1915,7 @@ class MpvPlayerCore private constructor(
|
||||
// No player — safe to remove views immediately
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
sv?.holder?.removeCallback(this)
|
||||
osdSv?.holder?.removeCallback(osdSurfaceCallback)
|
||||
if (container?.parent != null) {
|
||||
contentView?.removeView(container)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
@@ -14,6 +15,7 @@ import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal fun completeMpvPropertyResult(
|
||||
result: MethodChannel.Result,
|
||||
@@ -62,6 +64,17 @@ open class MpvPlayerPlugin(
|
||||
private val nameToId = mutableMapOf<String, Int>()
|
||||
private var sessionGeneration = 0
|
||||
|
||||
// The Dart instanceId that created the current core. A `dispose` carrying a
|
||||
// different token lost the ownership race to a successor and must not tear
|
||||
// down that successor's core; it is acknowledged without touching anything.
|
||||
private var coreInstanceId: Long? = null
|
||||
|
||||
// How long a Dart `dispose` waits for the native teardown before being
|
||||
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
|
||||
// session's surface/audio release); small against the alternative, which
|
||||
// is wedging every subsequent playback session behind a hung teardown.
|
||||
private val disposeWatchdogMs = 6_000L
|
||||
|
||||
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
|
||||
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||
|
||||
@@ -95,6 +108,7 @@ open class MpvPlayerPlugin(
|
||||
++sessionGeneration
|
||||
val core = playerCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
cancelPendingInits()
|
||||
return core
|
||||
}
|
||||
@@ -149,7 +163,7 @@ open class MpvPlayerPlugin(
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
"getStats" -> handleGetStats(result)
|
||||
@@ -164,6 +178,13 @@ open class MpvPlayerPlugin(
|
||||
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
|
||||
"openContentFd" -> handleOpenContentFd(call, result)
|
||||
"closeContentFd" -> handleCloseContentFd(call, result)
|
||||
"getHeapSize" -> {
|
||||
// Device heap class for Dart-side memory tiering (stream ring cache).
|
||||
// Lives on the always-registered mpv channel so it survives backends.
|
||||
val context: Context? = activity ?: applicationContext
|
||||
val am = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
result.success(am?.largeMemoryClass ?: 0)
|
||||
}
|
||||
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
|
||||
"setLogLevel" -> handleSetLogLevel(call, result)
|
||||
else -> result.notImplemented()
|
||||
@@ -247,6 +268,7 @@ open class MpvPlayerPlugin(
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to initialize: ${e.message}", e)
|
||||
completePendingInits(attempt, success = false, errorMessage = e.message)
|
||||
@@ -258,7 +280,10 @@ open class MpvPlayerPlugin(
|
||||
playerCore !== core ||
|
||||
!isCurrentInitAttempt(attempt)
|
||||
if (stale || !success) {
|
||||
if (playerCore === core) playerCore = null
|
||||
if (playerCore === core) {
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
}
|
||||
core.dispose()
|
||||
if (stale) {
|
||||
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
@@ -315,13 +340,37 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<Number>("instanceId")?.toLong()
|
||||
runOnMain {
|
||||
val core = takeCoreForTeardown()
|
||||
core?.dispose {
|
||||
Log.d(tag, "Disposed")
|
||||
val owner = coreInstanceId
|
||||
if (playerCore != null && token != null && owner != null && token != owner) {
|
||||
// This dispose lost the ownership race: a successor already created
|
||||
// the current core. Acknowledge without touching it.
|
||||
Log.d(tag, "Ignoring stale dispose (token=$token, core owner=$owner)")
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
val core = takeCoreForTeardown()
|
||||
if (core == null) {
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
// A hung native teardown must not wedge the Dart-side release chain:
|
||||
// answer after the watchdog even if the teardown thread is stuck, so
|
||||
// the next session can start on a fresh core. The stuck core leaks its
|
||||
// resources until the process ends — recoverable, unlike the wedge.
|
||||
val completed = AtomicBoolean(false)
|
||||
fun completeOnce(reason: String) {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
Log.d(tag, reason)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
channels.mainHandler.postDelayed({
|
||||
completeOnce("Dispose watchdog fired after ${disposeWatchdogMs}ms; teardown continues in background")
|
||||
}, disposeWatchdogMs)
|
||||
core.dispose { completeOnce("Disposed") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Pure geometry for the video plane's surface rectangle.
|
||||
*
|
||||
* The fork `vo=mediacodec` scales decoded buffers to the whole Surface and
|
||||
* implements none of mpv's src/dst rect math, so aspect, cover and zoom are
|
||||
* view geometry: the video surface is sized to the rectangle the image should
|
||||
* occupy and the container clips whatever falls outside it. That mirrors the
|
||||
* ExoPlayer path, which scales its own surface and keeps the subtitle overlay
|
||||
* on the full-size container.
|
||||
*/
|
||||
internal object VideoRectPolicy {
|
||||
/**
|
||||
* `video-zoom` is reachable from the user's mpv.conf, where an absurd value
|
||||
* is a compositor allocation rather than just arithmetic. Bounds it well
|
||||
* outside the range the player's own zoom control offers.
|
||||
*/
|
||||
private const val MAX_ZOOM_LOG2 = 2f
|
||||
|
||||
data class Size(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* Size for the video surface, or null when a dimension is not known yet.
|
||||
*
|
||||
* [panscan] is mpv's 0..1 property: 0 fits the image inside the container
|
||||
* (letterbox), 1 fills it (crop), values between interpolate the scale.
|
||||
* [videoZoomLog2] is mpv's `video-zoom`, a log2 factor, applied on top.
|
||||
*/
|
||||
fun sizeFor(
|
||||
containerWidth: Int,
|
||||
containerHeight: Int,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
panscan: Float = 0f,
|
||||
videoZoomLog2: Float = 0f
|
||||
): Size? {
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) return null
|
||||
val fit = min(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
|
||||
val cover = max(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
|
||||
val pan = panscan.coerceIn(0f, 1f)
|
||||
val zoom = 2.0f.pow(videoZoomLog2.coerceIn(-MAX_ZOOM_LOG2, MAX_ZOOM_LOG2))
|
||||
val scale = (fit + (cover - fit) * pan) * zoom
|
||||
return Size(
|
||||
width = (videoWidth * scale).toInt().coerceAtLeast(1),
|
||||
height = (videoHeight * scale).toInt().coerceAtLeast(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
|
||||
/**
|
||||
* Reports whether an enabled accessibility service can consume the app's semantics tree.
|
||||
*
|
||||
* Flutter compiles a semantics tree for every frame while [AccessibilityManager.isEnabled], which
|
||||
* Android sets for any bound service. On TV that is routinely a utility that never reads app
|
||||
* content (a launcher's foreground-app hook, a key remapper), so Dart gates the tree on this
|
||||
* verdict. The verdict errs towards keeping the tree:
|
||||
*
|
||||
* - touch exploration is a screen reader, full stop;
|
||||
* - an empty enabled list while accessibility is on means a [android.app.UiAutomation] client
|
||||
* (instrumentation, Maestro), which is not listed but reads the tree;
|
||||
* - on API 33+ a service flagged `isAccessibilityTool`, or one giving spoken, braille, audible or
|
||||
* visual feedback, consumes; a non-tool with only generic/haptic feedback does not;
|
||||
* - below 33 only a haptic-only service is treated as not consuming, since `feedbackGeneric` is
|
||||
* what Switch Access-style tools declare.
|
||||
*/
|
||||
class AssistiveTechnologyMonitor(context: Context) {
|
||||
private val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
private var onChanged: (() -> Unit)? = null
|
||||
private val stateListener = AccessibilityManager.AccessibilityStateChangeListener { onChanged?.invoke() }
|
||||
private val touchExplorationListener =
|
||||
AccessibilityManager.TouchExplorationStateChangeListener { onChanged?.invoke() }
|
||||
private var servicesListener: AccessibilityManager.AccessibilityServicesStateChangeListener? = null
|
||||
|
||||
fun start(onChanged: () -> Unit) {
|
||||
if (this.onChanged != null) return
|
||||
this.onChanged = onChanged
|
||||
manager.addAccessibilityStateChangeListener(stateListener)
|
||||
manager.addTouchExplorationStateChangeListener(touchExplorationListener)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val listener = AccessibilityManager.AccessibilityServicesStateChangeListener { onChanged() }
|
||||
servicesListener = listener
|
||||
manager.addAccessibilityServicesStateChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (onChanged == null) return
|
||||
onChanged = null
|
||||
manager.removeAccessibilityStateChangeListener(stateListener)
|
||||
manager.removeTouchExplorationStateChangeListener(touchExplorationListener)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
servicesListener?.let { manager.removeAccessibilityServicesStateChangeListener(it) }
|
||||
servicesListener = null
|
||||
}
|
||||
}
|
||||
|
||||
fun signals(): Map<String, Any> {
|
||||
val services = manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||
return mapOf(
|
||||
"accessibilityEnabled" to manager.isEnabled,
|
||||
"touchExplorationEnabled" to manager.isTouchExplorationEnabled,
|
||||
"enabledServiceCount" to services.size,
|
||||
"consumesSemantics" to consumesSemantics(services)
|
||||
)
|
||||
}
|
||||
|
||||
private fun consumesSemantics(services: List<AccessibilityServiceInfo>): Boolean {
|
||||
if (manager.isTouchExplorationEnabled) return true
|
||||
if (services.isEmpty()) return true
|
||||
return services.any { info -> consumesSemantics(info) }
|
||||
}
|
||||
|
||||
private fun consumesSemantics(info: AccessibilityServiceInfo): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (info.isAccessibilityTool) return true
|
||||
return info.feedbackType and READER_FEEDBACK != 0
|
||||
}
|
||||
return info.feedbackType and AccessibilityServiceInfo.FEEDBACK_HAPTIC.inv() != 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READER_FEEDBACK =
|
||||
AccessibilityServiceInfo.FEEDBACK_SPOKEN or
|
||||
AccessibilityServiceInfo.FEEDBACK_BRAILLE or
|
||||
AccessibilityServiceInfo.FEEDBACK_AUDIBLE or
|
||||
AccessibilityServiceInfo.FEEDBACK_VISUAL
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,15 @@ class FrameRateManager(
|
||||
private const val TAG = "FrameRateManager"
|
||||
private const val DISPLAY_SETTLE_MS = 2000L
|
||||
private const val WATCHDOG_MARGIN_MS = 3000L
|
||||
|
||||
// How long to let the HDR-exit commit land before restoring the refresh
|
||||
// rate. Restoring while the display is still signaling HDR folds the
|
||||
// HDR-exit and the mode change into one HDMI renegotiation, which some
|
||||
// sink chains take 8-30 s to complete (#2172); sequenced, the HDR
|
||||
// infoframe clear is free and the SDR mode switch takes ~1 s. The
|
||||
// player's surface teardown commits the HDR exit within ~50 ms of
|
||||
// dispose, so 400 ms covers it with margin even on a busy main thread.
|
||||
private const val HDR_EXIT_SETTLE_MS = 400L
|
||||
}
|
||||
|
||||
private var currentVideoFps: Float = 0f
|
||||
@@ -30,6 +39,12 @@ class FrameRateManager(
|
||||
private var watchdogRunnable: Runnable? = null
|
||||
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
|
||||
|
||||
// Owns the deferred HDR-exit restore. Deliberately NOT the shared player
|
||||
// [handler]: core dispose clears that one wholesale, and the restore must
|
||||
// survive player disposal or the display stays at the content rate.
|
||||
private val restoreHandler = Handler(android.os.Looper.getMainLooper())
|
||||
private var pendingRestoreRunnable: Runnable? = null
|
||||
|
||||
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
|
||||
// Request a display mode switch for the video's frame rate and/or, with
|
||||
@@ -54,6 +69,9 @@ class FrameRateManager(
|
||||
matchResolution: Boolean = false,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
// A new session's switch must not be clobbered by a still-pending
|
||||
// deferred restore from the previous session's teardown.
|
||||
cancelPendingRestore()
|
||||
currentVideoFps = fps
|
||||
currentVideoWidth = videoWidth
|
||||
currentVideoHeight = videoHeight
|
||||
@@ -78,21 +96,47 @@ class FrameRateManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
// [hdrActive]: the session was outputting HDR. The restore is then deferred
|
||||
// by [HDR_EXIT_SETTLE_MS] so the caller's surface teardown can commit the
|
||||
// HDR exit first — see [HDR_EXIT_SETTLE_MS] for why stacking them is slow.
|
||||
fun clearVideoFrameRate(hdrActive: Boolean = false) {
|
||||
Log.d(TAG, "clearVideoFrameRate(hdrActive=$hdrActive)")
|
||||
currentVideoFps = 0f
|
||||
// Resolve any pending setVideoFrameRate future as "not switched" so
|
||||
// the Dart caller's await doesn't hang on player dispose.
|
||||
firePendingCompletion("clear", switched = false)
|
||||
// Restore default display mode on API M (preferredDisplayModeId persists)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
cancelPendingRestore()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
// Nothing to restore when no preferred mode was ever applied.
|
||||
if ((activity.window?.attributes?.preferredDisplayModeId ?: 0) == 0) return
|
||||
if (hdrActive) {
|
||||
val restore = Runnable {
|
||||
pendingRestoreRunnable = null
|
||||
// Log.d, not [log]: this fires after core dispose, when the
|
||||
// Flutter-channel logger is already gone.
|
||||
Log.d(TAG, "restoring default display mode after HDR exit")
|
||||
restorePreferredDisplayMode()
|
||||
}
|
||||
pendingRestoreRunnable = restore
|
||||
restoreHandler.postDelayed(restore, HDR_EXIT_SETTLE_MS)
|
||||
} else {
|
||||
restorePreferredDisplayMode()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restorePreferredDisplayMode() {
|
||||
// preferredDisplayModeId persists on the window; restore the default.
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelPendingRestore() {
|
||||
pendingRestoreRunnable?.let { restoreHandler.removeCallbacks(it) }
|
||||
pendingRestoreRunnable = null
|
||||
}
|
||||
|
||||
// Release pending callbacks/listener without restoring the display mode.
|
||||
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
|
||||
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
|
||||
|
||||
@@ -7,6 +7,34 @@ import java.util.Locale
|
||||
|
||||
/** Canonical decoder lookup and hardware classification for native playback. */
|
||||
internal object MediaCodecQuery {
|
||||
/**
|
||||
* Codecs whose advertised support is decided by hardware: both have a
|
||||
* software decoder behind them, but a software HEVC or AV1 decode on
|
||||
* phone/TV-class hardware drops frames.
|
||||
*/
|
||||
private val HARDWARE_GATED_VIDEO_MIME_TYPES = mapOf(
|
||||
"hevc" to "video/hevc",
|
||||
"av1" to "video/av01"
|
||||
)
|
||||
|
||||
/** Codec name -> whether this device has a hardware decoder for it. */
|
||||
fun hardwareVideoDecodeSupport(
|
||||
hardwareMimeTypes: Set<String> = hardwareDecoderMimeTypes()
|
||||
): Map<String, Boolean> = HARDWARE_GATED_VIDEO_MIME_TYPES.mapValues { (_, mimeType) -> mimeType in hardwareMimeTypes }
|
||||
|
||||
/**
|
||||
* Every MIME type served by a hardware decoder, lowercased. One walk answers
|
||||
* for all codecs, unlike [findHardwareDecoder], which rescans per lookup.
|
||||
*/
|
||||
private fun hardwareDecoderMimeTypes(): Set<String> {
|
||||
val mimeTypes = HashSet<String>()
|
||||
for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) {
|
||||
if (info.isEncoder || !isHardwareAccelerated(info)) continue
|
||||
for (type in info.supportedTypes) mimeTypes.add(type.lowercase(Locale.ROOT))
|
||||
}
|
||||
return mimeTypes
|
||||
}
|
||||
|
||||
fun findHardwareDecoder(
|
||||
mimeType: String,
|
||||
codecKind: Int = MediaCodecList.REGULAR_CODECS,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
@@ -14,8 +15,37 @@ internal object PlayerSurfaceHost {
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Fallback fill for the frames before the punch surface below is placed.
|
||||
setBackgroundColor(Color.BLACK)
|
||||
this.clipChildren = clipChildren
|
||||
addView(createLetterboxPunchSurface(activity))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen, buffer-less SurfaceView kept beneath the video surface.
|
||||
*
|
||||
* Its only job is taking the letterbox area off the app-window (graphics)
|
||||
* plane: like any below-window SurfaceView it punches the parent canvas and
|
||||
* registers its rect as a window transparent region, so the pixels around
|
||||
* the video rect scan out as the SurfaceFlinger backdrop instead of
|
||||
* window-plane black. Several TV compositors (Fire TV Stick 4K Max, some
|
||||
* Sony/Philips models) raise SDR graphics-plane black while the display is
|
||||
* in HDR/Dolby Vision mode, which shows as gray letterbox bars on OLED
|
||||
* panels (issue #2163; same mechanism as ExoPlayer #8803 and Kodi #25300).
|
||||
*
|
||||
* No buffer is ever posted to it: SurfaceFlinger skips buffer-less layers,
|
||||
* and drawing black into it would put the bars back onto an SDR layer —
|
||||
* exactly the plane being avoided. Views drawn on the parent canvas after
|
||||
* the punch (Media3 subtitle cues) still re-claim their own bounds.
|
||||
*/
|
||||
private fun createLetterboxPunchSurface(activity: Activity): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(false)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -2)
|
||||
}
|
||||
|
||||
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
@@ -29,6 +59,23 @@ internal object PlayerSurfaceHost {
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent plane directly above the video surface for the mpv
|
||||
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
|
||||
* the video SurfaceView but still beneath the Flutter window content.
|
||||
*/
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
holder.addCallback(callback)
|
||||
holder.setFormat(PixelFormat.TRANSLUCENT)
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(true)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -1)
|
||||
}
|
||||
|
||||
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
|
||||
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
contentView.addView(container, 0)
|
||||
|
||||
@@ -13,3 +13,8 @@ add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
|
||||
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
|
||||
|
||||
add_executable(mpv_utf8_convert_test mpv_utf8_convert_test.cpp)
|
||||
target_compile_features(mpv_utf8_convert_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME mpv_utf8_convert_test COMMAND mpv_utf8_convert_test)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "../../../../libmpv/src/main/cpp/utf8_convert.h"
|
||||
|
||||
namespace {
|
||||
|
||||
using plezy::utf8::FromUtf16;
|
||||
using plezy::utf8::ToUtf16;
|
||||
|
||||
bool check(bool condition, const char* message) {
|
||||
if (!condition) std::fprintf(stderr, "%s\n", message);
|
||||
return condition;
|
||||
}
|
||||
|
||||
bool roundTripsAsciiBmpAndSupplementary() {
|
||||
// "a" U+00E9 U+4E2D U+1F3AC (clapper board) — 1/2/3/4-byte sequences.
|
||||
const std::string utf8 = "a\xC3\xA9\xE4\xB8\xAD\xF0\x9F\x8E\xAC";
|
||||
const std::u16string utf16 = ToUtf16(utf8.c_str());
|
||||
return check(utf16 == u"a\u00E9\u4E2D\U0001F3AC", "UTF-8 -> UTF-16 mismatch") &&
|
||||
check(FromUtf16(utf16.data(), utf16.size()) == utf8, "UTF-16 -> UTF-8 mismatch");
|
||||
}
|
||||
|
||||
bool doesNotEmitModifiedUtf8() {
|
||||
// JNI's modified UTF-8 would encode U+1F3AC as a 6-byte CESU-8 surrogate
|
||||
// pair; mpv/open() need the real 4-byte form.
|
||||
const std::u16string clapper = u"\U0001F3AC";
|
||||
return check(FromUtf16(clapper.data(), clapper.size()) == "\xF0\x9F\x8E\xAC", "supplementary char not 4 bytes") &&
|
||||
check(
|
||||
ToUtf16("\xED\xA0\xBC\xED\xBE\xAC") == u"\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD",
|
||||
"CESU-8 surrogate bytes accepted as UTF-8");
|
||||
}
|
||||
|
||||
bool replacesMalformedBytesOneAtATime() {
|
||||
return check(ToUtf16("ok\xFF\xFEz") == u"ok\uFFFD\uFFFDz", "stray bytes not replaced individually") &&
|
||||
check(ToUtf16("\xC0\x80") == u"\uFFFD\uFFFD", "overlong NUL accepted") &&
|
||||
check(ToUtf16("\xE2\x82") == u"\uFFFD\uFFFD", "truncated sequence not replaced") &&
|
||||
check(ToUtf16("\xF4\x90\x80\x80") == u"\uFFFD\uFFFD\uFFFD\uFFFD", "code point above U+10FFFF accepted") &&
|
||||
check(ToUtf16("\xE0\x80\xAF") == u"\uFFFD\uFFFD\uFFFD", "overlong 3-byte form accepted");
|
||||
}
|
||||
|
||||
bool replacesLoneSurrogates() {
|
||||
const std::u16string lone = u"x\xD83Cy\xDFACz";
|
||||
return check(FromUtf16(lone.data(), lone.size()) == "x\xEF\xBF\xBDy\xEF\xBF\xBDz", "lone surrogates not replaced");
|
||||
}
|
||||
|
||||
bool handlesNullAndEmpty() {
|
||||
return check(ToUtf16(nullptr).empty(), "NULL input not empty") &&
|
||||
check(ToUtf16("").empty(), "empty input not empty") &&
|
||||
check(FromUtf16(nullptr, 0).empty(), "NULL UTF-16 not empty");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const bool ok = roundTripsAsciiBmpAndSupplementary() && doesNotEmitModifiedUtf8() &&
|
||||
replacesMalformedBytesOneAtATime() && replacesLoneSurrogates() && handlesNullAndEmpty();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.edde746.plezy
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -30,6 +32,40 @@ class ExternalPlayerChannelTest {
|
||||
assertEquals(false, result["playbackError"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshLaunchTellsPlayerToStartFromBeginning() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
val channel = ExternalPlayerChannel(activity)
|
||||
val source = ExternalPlayerChannel.Source(
|
||||
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
|
||||
grantRead = false,
|
||||
fileName = "file.mkv"
|
||||
)
|
||||
|
||||
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 0L, title = "Episode")
|
||||
|
||||
assertTrue(intent.getBooleanExtra("from_start", false))
|
||||
assertFalse(intent.hasExtra("position"))
|
||||
assertFalse(intent.hasExtra("startfrom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeLaunchPassesPositionAndDisablesFromStart() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
val channel = ExternalPlayerChannel(activity)
|
||||
val source = ExternalPlayerChannel.Source(
|
||||
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
|
||||
grantRead = false,
|
||||
fileName = "file.mkv"
|
||||
)
|
||||
|
||||
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 90_000L, title = null)
|
||||
|
||||
assertFalse(intent.getBooleanExtra("from_start", true))
|
||||
assertEquals(90_000, intent.getIntExtra("position", -1))
|
||||
assertEquals(90_000, intent.getIntExtra("startfrom", -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activityDestroyCompletesPendingChannelCall() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).get()
|
||||
|
||||
@@ -94,24 +94,111 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListNamesOnlyCodecsMpvsStereoIecTrackCanCarry() {
|
||||
// eac3 (a 192kHz burst), truehd and dts-hd (192kHz/8ch) never survive mpv's audiotrack
|
||||
// AO, which opens every spdif format as a stereo IEC 61937 track at the mixer rate;
|
||||
// naming them strands playback on a dead audio output (#1991).
|
||||
assertEquals("ac3,dts", mpvSpdifCodecs { true })
|
||||
fun spdifListNamesEveryCodecARouteWithAllShapesCanCarry() {
|
||||
// libmpv v1.1.0's audiotrack AO opens each burst at its own rate and channel mask, so a route
|
||||
// that takes all three shapes and advertises everything bitstreams the lossless codecs too.
|
||||
// `dts-hd` supersedes plain `dts`: ad_spdif picks the core burst per file for non-HD tracks.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListOnAStereo48kOnlyRouteNamesTheCoreCodecsOnly() {
|
||||
// E-AC3 (192kHz), TrueHD MAT and DTS-HD MA (192kHz/8ch) have no track to ride here.
|
||||
assertEquals("ac3,dts", spdifCodecs(allEncodings, setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdFallsBackToThePlainCoreWithoutTheCarrierShape() {
|
||||
// Advertising ENCODING_DTS_HD says the receiver decodes it, not that the route takes the
|
||||
// 192kHz/8ch track its burst needs (#1988); naming `dts-hd` anyway strands playback.
|
||||
assertEquals(
|
||||
"dts",
|
||||
spdifCodecs(setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD), setOf(MpvIecShape.STEREO_48K))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eac3IsNotNamedWithoutThe192kStereoShape() {
|
||||
assertEquals("", spdifCodecs(setOf(C.ENCODING_E_AC3), setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trueHdIsNotNamedWithoutTheCarrierShape() {
|
||||
assertEquals(
|
||||
"",
|
||||
spdifCodecs(
|
||||
setOf(C.ENCODING_DOLBY_TRUEHD),
|
||||
setOf(MpvIecShape.STEREO_48K, MpvIecShape.STEREO_192K)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainDtsSurvivesWhenTheRouteDoesNotAdvertiseDtsHd() {
|
||||
// The core burst is stereo/48k, so it rides a full-shape route unchanged when only the
|
||||
// lossless encoding is missing.
|
||||
assertEquals("dts", spdifCodecs(setOf(C.ENCODING_DTS), allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListDropsCodecsTheRouteCannotBitstream() {
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3 bitstreams, DTS does not (#1703).
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, DTS does not (#1703).
|
||||
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
|
||||
|
||||
assertEquals("ac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
|
||||
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
assertEquals("", mpvSpdifCodecs({ false }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
|
||||
// Every encoding advertised, but no raw track and no IEC 61937 shape opens: mpv has no
|
||||
// decode fallback for a named codec, so nothing may be named (#1991).
|
||||
assertEquals("", spdifCodecs(allEncodings, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawCapableRouteBitstreamsTheCoreCodecsWithoutAnyIecShape() {
|
||||
// #2177's Shield: every mpv IEC track opens and drains into silence, while raw
|
||||
// ENCODING_AC3/E_AC3/DTS tracks (the ExoPlayer transport) play. The AO opens raw first,
|
||||
// so raw support alone must qualify the core codecs.
|
||||
assertEquals(
|
||||
"ac3,eac3,dts",
|
||||
spdifCodecs(allEncodings, emptySet(), raw = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3, C.ENCODING_DTS))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawSupportNeverQualifiesTheLosslessCodecs() {
|
||||
// TrueHD and DTS-HD MA have no raw transport in the AO; they ride the 192kHz/7.1 IEC
|
||||
// carrier or decode. A route that takes every raw track but no carrier must not name them.
|
||||
assertEquals("ac3,eac3,dts", spdifCodecs(allEncodings, emptySet(), raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawProbeIsOnlyConsultedForRawCandidates() {
|
||||
// The raw probe costs real route calls; the carrier-only codecs must never trigger it.
|
||||
val codecs = mpvSpdifCodecs(
|
||||
{ true },
|
||||
{ true },
|
||||
{ encoding ->
|
||||
if (encoding == C.ENCODING_DOLBY_TRUEHD || encoding == C.ENCODING_DTS_HD) {
|
||||
throw AssertionError("raw probe consulted for a carrier-only codec")
|
||||
}
|
||||
true
|
||||
}
|
||||
)
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", codecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdStillSupersedesPlainDtsWhenDtsBitstreamsRaw() {
|
||||
// dts-hd selects the lossless spdif decoder for the whole dts codec; the raw core track
|
||||
// must not resurrect the plain name beside it.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes, raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -204,4 +291,17 @@ class AudioOutputPolicyTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every encoding the spdif table can ask for, i.e. a receiver that decodes all of them. */
|
||||
private val allEncodings = setOf(
|
||||
C.ENCODING_AC3,
|
||||
C.ENCODING_E_AC3,
|
||||
C.ENCODING_DOLBY_TRUEHD,
|
||||
C.ENCODING_DTS,
|
||||
C.ENCODING_DTS_HD
|
||||
)
|
||||
|
||||
private val allShapes = MpvIecShape.values().toSet()
|
||||
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String = mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DoviCodecsTest {
|
||||
@Test
|
||||
fun hevcProfilesUseDvhePrefix() {
|
||||
// Profile 8 level 6 is the stream from #2096: the record's dv_profile and
|
||||
// dv_level bytes must surface verbatim, matching media3's
|
||||
// DolbyVisionConfig output for the same file (dvhe.08.06).
|
||||
assertEquals("dvhe.08.06", DoviCodecs.rfc6381(8, 6))
|
||||
assertEquals("dvhe.04.09", DoviCodecs.rfc6381(4, 9))
|
||||
assertEquals("dvhe.05.06", DoviCodecs.rfc6381(5, 6))
|
||||
// Profile 7 keys DoviConvertingTrackOutput's P7 conversion.
|
||||
assertEquals("dvhe.07.06", DoviCodecs.rfc6381(7, 6))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun avcAndAv1ProfilesUseTheirOwnPrefixes() {
|
||||
assertEquals("dvav.09.05", DoviCodecs.rfc6381(9, 5))
|
||||
assertEquals("dav1.10.04", DoviCodecs.rfc6381(10, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoDigitLevelsAreNotZeroPadded() {
|
||||
assertEquals("dvhe.08.10", DoviCodecs.rfc6381(8, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unrecognizedProfilesReturnNullSoTheBaseCodecFormatSurvives() {
|
||||
// Media3's DolbyVisionConfig table: anything it rejects must keep the
|
||||
// track's plain HEVC/AVC format instead of publishing an unplayable
|
||||
// video/dolby-vision variant.
|
||||
assertNull(DoviCodecs.rfc6381(0, 6))
|
||||
assertNull(DoviCodecs.rfc6381(2, 6))
|
||||
assertNull(DoviCodecs.rfc6381(6, 6))
|
||||
assertNull(DoviCodecs.rfc6381(20, 6))
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.ParserException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Spec for the per-codec initializationData shapes the ffmpeg demuxer must
|
||||
* publish so MediaCodec csd-0/1/2 and FfmpegAudioDecoder's extradata
|
||||
* reconstruction both see what media3's own extractors emit. Regression
|
||||
* coverage for #2088: Opus with a bare OpusHead csd made Android's decoders
|
||||
* eat the first two real packets as configuration and discard all output.
|
||||
*/
|
||||
class FfmpegAudioCsdTest {
|
||||
|
||||
private fun opusHead(preSkip: Int): ByteArray {
|
||||
val header = ByteArray(19)
|
||||
"OpusHead".toByteArray(Charsets.US_ASCII).copyInto(header)
|
||||
header[8] = 1 // version
|
||||
header[9] = 2 // channel count
|
||||
header[10] = (preSkip and 0xFF).toByte()
|
||||
header[11] = (preSkip ushr 8).toByte()
|
||||
return header
|
||||
}
|
||||
|
||||
private fun nativeOrderLong(bytes: ByteArray): Long = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder()).long
|
||||
|
||||
@Test
|
||||
fun opusBuildsThreeCsdBuffers() {
|
||||
val header = opusHead(preSkip = 312)
|
||||
val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_OPUS, header)
|
||||
assertEquals(3, csd.size)
|
||||
assertArrayEquals(header, csd[0])
|
||||
// Pre-skip converted to nanoseconds at the Opus 48 kHz clock.
|
||||
assertEquals(312L * 1_000_000_000L / 48_000L, nativeOrderLong(csd[1]))
|
||||
// media3's default seek pre-roll: 3840 samples = 80 ms.
|
||||
assertEquals(80_000_000L, nativeOrderLong(csd[2]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opusRejectsTruncatedHeader() {
|
||||
assertThrows(ParserException::class.java) {
|
||||
FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_OPUS, ByteArray(11))
|
||||
}
|
||||
}
|
||||
|
||||
private fun xiphLace(length: Int): ByteArray {
|
||||
val bytes = ArrayList<Byte>()
|
||||
var remaining = length
|
||||
while (remaining >= 0xFF) {
|
||||
bytes.add(0xFF.toByte())
|
||||
remaining -= 0xFF
|
||||
}
|
||||
bytes.add(remaining.toByte())
|
||||
return bytes.toByteArray()
|
||||
}
|
||||
|
||||
private fun vorbisHeader(type: Int, payloadLength: Int): ByteArray = byteArrayOf(type.toByte()) + "vorbis".toByteArray(Charsets.US_ASCII) + ByteArray(payloadLength)
|
||||
|
||||
@Test
|
||||
fun vorbisSplitsLacedHeadersIntoIdentificationAndSetup() {
|
||||
val id = vorbisHeader(0x01, payloadLength = 23) // 30 bytes, the real identification header size
|
||||
val comment = vorbisHeader(0x03, payloadLength = 300) // > 255 exercises the 0xFF lacing
|
||||
val setup = vorbisHeader(0x05, payloadLength = 40)
|
||||
val extradata = byteArrayOf(0x02) + xiphLace(id.size) + xiphLace(comment.size) + id + comment + setup
|
||||
|
||||
val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, extradata)
|
||||
|
||||
assertEquals(2, csd.size)
|
||||
assertArrayEquals(id, csd[0])
|
||||
assertArrayEquals(setup, csd[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vorbisRejectsNonLacedExtradata() {
|
||||
assertThrows(ParserException::class.java) {
|
||||
FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, byteArrayOf(0x01, 0x02, 0x03))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vorbisRejectsTruncatedExtradata() {
|
||||
val id = vorbisHeader(0x01, payloadLength = 23)
|
||||
val truncated = byteArrayOf(0x02) + xiphLace(id.size) + xiphLace(64) + id // headers end early
|
||||
assertThrows(ParserException::class.java) {
|
||||
FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, truncated)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flacWrapsBareStreamInfoInMarkedStream() {
|
||||
val streamInfo = ByteArray(34) { it.toByte() }
|
||||
val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, streamInfo)
|
||||
assertEquals(1, csd.size)
|
||||
assertArrayEquals(
|
||||
"fLaC".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x80.toByte(), 0x00, 0x00, 34) + streamInfo,
|
||||
csd[0]
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flacPrependsMarkerToBlockHeadedStreamInfo() {
|
||||
val block = byteArrayOf(0x00, 0x00, 0x00, 34) + ByteArray(34) { it.toByte() }
|
||||
val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, block)
|
||||
assertEquals(1, csd.size)
|
||||
assertArrayEquals("fLaC".toByteArray(Charsets.US_ASCII) + block, csd[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flacKeepsMarkedStreamVerbatim() {
|
||||
val marked = "fLaC".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x80.toByte(), 0x00, 0x00, 34) + ByteArray(34)
|
||||
val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, marked)
|
||||
assertEquals(1, csd.size)
|
||||
assertSame(marked, csd[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flacRejectsUnrecognizedExtradata() {
|
||||
assertThrows(ParserException::class.java) {
|
||||
FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, ByteArray(10))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherCodecsPassExtradataThroughAsSingleEntry() {
|
||||
val asc = byteArrayOf(0x12, 0x10) // AAC-LC 44.1 kHz stereo AudioSpecificConfig
|
||||
val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_AAC, asc)
|
||||
assertEquals(1, csd.size)
|
||||
assertSame(asc, csd[0])
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FfmpegDemuxerPolicyTest {
|
||||
@Test
|
||||
fun wireValuesRoundTrip() {
|
||||
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("ffmpeg"))
|
||||
assertEquals(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY, FfmpegDemuxerPolicy.fromWire("media3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownWireValuesResolveToFfmpeg() {
|
||||
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(null))
|
||||
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(""))
|
||||
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("bogus"))
|
||||
// Retired wire value from the container-list era maps to the default.
|
||||
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("auto"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ffmpegDemuxesByDefaultAndMedia3OnlyDisables() {
|
||||
assertTrue(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.FFMPEG))
|
||||
assertFalse(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY))
|
||||
}
|
||||
}
|
||||
-222
@@ -1,222 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import java.io.IOException
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The demuxer's correctness now rests on this source answering any absolute
|
||||
* position, so these pin the contract libavformat depends on: a read lands on
|
||||
* the requested byte, a backward jump reopens rather than returning the wrong
|
||||
* bytes, and sequential reads do not reopen (which would turn steady-state
|
||||
* playback into one request per 64 KB).
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FfmpegRandomAccessSourceTest {
|
||||
|
||||
private val content = ByteArray(4096) { (it % 251).toByte() }
|
||||
private val uri: Uri = Uri.parse("https://example.test/movie.mkv")
|
||||
|
||||
private class FakeDataSource(private val content: ByteArray) : DataSource {
|
||||
var openedAt: Long = -1
|
||||
var closed = false
|
||||
private var position = 0
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
openedAt = dataSpec.position
|
||||
position = dataSpec.position.toInt()
|
||||
return (content.size - position).toLong()
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= content.size) return C.RESULT_END_OF_INPUT
|
||||
val count = minOf(length, content.size - position)
|
||||
content.copyInto(buffer, offset, position, position + count)
|
||||
position += count
|
||||
return count
|
||||
}
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) = Unit
|
||||
|
||||
override fun getUri(): Uri? = null
|
||||
|
||||
override fun getResponseHeaders(): Map<String, List<String>> = emptyMap()
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
|
||||
private inner class FakeFactory : DataSource.Factory {
|
||||
val created = mutableListOf<FakeDataSource>()
|
||||
|
||||
override fun createDataSource(): DataSource = FakeDataSource(content).also { created.add(it) }
|
||||
}
|
||||
|
||||
private fun sourceFor(factory: DataSource.Factory, spec: DataSpec? = DataSpec(uri)) = FfmpegRandomAccessSource(factory) { spec }
|
||||
|
||||
@Test
|
||||
fun readsLandOnTheRequestedAbsolutePosition() {
|
||||
val factory = FakeFactory()
|
||||
val source = sourceFor(factory)
|
||||
val buffer = ByteArray(16)
|
||||
|
||||
assertEquals(16, source.readAt(1000, buffer, 16))
|
||||
|
||||
assertArrayEquals(content.copyOfRange(1000, 1016), buffer)
|
||||
assertEquals(1000L, factory.created.single().openedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sequentialReadsReuseOneHandle() {
|
||||
val factory = FakeFactory()
|
||||
val source = sourceFor(factory)
|
||||
val buffer = ByteArray(64)
|
||||
|
||||
source.readAt(0, buffer, 64)
|
||||
source.readAt(64, buffer, 64)
|
||||
source.readAt(128, buffer, 64)
|
||||
|
||||
assertEquals(1, source.openCount)
|
||||
assertArrayEquals(content.copyOfRange(128, 192), buffer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backwardJumpReopensAndReturnsTheRequestedBytes() {
|
||||
val factory = FakeFactory()
|
||||
val source = sourceFor(factory)
|
||||
val buffer = ByteArray(32)
|
||||
|
||||
source.readAt(2048, buffer, 32)
|
||||
// The jump libavformat makes for an index at the end of the file, then back.
|
||||
assertEquals(32, source.readAt(16, buffer, 32))
|
||||
|
||||
assertArrayEquals(content.copyOfRange(16, 48), buffer)
|
||||
assertEquals(2, source.openCount)
|
||||
assertTrue("abandoned handle must be closed", factory.created.first().closed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endOfInputReportsZeroWithoutAdvancing() {
|
||||
val factory = FakeFactory()
|
||||
val source = sourceFor(factory)
|
||||
val buffer = ByteArray(8)
|
||||
|
||||
assertEquals(0, source.readAt(content.size.toLong(), buffer, 8))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lengthResolvesFromAnOpenAndStaysKnown() {
|
||||
val factory = FakeFactory()
|
||||
val source = sourceFor(factory)
|
||||
|
||||
assertEquals(C.LENGTH_UNSET.toLong(), source.length())
|
||||
source.readAt(512, ByteArray(8), 8)
|
||||
|
||||
assertEquals(content.size.toLong(), source.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clonedSpecKeepsCacheKeyAndFlagsButTakesOurPosition() {
|
||||
val recorded = DataSpec.Builder()
|
||||
.setUri(uri)
|
||||
.setPosition(9999)
|
||||
.setLength(1234)
|
||||
.setKey("download-cache-key")
|
||||
.setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION)
|
||||
.setHttpRequestHeaders(mapOf("X-Test" to "1"))
|
||||
.build()
|
||||
var openedSpec: DataSpec? = null
|
||||
val factory = DataSource.Factory {
|
||||
object : DataSource {
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
openedSpec = dataSpec
|
||||
return C.LENGTH_UNSET.toLong()
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int = C.RESULT_END_OF_INPUT
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) = Unit
|
||||
|
||||
override fun getUri(): Uri? = null
|
||||
|
||||
override fun getResponseHeaders(): Map<String, List<String>> = emptyMap()
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
sourceFor(factory, recorded).readAt(4242, ByteArray(4), 4)
|
||||
|
||||
val spec = requireNotNull(openedSpec)
|
||||
assertEquals(4242L, spec.position)
|
||||
assertEquals(C.LENGTH_UNSET.toLong(), spec.length)
|
||||
assertEquals("download-cache-key", spec.key)
|
||||
assertEquals(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION, spec.flags)
|
||||
assertEquals("1", spec.httpRequestHeaders["X-Test"])
|
||||
assertEquals(uri, spec.uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readWithoutARecordedSpecFails() {
|
||||
val source = sourceFor(FakeFactory(), spec = null)
|
||||
|
||||
assertThrows(IOException::class.java) { source.readAt(0, ByteArray(4), 4) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeReleasesTheHandle() {
|
||||
val factory = FakeFactory()
|
||||
val source = sourceFor(factory)
|
||||
source.readAt(0, ByteArray(4), 4)
|
||||
|
||||
assertTrue(source.isOpen)
|
||||
source.close()
|
||||
|
||||
assertFalse(source.isOpen)
|
||||
assertTrue(factory.created.single().closed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordingFactoryReportsTheSpecMedia3OpenedPerUri() {
|
||||
val delegate = FakeFactory()
|
||||
val recorder = RecordingDataSourceFactory(delegate)
|
||||
val other = Uri.parse("https://example.test/next-episode.mkv")
|
||||
|
||||
assertNull(recorder.specFor(uri))
|
||||
recorder.createDataSource().open(DataSpec.Builder().setUri(uri).setPosition(64).setKey("a").build())
|
||||
recorder.createDataSource().open(DataSpec.Builder().setUri(other).setPosition(128).build())
|
||||
|
||||
// Preloading the next item must not redirect the current demuxer's reads.
|
||||
assertEquals(64L, recorder.specFor(uri)?.position)
|
||||
assertEquals("a", recorder.specFor(uri)?.key)
|
||||
assertEquals(128L, recorder.specFor(other)?.position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordingFactoryDelegatesReads() {
|
||||
val delegate = FakeFactory()
|
||||
val recorder = RecordingDataSourceFactory(delegate)
|
||||
val buffer = ByteArray(16)
|
||||
|
||||
val dataSource = recorder.createDataSource()
|
||||
dataSource.open(DataSpec.Builder().setUri(uri).setPosition(32).build())
|
||||
assertEquals(16, dataSource.read(buffer, 0, 16))
|
||||
|
||||
assertArrayEquals(content.copyOfRange(32, 48), buffer)
|
||||
assertSame(delegate.created.single(), delegate.created.first())
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Byte-exact spec for the media3-shaped subtitle sample prefixes. The shapes
|
||||
* mirror media3 1.11.0 MatroskaExtractor's SSA_PREFIX/SUBRIP_PREFIX/VTT_PREFIX
|
||||
* with the duration patched into the second timecode; SsaParser,
|
||||
* SubripParser, WebvttParser, and AssTrackOutput all parse these exact bytes.
|
||||
*/
|
||||
class FfmpegSubtitleSamplesTest {
|
||||
|
||||
private fun string(bytes: ByteArray): String = bytes.toString(Charsets.UTF_8)
|
||||
|
||||
@Test
|
||||
fun ssaPrefixMatchesMatroskaExtractorShape() {
|
||||
// 1h 2m 3s 450ms -> centiseconds, ':' separator, single-digit hour.
|
||||
assertEquals(
|
||||
"Dialogue: 0:00:00:00,1:02:03:45,",
|
||||
string(FfmpegSubtitleSamples.ssaPrefix(3_723_450_000L))
|
||||
)
|
||||
assertEquals("Dialogue: 0:00:00:00,0:00:05:00,", string(FfmpegSubtitleSamples.ssaPrefix(5_000_000L)))
|
||||
// Sub-centisecond remainders truncate, matching integer division.
|
||||
assertEquals("Dialogue: 0:00:00:00,0:00:00:99,", string(FfmpegSubtitleSamples.ssaPrefix(999_999L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subripPrefixIsACompleteCueHeader() {
|
||||
assertEquals(
|
||||
"1\n00:00:00,000 --> 01:02:03,450\n",
|
||||
string(FfmpegSubtitleSamples.subripPrefix(3_723_450_000L))
|
||||
)
|
||||
assertEquals("1\n00:00:00,000 --> 00:00:05,000\n", string(FfmpegSubtitleSamples.subripPrefix(5_000_000L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vttPrefixIsAOneCueDocumentHeader() {
|
||||
assertEquals(
|
||||
"WEBVTT\n\n00:00:00.000 --> 01:02:03.450\n",
|
||||
string(FfmpegSubtitleSamples.vttPrefix(3_723_450_000L))
|
||||
)
|
||||
assertEquals("WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n", string(FfmpegSubtitleSamples.vttPrefix(5_000_000L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ssaDialogueFormatMatchesMedia3Constant() {
|
||||
assertEquals(
|
||||
"Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
||||
string(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class MatroskaLateTracksTest {
|
||||
|
||||
private class CapturedTrack(val type: Int) : TrackOutput {
|
||||
val timesUs = mutableListOf<Long>()
|
||||
var format: Format? = null
|
||||
|
||||
val sampleCount: Int
|
||||
get() = timesUs.size
|
||||
|
||||
override fun format(format: Format) {
|
||||
this.format = format
|
||||
}
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = input.read(ByteArray(length), 0, length)
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
data.skipBytes(length)
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
timesUs.add(timeUs)
|
||||
}
|
||||
}
|
||||
|
||||
private class CapturingExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, CapturedTrack>()
|
||||
val seekMaps = mutableListOf<SeekMap>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
|
||||
override fun endTracks() = Unit
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
seekMaps.add(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
var position = 0L
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= data.size) return C.RESULT_END_OF_INPUT
|
||||
val bytesRead = minOf(length, data.size - position.toInt())
|
||||
data.copyInto(buffer, offset, position.toInt(), position.toInt() + bytesRead)
|
||||
position += bytesRead
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/matroska_tracks_at_end.mkv")) {
|
||||
"fixture matroska_tracks_at_end.mkv missing from test resources"
|
||||
}.use { it.readBytes() }
|
||||
|
||||
private fun readToEnd(extractor: Extractor, reader: ByteArrayDataReader, length: Long) {
|
||||
var input: ExtractorInput = DefaultExtractorInput(reader, reader.position, length)
|
||||
val seekPosition = PositionHolder()
|
||||
repeat(100_000) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
error("extractor did not reach end of input")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
|
||||
val assHandler = AssHandler()
|
||||
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
|
||||
val output = CapturingExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val data = fixtureData()
|
||||
readToEnd(extractor, ByteArrayDataReader(data), data.size.toLong())
|
||||
|
||||
assertEquals(2, output.tracks.size)
|
||||
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
|
||||
output.tracks.values.forEach { track ->
|
||||
assertNotNull(track.format)
|
||||
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for
|
||||
* tracks-after-clusters files is before the Tracks element parsed — the map then permanently
|
||||
* reports unseekable and resolves every seek to byte 0, snapping playback to the start
|
||||
* (#1969; upstream androidx/media #3377).
|
||||
* The production stack must repair it through the per-track cue lookups.
|
||||
*/
|
||||
@Test
|
||||
fun seeksViaCuesWhenSeekMapIsBuiltBeforeTracks() {
|
||||
val assHandler = AssHandler()
|
||||
val extractor = CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
|
||||
val output = CapturingExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val data = fixtureData()
|
||||
val reader = ByteArrayDataReader(data)
|
||||
readToEnd(extractor, reader, data.size.toLong())
|
||||
|
||||
assertEquals(1, output.seekMaps.size)
|
||||
val seekMap = output.seekMaps.single()
|
||||
assertTrue("tracks-after-clusters file with Cues must be seekable", seekMap.isSeekable)
|
||||
|
||||
val points = seekMap.getSeekPoints(500_000L)
|
||||
// Cue-based resolution snaps to the fixture's only cue point (t=0); a byte-proportional
|
||||
// estimate would return the requested time instead.
|
||||
assertEquals(0L, points.first.timeUs)
|
||||
val position = points.first.position
|
||||
val clusterId = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
|
||||
assertTrue(
|
||||
"seek position $position must point at a Cluster element",
|
||||
data.copyOfRange(position.toInt(), position.toInt() + 4).contentEquals(clusterId)
|
||||
)
|
||||
|
||||
output.tracks.values.forEach { it.timesUs.clear() }
|
||||
extractor.seek(position, points.first.timeUs)
|
||||
reader.position = position
|
||||
readToEnd(extractor, reader, data.size.toLong())
|
||||
|
||||
output.tracks.values.forEach { track ->
|
||||
assertTrue("expected extracted samples after seek for track type ${track.type}", track.sampleCount > 0)
|
||||
assertEquals(points.first.timeUs, track.timesUs.first())
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-33
@@ -5,7 +5,14 @@ import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import java.io.EOFException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -17,17 +24,16 @@ import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Drives LatmTrackOutput with LOAS frames from the committed fixture (1s 440Hz
|
||||
* sine, AAC-LC 48kHz stereo, LATM/LOAS) and verifies it unwraps them to raw
|
||||
* AAC with a synthesized AudioSpecificConfig. Both demuxer paths deliver LOAS
|
||||
* this way: the FFmpeg extractor wraps aac_latm tracks directly.
|
||||
* Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS
|
||||
* muxed into MKV as A_MS/ACM tag 0x1602) and verifies direct Matroska playback
|
||||
* unwraps LOAS frames to raw AAC with a synthesized AudioSpecificConfig.
|
||||
*
|
||||
* Robolectric provides real android.util.* implementations for media3's
|
||||
* internals.
|
||||
* Robolectric provides real android.util.* implementations — MatroskaExtractor
|
||||
* stores tracks in a SparseArray, which is a no-op stub on plain JVM.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class LatmTrackOutputTest {
|
||||
class MatroskaLatmSupportTest {
|
||||
|
||||
private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray)
|
||||
|
||||
@@ -65,6 +71,14 @@ class LatmTrackOutputTest {
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, FakeTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
|
||||
override fun endTracks() {}
|
||||
override fun seekMap(seekMap: SeekMap) {}
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
var position = 0L
|
||||
|
||||
@@ -104,46 +118,76 @@ class LatmTrackOutputTest {
|
||||
return frames
|
||||
}
|
||||
|
||||
private fun placeholderFormat(): Format = Format.Builder()
|
||||
.setId("1")
|
||||
.setSampleMimeType(MimeTypes.AUDIO_AAC)
|
||||
.setLabel("LATM track")
|
||||
.setLanguage("eng")
|
||||
.build()
|
||||
private fun extractFixture(): FakeExtractorOutput {
|
||||
val data = fixtureData()
|
||||
|
||||
val assHandler = AssHandler()
|
||||
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
|
||||
val output = FakeExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val reader = ByteArrayDataReader(data)
|
||||
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
|
||||
val seekPosition = PositionHolder()
|
||||
while (true) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return output
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unwrapsLoasFramesToRawAac() {
|
||||
val delegate = FakeTrackOutput()
|
||||
val output = LatmTrackOutput(delegate, 1)
|
||||
output.format(placeholderFormat())
|
||||
fun unwrapsLoasAcmTrackToRawAac() {
|
||||
val output = extractFixture()
|
||||
|
||||
val frames = loasFrames(40)
|
||||
var timeUs = 0L
|
||||
for (frame in frames) {
|
||||
output.sampleData(ParsableByteArray(frame), frame.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, frame.size, 0, null)
|
||||
timeUs += 21_333
|
||||
}
|
||||
assertEquals(1, output.tracks.size)
|
||||
val track = output.tracks.values.first()
|
||||
|
||||
// The placeholder format must be swallowed; the LATM-derived AAC format
|
||||
// carries the AudioSpecificConfig and the original track metadata.
|
||||
val format = delegate.formats.last()
|
||||
// No audio/x-unknown format may reach the queue; the LATM-derived AAC
|
||||
// format must carry the AudioSpecificConfig for MediaCodec.
|
||||
assertFalse(track.formats.any { it.sampleMimeType == MimeTypes.AUDIO_UNKNOWN })
|
||||
val format = track.formats.last()
|
||||
assertEquals(MimeTypes.AUDIO_AAC, format.sampleMimeType)
|
||||
assertEquals(48000, format.sampleRate)
|
||||
assertEquals(2, format.channelCount)
|
||||
assertEquals("1", format.id)
|
||||
assertEquals("LATM track", format.label)
|
||||
assertTrue(format.initializationData.isNotEmpty())
|
||||
assertTrue(format.initializationData[0].isNotEmpty())
|
||||
|
||||
assertTrue("expected ~40 samples, got ${delegate.samples.size}", delegate.samples.size in 35..40)
|
||||
for (sample in delegate.samples) {
|
||||
// 1s at 48kHz / 1024 samples per AAC frame ≈ 47 frames
|
||||
assertTrue("expected ~47 samples, got ${track.samples.size}", track.samples.size in 40..55)
|
||||
|
||||
// Raw AAC payloads: smaller than the LOAS wrapping, no LOAS syncword,
|
||||
// keyframe-flagged, monotonic timestamps spanning ~1s.
|
||||
var prevTimeUs = Long.MIN_VALUE
|
||||
for (sample in track.samples) {
|
||||
assertTrue(sample.data.isNotEmpty())
|
||||
val isLoasSync = sample.data.size >= 2 &&
|
||||
(sample.data[0].toInt() and 0xFF) == 0x56 &&
|
||||
(sample.data[1].toInt() and 0xE0) == 0xE0
|
||||
assertFalse("sample still LOAS-framed", isLoasSync)
|
||||
assertEquals(C.BUFFER_FLAG_KEY_FRAME, sample.flags and C.BUFFER_FLAG_KEY_FRAME)
|
||||
assertTrue(sample.timeUs >= prevTimeUs)
|
||||
prevTimeUs = sample.timeUs
|
||||
}
|
||||
val spanUs = track.samples.last().timeUs - track.samples.first().timeUs
|
||||
assertTrue("expected ~1s span, got ${spanUs}us", spanUs in 800_000..1_200_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsLoasAcmCodecPrivate() {
|
||||
val loas = byteArrayOf(0x02, 0x16, 0, 0, 0, 0)
|
||||
assertTrue(isLoasAcmTrack("A_MS/ACM", loas))
|
||||
// Wrong tag (PCM), wrong codec, or missing private data must not match
|
||||
assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x01, 0x00, 0, 0)))
|
||||
assertFalse(isLoasAcmTrack("A_AAC", loas))
|
||||
assertFalse(isLoasAcmTrack("A_MS/ACM", null))
|
||||
assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x02)))
|
||||
assertFalse(isLoasAcmTrack(null, loas))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -161,14 +205,19 @@ class LatmTrackOutputTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesStreamMuxConfigAcrossReset() {
|
||||
fun preservesStreamMuxConfigAcrossExtractorSeek() {
|
||||
val frames = loasFrames(2)
|
||||
assertEquals(0, frames[0][3].toInt() and 0x80)
|
||||
assertEquals(0x80, frames[1][3].toInt() and 0x80)
|
||||
|
||||
val delegate = FakeTrackOutput()
|
||||
val output = LatmTrackOutput(delegate, 1)
|
||||
output.format(placeholderFormat())
|
||||
output.format(
|
||||
Format.Builder()
|
||||
.setId("1")
|
||||
.setSampleMimeType(MimeTypes.AUDIO_UNKNOWN)
|
||||
.build()
|
||||
)
|
||||
|
||||
output.sampleData(ParsableByteArray(frames[0]), frames[0].size, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, frames[0].size, 0, null)
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class StreamBitrateMeterTest {
|
||||
|
||||
/** Feeds [count] packets of [sizeBytes] every [intervalUs], starting at [startUs]. */
|
||||
private fun StreamBitrateMeter.feed(count: Int, sizeBytes: Int, intervalUs: Long, startUs: Long = 0L) {
|
||||
for (i in 0 until count) onPacket(startUs + i * intervalUs, sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `constant packet stream measures its bitrate`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
// 1200-byte packets every 32 ms: 1200 * 8 / 0.032 = 300 kbps.
|
||||
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
|
||||
assertEquals(300_000, meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too few packets report nothing`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
meter.feed(count = 15, sizeBytes = 1200, intervalUs = 32_000)
|
||||
assertNull(meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero pts span reports nothing`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
repeat(32) { meter.onPacket(0L, 1200) }
|
||||
assertNull(meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window drops old packets so a bitrate change is followed`() {
|
||||
val meter = StreamBitrateMeter(windowUs = 1_000_000)
|
||||
meter.feed(count = 100, sizeBytes = 1200, intervalUs = 32_000)
|
||||
// Continue at four times the packet size; after well over a window of
|
||||
// new packets, only the new rate remains.
|
||||
meter.feed(count = 100, sizeBytes = 4800, intervalUs = 32_000, startUs = 100 * 32_000L)
|
||||
assertEquals(1_200_000, meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backwards pts jump resets the window`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
|
||||
// Seek back: the single post-seek packet must not combine with the
|
||||
// pre-seek span.
|
||||
meter.onPacket(0L, 1200)
|
||||
assertNull(meter.bitrateBps())
|
||||
meter.feed(count = 31, sizeBytes = 1200, intervalUs = 32_000, startUs = 32_000)
|
||||
assertEquals(300_000, meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty packets are ignored`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
|
||||
meter.onPacket(32 * 32_000L, 0)
|
||||
assertEquals(300_000, meter.bitrateBps())
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.DeflaterOutputStream
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ZlibInflatingTrackOutputTest {
|
||||
@Before
|
||||
fun enableMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun resetMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
|
||||
val original = ByteArray(512 * 1024) { (it % 251).toByte() }
|
||||
val compressed = deflate(original)
|
||||
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
val split = compressed.size / 2
|
||||
|
||||
output.sampleData(
|
||||
ParsableByteArray(compressed.copyOfRange(0, split)),
|
||||
split,
|
||||
TrackOutput.SAMPLE_DATA_PART_MAIN
|
||||
)
|
||||
output.sampleData(
|
||||
ParsableByteArray(compressed.copyOfRange(split, compressed.size)),
|
||||
compressed.size - split,
|
||||
TrackOutput.SAMPLE_DATA_PART_MAIN
|
||||
)
|
||||
output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
|
||||
|
||||
assertArrayEquals(original, delegate.retained.toByteArray())
|
||||
assertEquals(42L, delegate.timeUs)
|
||||
assertEquals(C.BUFFER_FLAG_KEY_FRAME, delegate.flags)
|
||||
assertEquals(original.size, delegate.sampleSize)
|
||||
assertEquals(0, delegate.offset)
|
||||
assertEquals(1, delegate.metadataCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeTransformUsesMetadataBoundariesForBufferedSamples() {
|
||||
val first = ByteArray(4096) { (it % 19).toByte() }
|
||||
val second = ByteArray(2048) { (it % 23).toByte() }
|
||||
val firstCompressed = deflate(first)
|
||||
val secondCompressed = deflate(second)
|
||||
val combined = firstCompressed + secondCompressed
|
||||
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
|
||||
output.sampleData(ParsableByteArray(combined), combined.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
output.sampleMetadata(1, 0, firstCompressed.size, secondCompressed.size, null)
|
||||
output.sampleMetadata(2, 0, secondCompressed.size, 0, null)
|
||||
|
||||
assertArrayEquals(first + second, delegate.retained.toByteArray())
|
||||
assertEquals(2, delegate.metadataCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactlySixteenMiBInflatedSampleIsAccepted() {
|
||||
val size = 16 * 1024 * 1024
|
||||
val compressed = deflateRepeated(size)
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
|
||||
feed(output, compressed)
|
||||
|
||||
assertEquals(size, delegate.byteCount)
|
||||
assertEquals(size, delegate.sampleSize)
|
||||
assertEquals(1, delegate.metadataCount)
|
||||
assertTrue(compressed.size < 64 * 1024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneByteOverInflatedLimitFailsBeforeDelegation() {
|
||||
val compressed = deflateRepeated(16 * 1024 * 1024 + 1)
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
|
||||
assertThrows(ParserException::class.java) { feed(output, compressed) }
|
||||
|
||||
assertEquals(0, delegate.byteCount)
|
||||
assertEquals(0, delegate.metadataCount)
|
||||
assertTrue(compressed.size < 64 * 1024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excessiveCompressionRatioFailsBeforeInflatedSizeLimit() {
|
||||
val compressed = deflate(ByteArray(8 * 1024 * 1024))
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
|
||||
assertThrows(ParserException::class.java) { feed(output, compressed) }
|
||||
|
||||
assertEquals(0, delegate.byteCount)
|
||||
assertEquals(0, delegate.metadataCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptStreamPassesTheBoundedSampleThroughUnchanged() {
|
||||
val corrupt = byteArrayOf(0, 1, 2, 3)
|
||||
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
|
||||
feed(output, corrupt)
|
||||
|
||||
assertArrayEquals(corrupt, delegate.retained.toByteArray())
|
||||
assertEquals(corrupt.size, delegate.sampleSize)
|
||||
assertEquals(1, delegate.metadataCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun truncatedAndDictionaryStreamsFailClosed() {
|
||||
val valid = deflate(ByteArray(4096) { 7 })
|
||||
val dictionary = "shared-zlib-dictionary".toByteArray()
|
||||
val cases = listOf(
|
||||
valid.copyOf(valid.size - 2),
|
||||
deflate(ByteArray(4096) { 3 }, dictionary)
|
||||
)
|
||||
|
||||
for (compressed in cases) {
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||
|
||||
assertThrows(ParserException::class.java) { feed(output, compressed) }
|
||||
assertEquals(0, delegate.byteCount)
|
||||
assertEquals(0, delegate.metadataCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() {
|
||||
val bytes = byteArrayOf(9, 8, 7, 6)
|
||||
val trailing = byteArrayOf(5, 4)
|
||||
val allBytes = bytes + trailing
|
||||
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||
val output = ZlibInflatingTrackOutput(delegate)
|
||||
|
||||
output.sampleData(ParsableByteArray(allBytes), allBytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
output.sampleMetadata(99L, 3, bytes.size, trailing.size, null)
|
||||
|
||||
assertArrayEquals(allBytes, delegate.retained.toByteArray())
|
||||
assertEquals(bytes.size, delegate.sampleSize)
|
||||
assertEquals(trailing.size, delegate.offset)
|
||||
assertEquals(99L, delegate.timeUs)
|
||||
}
|
||||
|
||||
private fun feed(output: ZlibInflatingTrackOutput, compressed: ByteArray) {
|
||||
output.sampleData(
|
||||
ParsableByteArray(compressed),
|
||||
compressed.size,
|
||||
TrackOutput.SAMPLE_DATA_PART_MAIN
|
||||
)
|
||||
output.sampleMetadata(1L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
|
||||
}
|
||||
|
||||
private fun deflate(bytes: ByteArray, dictionary: ByteArray? = null): ByteArray {
|
||||
val target = ByteArrayOutputStream()
|
||||
val deflater = Deflater().apply {
|
||||
if (dictionary != null) setDictionary(dictionary)
|
||||
}
|
||||
DeflaterOutputStream(target, deflater).use { it.write(bytes) }
|
||||
return target.toByteArray()
|
||||
}
|
||||
|
||||
private fun deflateRepeated(size: Int): ByteArray {
|
||||
val target = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(8192) { (it % 7).toByte() }
|
||||
DeflaterOutputStream(target).use { stream ->
|
||||
var remaining = size
|
||||
while (remaining > 0) {
|
||||
val count = minOf(remaining, chunk.size)
|
||||
stream.write(chunk, 0, count)
|
||||
remaining -= count
|
||||
}
|
||||
}
|
||||
return target.toByteArray()
|
||||
}
|
||||
|
||||
private class RecordingTrackOutput(
|
||||
private val retainBytes: Boolean = false
|
||||
) : TrackOutput {
|
||||
val retained = ByteArrayOutputStream()
|
||||
var byteCount = 0
|
||||
var metadataCount = 0
|
||||
var timeUs = C.TIME_UNSET
|
||||
var flags = 0
|
||||
var sampleSize = -1
|
||||
var offset = -1
|
||||
|
||||
override fun format(format: Format) = Unit
|
||||
|
||||
override fun sampleData(
|
||||
input: DataReader,
|
||||
length: Int,
|
||||
allowEndOfInput: Boolean,
|
||||
sampleDataPart: Int
|
||||
): Int {
|
||||
val buffer = ByteArray(length)
|
||||
val read = input.read(buffer, 0, length)
|
||||
if (read > 0) record(buffer, read)
|
||||
return read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
val buffer = ByteArray(length)
|
||||
data.readBytes(buffer, 0, length)
|
||||
record(buffer, length)
|
||||
}
|
||||
|
||||
private fun record(buffer: ByteArray, length: Int) {
|
||||
byteCount += length
|
||||
if (retainBytes) retained.write(buffer, 0, length)
|
||||
}
|
||||
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long,
|
||||
flags: Int,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
cryptoData: TrackOutput.CryptoData?
|
||||
) {
|
||||
metadataCount++
|
||||
this.timeUs = timeUs
|
||||
this.flags = flags
|
||||
sampleSize = size
|
||||
this.offset = offset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.DeflaterOutputStream
|
||||
import java.util.zip.Inflater
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint,
|
||||
* timecode, flags) must survive byte-identically, the payload must inflate, and
|
||||
* every block the rewriter cannot prove safe must pass through unchanged (null).
|
||||
*/
|
||||
class ZlibTextBlockTest {
|
||||
|
||||
private val inflater = Inflater()
|
||||
|
||||
private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload
|
||||
|
||||
private fun deflate(bytes: ByteArray): ByteArray {
|
||||
val target = ByteArrayOutputStream()
|
||||
DeflaterOutputStream(target, Deflater()).use { it.write(bytes) }
|
||||
return target.toByteArray()
|
||||
}
|
||||
|
||||
// Track 3 (varint 0x83), relative timecode 0x0102, no lacing.
|
||||
private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00)
|
||||
|
||||
@Test
|
||||
fun inflatesUnlacedBlockPayloadAndPreservesHeader() {
|
||||
val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray()
|
||||
val data = block(unlacedHeader, deflate(dialogue))
|
||||
|
||||
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
|
||||
|
||||
assertArrayEquals(unlacedHeader + dialogue, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoByteTrackNumberVarintIsPreserved() {
|
||||
val dialogue = "1,,Default,,0,0,0,,hello".toByteArray()
|
||||
val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298
|
||||
val data = block(header, deflate(dialogue))
|
||||
|
||||
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
|
||||
|
||||
assertArrayEquals(header + dialogue, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lacedBlockPassesThrough() {
|
||||
val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set
|
||||
val data = block(laced, deflate("payload".toByteArray()))
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptStreamPassesThrough() {
|
||||
val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun truncatedStreamPassesThrough() {
|
||||
val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray())
|
||||
val truncated = compressed.copyOf(compressed.size - 4)
|
||||
val data = block(unlacedHeader, truncated)
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerOnlyBlockPassesThrough() {
|
||||
assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater))
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.CueDecoder
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with
|
||||
* `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that
|
||||
* broke #2023) and an identical uncompressed mux — and verifies the zlib block
|
||||
* payloads are inflated *before* MatroskaExtractor's subtitle sample assembly.
|
||||
*
|
||||
* Regenerate with:
|
||||
* mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass
|
||||
* mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass
|
||||
* where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events.
|
||||
*
|
||||
* Robolectric provides real android.util/android.os implementations —
|
||||
* MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which
|
||||
* are no-op stubs on plain JVM.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ZlibTextSubtitleExtractionTest {
|
||||
|
||||
private class CapturedSample(val timeUs: Long, val data: ByteArray)
|
||||
|
||||
private class FakeTrackOutput : TrackOutput {
|
||||
val formats = mutableListOf<androidx.media3.common.Format>()
|
||||
val samples = mutableListOf<CapturedSample>()
|
||||
private var buf = ByteArray(64 * 1024)
|
||||
private var bufLen = 0
|
||||
|
||||
override fun format(format: androidx.media3.common.Format) {
|
||||
formats.add(format)
|
||||
}
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
|
||||
ensureCapacity(bufLen + length)
|
||||
val read = input.read(buf, bufLen, length)
|
||||
if (read > 0) bufLen += read
|
||||
return read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
ensureCapacity(bufLen + length)
|
||||
data.readBytes(buf, bufLen, length)
|
||||
bufLen += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
val start = bufLen - offset - size
|
||||
samples.add(CapturedSample(timeUs, buf.copyOfRange(start, start + size)))
|
||||
if (offset == 0) bufLen = 0
|
||||
}
|
||||
|
||||
private fun ensureCapacity(needed: Int) {
|
||||
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, FakeTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
|
||||
override fun endTracks() {}
|
||||
override fun seekMap(seekMap: SeekMap) {}
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
var position = 0L
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= data.size) return C.RESULT_END_OF_INPUT
|
||||
val toRead = minOf(length, data.size - position.toInt())
|
||||
System.arraycopy(data, position.toInt(), buffer, offset, toRead)
|
||||
position += toRead
|
||||
return toRead
|
||||
}
|
||||
}
|
||||
|
||||
private fun extract(resource: String): FakeExtractorOutput {
|
||||
val data = checkNotNull(javaClass.getResourceAsStream(resource)) {
|
||||
"fixture $resource missing from test resources"
|
||||
}.use { it.readBytes() }
|
||||
|
||||
// DefaultSubtitleParserFactory (not AssSubtitleParserFactory) so the SSA track
|
||||
// transcodes to decodable media3 cues instead of loading native libass.
|
||||
val extractor = ZlibMatroskaExtractor(DefaultSubtitleParserFactory(), AssHandler())
|
||||
val output = FakeExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val reader = ByteArrayDataReader(data)
|
||||
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
|
||||
val seekPosition = PositionHolder()
|
||||
while (true) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return output
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DecodedCue(val startTimeUs: Long, val durationUs: Long, val text: String)
|
||||
|
||||
private fun decodeCues(output: FakeExtractorOutput): List<DecodedCue> {
|
||||
val track = output.tracks.values.single()
|
||||
val format = track.formats.last()
|
||||
assertEquals(MimeTypes.APPLICATION_MEDIA3_CUES, format.sampleMimeType)
|
||||
assertEquals(MimeTypes.TEXT_SSA, format.codecs)
|
||||
val decoder = CueDecoder()
|
||||
return track.samples.map { sample ->
|
||||
val cues = decoder.decode(sample.timeUs, sample.data, 0, sample.data.size)
|
||||
DecodedCue(
|
||||
cues.startTimeUs,
|
||||
cues.durationUs,
|
||||
cues.cues.joinToString("\n") { it.text.toString() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibCompressedAssDialogueIsInflatedToParseableCues() {
|
||||
val cues = decodeCues(extract("/zlib_ssa.mkv"))
|
||||
|
||||
assertEquals(45, cues.size)
|
||||
assertEquals("SUBTITLE LINE 1 (0:00:00.00)", cues.first().text)
|
||||
assertEquals(0L, cues.first().startTimeUs)
|
||||
assertEquals(2_000_000L, cues.first().durationUs)
|
||||
assertEquals("SUBTITLE LINE 45 (0:01:28.00)", cues.last().text)
|
||||
assertEquals(88_000_000L, cues.last().startTimeUs)
|
||||
cues.forEachIndexed { index, cue ->
|
||||
assertEquals(index * 2_000_000L, cue.startTimeUs)
|
||||
assertTrue("cue $index text: ${cue.text}", cue.text.startsWith("SUBTITLE LINE ${index + 1} "))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibAndPlainMuxesProduceIdenticalCues() {
|
||||
val zlib = decodeCues(extract("/zlib_ssa.mkv"))
|
||||
val plain = decodeCues(extract("/plain_ssa.mkv"))
|
||||
|
||||
assertEquals(plain.size, zlib.size)
|
||||
plain.zip(zlib).forEachIndexed { index, (expected, actual) ->
|
||||
assertEquals("startTimeUs of cue $index", expected.startTimeUs, actual.startTimeUs)
|
||||
assertEquals("durationUs of cue $index", expected.durationUs, actual.durationUs)
|
||||
assertEquals("text of cue $index", expected.text, actual.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Boundaries are the contract; rationale on [DemuxerBudget]. */
|
||||
class DemuxerBudgetTest {
|
||||
private val mib = 1024L * 1024L
|
||||
|
||||
@Test
|
||||
fun `unknown heap class keeps mpv defaults`() {
|
||||
assertNull(DemuxerBudget.forHeapClassMB(0))
|
||||
assertNull(DemuxerBudget.forHeapClassMB(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small heap class gets the tight tier`() {
|
||||
val budget = DemuxerBudget.forHeapClassMB(256)!!
|
||||
assertEquals(32 * mib, budget.aheadBytes)
|
||||
assertEquals(16 * mib, budget.backBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid heap class gets the middle tier`() {
|
||||
assertEquals(64 * mib, DemuxerBudget.forHeapClassMB(257)!!.aheadBytes)
|
||||
val budget = DemuxerBudget.forHeapClassMB(512)!!
|
||||
assertEquals(64 * mib, budget.aheadBytes)
|
||||
assertEquals(32 * mib, budget.backBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large heap class gets the full tier`() {
|
||||
val budget = DemuxerBudget.forHeapClassMB(513)!!
|
||||
assertEquals(100 * mib, budget.aheadBytes)
|
||||
assertEquals(48 * mib, budget.backBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* P5 has no compatible base layer: on a device without native DV it must
|
||||
* leave the video plane for gpu-next software reshaping, and nothing else
|
||||
* may be routed (wrong-colors class verified on a Pixel 7). The vo target
|
||||
* matrix keeps gpu-next away from hardware sessions (#2010) while giving
|
||||
* the reshaping path the only vo that composites RPU metadata (#1902).
|
||||
*/
|
||||
class GpuVoPolicyTest {
|
||||
@Test
|
||||
fun `P5 without native support is routed`() {
|
||||
assertTrue(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `P5 with native support stays on the plane`() {
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base-layer-compatible profiles are never routed`() {
|
||||
// P7/P8 strip to an HDR10/HLG base layer; non-DV content has no profile.
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(7L, "auto", canPlayP5Natively = false))
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(8L, "auto", canPlayP5Natively = false))
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(null, "auto", canPlayP5Natively = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit conversion modes are user overrides and stay native`() {
|
||||
for (mode in listOf("disabled", "native", "dv81", "hevc", "hevc_strip")) {
|
||||
assertFalse(mode, GpuVoPolicy.needsDvReshaping(5L, mode, canPlayP5Natively = false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hdr tone-mapping is needed only for a PQ or HLG signal on a non-HDR display`() {
|
||||
assertTrue(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = false))
|
||||
assertTrue(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = false))
|
||||
// An HDR display scans the signal out itself.
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = true))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = true))
|
||||
// SDR transfers need no mapping, and mpv reports none before the first
|
||||
// frame of a file.
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("bt.1886", displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("srgb", displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping(null, displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("", displaySupportsHdr = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only direct mediacodec output can stay on the plane`() {
|
||||
// -copy also reads frames back into system memory, so it leaves too.
|
||||
assertTrue(GpuVoPolicy.needsSoftwareRender("no"))
|
||||
assertTrue(GpuVoPolicy.needsSoftwareRender("mediacodec-copy"))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender("mediacodec"))
|
||||
// Unreported until the decoder initializes: stay on the plane.
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender(null))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a software-decoding session targets gpu, not gpu-next`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SW_DECODE)))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_SW_DECODE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no reasons keeps the video plane`() {
|
||||
assertNull(GpuVoPolicy.targetFor(emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dv reshaping targets gpu-next even alongside other reasons`() {
|
||||
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
|
||||
assertEquals(
|
||||
"gpu-next",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_DV_RESHAPE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shaders and chain failure target the hardware-safe gpu vo`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CHAIN_FAILURE)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_CHAIN_FAILURE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hdr tone-mapping targets gpu but yields to dv reshaping`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_SHADERS))
|
||||
)
|
||||
assertEquals(
|
||||
"gpu-next",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_DV_RESHAPE))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import android.os.Looper
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.FrameLayout
|
||||
import com.edde746.plezy.libmpv.EndFileReason
|
||||
import com.edde746.plezy.libmpv.LogLevel
|
||||
import com.edde746.plezy.libmpv.LogMessage
|
||||
import com.edde746.plezy.libmpv.MpvEvent
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import dev.jdtech.mpv.EndFileReason
|
||||
import dev.jdtech.mpv.LogLevel
|
||||
import dev.jdtech.mpv.LogMessage
|
||||
import dev.jdtech.mpv.MpvEvent
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -62,14 +63,24 @@ class MpvPlayerPluginTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardwareDecodeSessionsKeepTheLegacyGpuVo() {
|
||||
// gpu-next under hwdec=mediacodec fails every frame on Tegra (the
|
||||
// cross-stage samplerExternalOES linker bug): solid blue screen with
|
||||
// audio on the Shield (#2010). DV reshaping — the reason gpu-next exists
|
||||
// on Android (#1902) — only happens under software decode anyway, so
|
||||
// gpu-next is offered exactly there and nowhere else.
|
||||
assertEquals("gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
|
||||
assertEquals("gpu-next,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
|
||||
fun hardwareDecodeSessionsUseTheMediaCodecVoWithGpuFallback() {
|
||||
// Rationale on MpvPlayerCore.initialVideoOutput.
|
||||
assertEquals("mediacodec,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
|
||||
assertEquals("gpu,gpu-next", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrSurfaceIsWantedOnlyForPqAndHlgTransfers() {
|
||||
// Rationale on MpvPlayerCore.wantsHdrSurface: both render into a PQ
|
||||
// target; anything else stays on the sRGB surface, which renders every
|
||||
// content correctly.
|
||||
assertTrue(MpvPlayerCore.wantsHdrSurface("smpte2084"))
|
||||
assertTrue(MpvPlayerCore.wantsHdrSurface("arib-std-b67"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("bt709"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("bt1886"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("unknown"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface(""))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -622,6 +633,53 @@ class MpvPlayerPluginTest {
|
||||
assertEquals(0, pending.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleDisposeIsAcknowledgedWithoutTearingDownTheCore() {
|
||||
// A dispose whose instanceId is not the core creator's lost the ownership
|
||||
// race to a successor; tearing the core down anyway would kill that
|
||||
// successor's session. It must be acknowledged as a no-op instead.
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 2L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 1)), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNotNull(getPluginField(plugin, "playerCore"))
|
||||
assertEquals(2L, getPluginField(plugin, "coreInstanceId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchingDisposeTearsDownTheCoreAndClearsTheToken() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 7L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 7)), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNull(getPluginField(plugin, "playerCore"))
|
||||
assertNull(getPluginField(plugin, "coreInstanceId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenlessDisposeKeepsLegacySemanticsAndTearsDownTheCore() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 7L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", null), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNull(getPluginField(plugin, "playerCore"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
@@ -891,6 +949,97 @@ class MpvPlayerPluginTest {
|
||||
getBoolean(core)
|
||||
}
|
||||
|
||||
private fun invokeSetGpuVoRequirement(core: MpvPlayerCore, reason: String, active: Boolean) {
|
||||
MpvPlayerCore::class.java
|
||||
.getDeclaredMethod("setGpuVoRequirement", String::class.java, Boolean::class.javaPrimitiveType)
|
||||
.apply {
|
||||
isAccessible = true
|
||||
invoke(core, reason, active)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voTargetFollowsTheActiveReasonSet() {
|
||||
// Reason precedence through the real property-write path: DV reshaping
|
||||
// outranks HDR-on-SDR, dropping the winner falls back to the reason still
|
||||
// active, and dropping the last one returns the session to the plane.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
setBoolean(core, "isInitialized", true)
|
||||
|
||||
fun lastVo(): String? = writes.toList().lastOrNull { it.first == "vo" }?.second
|
||||
|
||||
// HDR-on-SDR raises first, then the DV router wins the arbitration.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, true)
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, true)
|
||||
awaitCondition { lastVo() == "gpu-next" }
|
||||
assertEquals("gpu-next", lastVo())
|
||||
|
||||
// Dropping the winning reason must fall back to the one still active,
|
||||
// not to the plane.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, false)
|
||||
awaitCondition { lastVo() == "gpu" }
|
||||
assertEquals("gpu", lastVo())
|
||||
|
||||
// Last reason dropping returns the session to the video plane.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, false)
|
||||
awaitCondition { lastVo() == "mediacodec" }
|
||||
assertEquals("mediacodec", lastVo())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dvConversionModeMapsOntoForkDecoderOptions() {
|
||||
// The app-level `dv-conversion-mode` property must translate to the fork
|
||||
// FFmpeg hevc_mediacodec options, mirroring the ExoPlayer DoviBridge
|
||||
// modes. Robolectric reports no Dolby Vision display, so `auto` takes the
|
||||
// no-DV branch deterministically.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
|
||||
fun apply(mode: String): Result<Unit> {
|
||||
writes.clear()
|
||||
var outcome: Result<Unit>? = null
|
||||
core.setProperty("dv-conversion-mode", mode) { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
return outcome!!
|
||||
}
|
||||
|
||||
assertTrue(apply("auto").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=0,dv_p7_mode=strip"), writes.toList())
|
||||
|
||||
assertTrue(apply("disabled").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=native"), writes.toList())
|
||||
|
||||
assertTrue(apply("dv81").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=convert"), writes.toList())
|
||||
|
||||
assertTrue(apply("hevc_strip").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=strip"), writes.toList())
|
||||
|
||||
val invalid = apply("bogus")
|
||||
assertTrue(invalid.isFailure)
|
||||
assertTrue(writes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderOptionsMergeKeepsUserEntriesFirst() {
|
||||
// The property interface cannot append to a list option, so the write
|
||||
// replaces it wholesale: a user's own mpv.conf `vd-lavc-o` entries must
|
||||
// survive, with the app's keys last (FFmpeg keeps the last duplicate).
|
||||
assertEquals(
|
||||
"threads=4,dolby_vision=1",
|
||||
MpvPlayerCore.mergeDecoderOptions("threads=4", "dolby_vision=1")
|
||||
)
|
||||
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(null, "dolby_vision=1"))
|
||||
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(" ", "dolby_vision=1"))
|
||||
}
|
||||
|
||||
private fun awaitQueueEntry(
|
||||
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||
expected: Pair<String, String>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VideoRectPolicyTest {
|
||||
@Test
|
||||
fun `contain letterboxes wide content inside the container`() {
|
||||
// 2.39:1 content on a 16:9 container: full width, bars top and bottom.
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
|
||||
assertEquals(1920, size.width)
|
||||
assertEquals(803, size.height)
|
||||
assertTrue(size.height < 1080)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contain pillarboxes tall content inside the container`() {
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 1000, 2390)!!
|
||||
assertEquals(1080, size.height)
|
||||
assertTrue(size.width < 1920)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panscan 1 fills the container so the image is cropped, not letterboxed`() {
|
||||
// The regression this exists for: cover used to be a no-op on the plane.
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue("cover must reach the container height", size.height >= 1080)
|
||||
assertTrue("width must overflow and be clipped", size.width > 1920)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panscan interpolates between contain and cover`() {
|
||||
val contain = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
|
||||
val half = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 0.5f)!!
|
||||
val cover = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue(half.height > contain.height)
|
||||
assertTrue(half.height < cover.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `video-zoom is a log2 factor on top of the fit`() {
|
||||
val base = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
|
||||
val doubled = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 1f)!!
|
||||
val halved = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = -1f)!!
|
||||
assertEquals(base.width * 2, doubled.width)
|
||||
assertEquals(base.width / 2, halved.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cover fills a container whose aspect is wildly mismatched`() {
|
||||
// Scope content in a portrait container: cover/fit exceeds 4, which a
|
||||
// fit-relative clamp would truncate back into letterboxing.
|
||||
val size = VideoRectPolicy.sizeFor(1080, 2400, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue("height must reach the container", size.height >= 2400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absurd zoom is bounded rather than allocating an unbounded surface`() {
|
||||
val fit = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
|
||||
val absurd = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 12f)!!
|
||||
assertEquals(fit.width * 4, absurd.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown dimensions yield no size instead of a degenerate one`() {
|
||||
assertNull(VideoRectPolicy.sizeFor(0, 1080, 1920, 1080))
|
||||
assertNull(VideoRectPolicy.sizeFor(1920, 1080, 0, 0))
|
||||
assertNull(VideoRectPolicy.sizeFor(1920, 0, 1920, 1080))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.time.Duration
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
/**
|
||||
* Teardown ordering for frame-rate matching (#2172): restoring the display
|
||||
* mode while the display is still signaling HDR folds the HDR exit and the
|
||||
* mode change into one slow HDMI renegotiation. An HDR session therefore
|
||||
* defers the restore; an SDR session restores immediately.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FrameRateManagerRestoreTest {
|
||||
|
||||
private fun buildManager(activity: Activity): FrameRateManager = FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
|
||||
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
|
||||
|
||||
private fun applyPreferredMode(activity: Activity, modeId: Int) {
|
||||
val attrs = activity.window.attributes
|
||||
attrs.preferredDisplayModeId = modeId
|
||||
activity.window.attributes = attrs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sdrClearRestoresTheDefaultModeImmediately() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = false)
|
||||
|
||||
assertEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrClearDefersTheRestoreUntilTheHdrExitSettles() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
|
||||
// Still at the content mode: the surface teardown must commit the HDR
|
||||
// exit before the mode change renegotiates the link.
|
||||
assertEquals(4, preferredModeId(activity))
|
||||
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
assertEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newSwitchRequestCancelsAPendingDeferredRestore() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
// A new session's request arrives before the deferred restore fires.
|
||||
manager.setVideoFrameRate(fps = 23.976f, videoDurationMs = 0L, extraDelayMs = 0L) { }
|
||||
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
// The stale restore must not clobber whatever the new request applied.
|
||||
// (Robolectric's display has no matching mode, so the request itself
|
||||
// leaves the window untouched; only the cancelled restore could zero it.)
|
||||
assertNotEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrClearWithNoAppliedModePostsNoRestore() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 7)
|
||||
|
||||
// Simulate "nothing applied by us": manager sees modeId 0.
|
||||
applyPreferredMode(activity, 0)
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
|
||||
// A later, externally applied mode must not be clobbered by a stale
|
||||
// deferred restore from a session that never switched.
|
||||
applyPreferredMode(activity, 7)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
assertEquals(7, preferredModeId(activity))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -60,4 +61,18 @@ class MediaCodecQueryTest {
|
||||
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
|
||||
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsGatedCodecsToTheMimeTypesDecodersAdvertise() {
|
||||
assertEquals(
|
||||
mapOf("hevc" to true, "av1" to true),
|
||||
MediaCodecQuery.hardwareVideoDecodeSupport(
|
||||
setOf("video/avc", "video/hevc", "video/av01", "audio/mp4a-latm")
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
mapOf("hevc" to true, "av1" to false),
|
||||
MediaCodecQuery.hardwareVideoDecodeSupport(setOf("video/avc", "video/hevc"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -41,4 +42,27 @@ class PlayerSurfaceHostTest {
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun letterboxAreaIsCoveredByABufferlessPunchSurfaceBeneathTheVideo() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
activity.setContentView(FrameLayout(activity))
|
||||
val callback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = Unit
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
|
||||
}
|
||||
|
||||
val container = PlayerSurfaceHost.createContainer(activity)
|
||||
val punch = container.getChildAt(0)
|
||||
assertTrue(punch is SurfaceView)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.width)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.height)
|
||||
|
||||
// Cores append their surfaces after createContainer, so video (and the mpv
|
||||
// OSD plane) always land above the punch layer in drawing order.
|
||||
val video = PlayerSurfaceHost.createVideoSurface(activity, callback)
|
||||
container.addView(video)
|
||||
assertTrue(container.indexOfChild(video) > container.indexOfChild(punch))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ android {
|
||||
compileSdk = 36
|
||||
// Matches the app's latest stable NDK so every project-owned native library
|
||||
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
|
||||
// is NOT what ships: the app packages the libmpv AAR's newer copy with top
|
||||
// is NOT what ships: the app packages the mpv-build tarball's newer copy with top
|
||||
// merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
@@ -58,6 +58,4 @@ dependencies {
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// media3 Format/MimeTypes touch android.util in static initializers.
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.edde746.plezy.libass.media
|
||||
|
||||
/**
|
||||
* Shared policy for embedded font attachments delivered to libass: which
|
||||
* attachment mime types are fonts, and the per-font/aggregate byte budgets
|
||||
* that keep a hostile container from ballooning the font store.
|
||||
*/
|
||||
object AssFonts {
|
||||
const val MAX_FONT_BYTES = 16L * 1024 * 1024
|
||||
const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
|
||||
|
||||
val fontMimeTypes = listOf(
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/sfnt",
|
||||
"font/woff",
|
||||
"font/woff2",
|
||||
"application/font-sfnt",
|
||||
"application/font-woff",
|
||||
"application/x-truetype-font",
|
||||
"application/vnd.ms-opentype",
|
||||
"application/x-font-ttf"
|
||||
)
|
||||
|
||||
/** Null to accept the font, or a human-readable rejection reason. */
|
||||
fun rejectionReason(size: Long, acceptedBytes: Long): String? = when {
|
||||
size > MAX_FONT_BYTES -> "per-font limit"
|
||||
size > MAX_TOTAL_FONT_BYTES - acceptedBytes -> "aggregate limit"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.edde746.plezy.libass.media.extractor
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.mkv.EbmlProcessor
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
open class AssMatroskaExtractor(
|
||||
subtitleParserFactory: SubtitleParser.Factory,
|
||||
private val assHandler: AssHandler,
|
||||
flags: Int = 0
|
||||
) : MatroskaExtractor(subtitleParserFactory, flags) {
|
||||
|
||||
private var currentAttachmentName: String? = null
|
||||
private var currentAttachmentMime: String? = null
|
||||
internal var acceptedFontBytes = 0L
|
||||
private set
|
||||
|
||||
internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray
|
||||
|
||||
override fun getElementType(id: Int): Int = when (id) {
|
||||
ID_ATTACHMENTS -> EbmlProcessor.ELEMENT_TYPE_MASTER
|
||||
ID_ATTACHED_FILE -> EbmlProcessor.ELEMENT_TYPE_MASTER
|
||||
ID_FILE_NAME -> EbmlProcessor.ELEMENT_TYPE_STRING
|
||||
ID_FILE_MIME_TYPE -> EbmlProcessor.ELEMENT_TYPE_STRING
|
||||
ID_FILE_DATA -> EbmlProcessor.ELEMENT_TYPE_BINARY
|
||||
else -> super.getElementType(id)
|
||||
}
|
||||
|
||||
override fun isLevel1Element(id: Int): Boolean = super.isLevel1Element(id) || id == ID_ATTACHMENTS
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
when (id) {
|
||||
ID_EBML -> {
|
||||
val currentExtractor = extractorOutput.get(this) as ExtractorOutput
|
||||
if (currentExtractor !is AssSubtitleExtractorOutput) {
|
||||
extractorOutput.set(
|
||||
this,
|
||||
AssSubtitleExtractorOutput(currentExtractor, assHandler, this)
|
||||
)
|
||||
}
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
}
|
||||
ID_ATTACHED_FILE -> clearAttachment()
|
||||
else -> super.startMasterElement(id, contentPosition, contentSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
when (id) {
|
||||
ID_VIDEO -> {
|
||||
// We need to get the video dimensions very early
|
||||
val track = getCurrentTrack(id)
|
||||
assHandler.setVideoSize(track.width, track.height)
|
||||
super.endMasterElement(id)
|
||||
}
|
||||
ID_ATTACHED_FILE -> clearAttachment()
|
||||
else -> super.endMasterElement(id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stringElement(id: Int, value: String) {
|
||||
when (id) {
|
||||
ID_FILE_NAME -> currentAttachmentName = value
|
||||
ID_FILE_MIME_TYPE -> currentAttachmentMime = value
|
||||
else -> super.stringElement(id, value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
when (id) {
|
||||
ID_FILE_DATA -> {
|
||||
if (contentSize < 0) {
|
||||
throw ParserException.createForMalformedContainer(
|
||||
"Negative Matroska attachment size",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
val attachmentName = requireNotNull(currentAttachmentName)
|
||||
val attachmentMime = requireNotNull(currentAttachmentMime)
|
||||
if (attachmentMime !in fontMimeTypes) {
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
if (contentSize == 0) {
|
||||
input.skipFully(0)
|
||||
return
|
||||
}
|
||||
|
||||
val size = contentSize.toLong()
|
||||
val rejectionReason = when {
|
||||
size > MAX_FONT_BYTES -> "per-font limit"
|
||||
size > MAX_TOTAL_FONT_BYTES - acceptedFontBytes -> "aggregate limit"
|
||||
else -> null
|
||||
}
|
||||
if (rejectionReason != null) {
|
||||
onFontRejected(contentSize, acceptedFontBytes, rejectionReason)
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
|
||||
val data = ByteArray(contentSize)
|
||||
input.readFully(data, 0, contentSize)
|
||||
acceptedFontBytes += size
|
||||
assHandler.addFont(attachmentName, data)
|
||||
}
|
||||
else -> super.binaryElement(id, contentSize, input)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onFontRejected(
|
||||
contentSize: Int,
|
||||
acceptedBytes: Long,
|
||||
reason: String
|
||||
) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Skipping embedded font: $reason (bytes=$contentSize, accepted=$acceptedBytes)"
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearAttachment() {
|
||||
currentAttachmentName = null
|
||||
currentAttachmentMime = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AssMatroskaExtractor"
|
||||
internal const val MAX_FONT_BYTES = 16L * 1024 * 1024
|
||||
internal const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
|
||||
const val ID_EBML = 0x1A45DFA3
|
||||
const val ID_VIDEO = 0xE0
|
||||
const val ID_ATTACHMENTS = 0x1941A469
|
||||
const val ID_ATTACHED_FILE = 0x61A7
|
||||
const val ID_FILE_NAME = 0x466E
|
||||
const val ID_FILE_MIME_TYPE = 0x4660
|
||||
const val ID_FILE_DATA = 0x465C
|
||||
|
||||
val fontMimeTypes = listOf(
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/sfnt",
|
||||
"font/woff",
|
||||
"font/woff2",
|
||||
"application/font-sfnt",
|
||||
"application/font-woff",
|
||||
"application/x-truetype-font",
|
||||
"application/vnd.ms-opentype",
|
||||
"application/x-font-ttf"
|
||||
)
|
||||
|
||||
val extractorOutput = MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
val subtitleSampleField = MatroskaExtractor::class.java.getDeclaredField("subtitleSample").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -5,22 +5,21 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
|
||||
/**
|
||||
* Wraps every text track with [AssTrackOutput] so embedded ASS dialogue
|
||||
* reaches libass. Install it on the raw-subtitle side of the output chain —
|
||||
* between the extractor and the SubtitleTranscodingExtractorOutput — so the
|
||||
* dialogue bytes are still unparsed when they pass through.
|
||||
* This class is only used by the overlay renderer. It's needed to get the start time of the subtitles.
|
||||
*/
|
||||
@UnstableApi
|
||||
class AssSubtitleExtractorOutput(
|
||||
private val delegate: ExtractorOutput,
|
||||
private val assHandler: AssHandler
|
||||
private val assHandler: AssHandler,
|
||||
private val extractor: AssMatroskaExtractor
|
||||
) : ExtractorOutput by delegate {
|
||||
override fun track(id: Int, type: Int): TrackOutput = if (type == C.TRACK_TYPE_TEXT) {
|
||||
// We can't know at this time if the subtitle track is ASS or another
|
||||
// format, so we wrap every subtitle track; non-ASS tracks pass through.
|
||||
AssTrackOutput(delegate.track(id, type), assHandler::readTrackDialogue)
|
||||
// We can't know at this time if the subtitle track is ASS or other format, so we wrap
|
||||
// every subtitle track
|
||||
AssTrackOutput(delegate.track(id, type), assHandler, extractor)
|
||||
} else {
|
||||
delegate.track(id, type)
|
||||
}
|
||||
|
||||
+30
-98
@@ -1,49 +1,28 @@
|
||||
package com.edde746.plezy.libass.media.text
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.io.EOFException
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* Feeds embedded ASS dialogue to libass while forwarding every sample
|
||||
* unchanged. Only used by the overlay renderer, which needs the start time and
|
||||
* duration of each dialogue line.
|
||||
*
|
||||
* Samples must arrive in the media3 MatroskaExtractor subtitle shape
|
||||
* `Dialogue: 0:00:00:00,<duration timecode>,<ReadOrder,Layer,Style,...>` — the
|
||||
* second field carries the sample *duration* (MatroskaExtractor patches
|
||||
* BlockDuration into it; the FFmpeg extractor writes the packet duration).
|
||||
* The extractor beneath must therefore emit raw subtitle samples: media3's
|
||||
* MatroskaExtractor with FLAG_EMIT_RAW_SUBTITLE_DATA, or the FFmpeg demuxer
|
||||
* path. Cue transcoding happens downstream of this wrapper.
|
||||
* This class is only used by the overlay renderer. It's needed to get the start time of the subtitles.
|
||||
*/
|
||||
@UnstableApi
|
||||
class AssTrackOutput(
|
||||
private val delegate: TrackOutput,
|
||||
private val dialogueSink: DialogueSink
|
||||
) : TrackOutput {
|
||||
|
||||
/** Receives one dialogue line; mirrors AssHandler.readTrackDialogue. */
|
||||
fun interface DialogueSink {
|
||||
fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int)
|
||||
}
|
||||
private val assHandler: AssHandler,
|
||||
private val extractor: AssMatroskaExtractor
|
||||
) : TrackOutput by delegate {
|
||||
|
||||
private var isAss = false
|
||||
private var trackId: String? = null
|
||||
|
||||
// Sample bytes buffered between sampleData and sampleMetadata; only
|
||||
// populated for ASS tracks.
|
||||
private var buf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var bufLen = 0
|
||||
private var readBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private val forwardParsable = ParsableByteArray()
|
||||
private var trackId: String? = null
|
||||
|
||||
override fun format(format: Format) {
|
||||
if (format.sampleMimeType == MimeTypes.TEXT_SSA || format.codecs == MimeTypes.TEXT_SSA) {
|
||||
@@ -53,34 +32,6 @@ class AssTrackOutput(
|
||||
delegate.format(format)
|
||||
}
|
||||
|
||||
override fun sampleData(
|
||||
input: DataReader,
|
||||
length: Int,
|
||||
allowEndOfInput: Boolean,
|
||||
sampleDataPart: Int
|
||||
): Int {
|
||||
if (!isAss) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
|
||||
if (readBuf.size < length) readBuf = ByteArray(length)
|
||||
val bytesRead = input.read(readBuf, 0, length)
|
||||
if (bytesRead == C.RESULT_END_OF_INPUT) {
|
||||
if (!allowEndOfInput) throw EOFException()
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
append(readBuf, bytesRead)
|
||||
forwardParsable.reset(readBuf, bytesRead)
|
||||
delegate.sampleData(forwardParsable, bytesRead, sampleDataPart)
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
if (isAss) {
|
||||
ensureCapacity(bufLen + length)
|
||||
System.arraycopy(data.data, data.position, buf, bufLen, length)
|
||||
bufLen += length
|
||||
}
|
||||
delegate.sampleData(data, length, sampleDataPart)
|
||||
}
|
||||
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long,
|
||||
flags: Int,
|
||||
@@ -88,37 +39,26 @@ class AssTrackOutput(
|
||||
offset: Int,
|
||||
cryptoData: TrackOutput.CryptoData?
|
||||
) {
|
||||
if (isAss) {
|
||||
// offset counts bytes buffered after this sample; the slice math matches
|
||||
// media3's TrackOutput contract even though subtitle samples are
|
||||
// normally delivered one at a time with offset 0.
|
||||
val start = bufLen - offset - size
|
||||
if (timeUs != C.TIME_UNSET && start >= 0) {
|
||||
feedDialogue(timeUs, start, size)
|
||||
}
|
||||
if (offset == 0) bufLen = 0
|
||||
if (isAss && timeUs.isValidTs) {
|
||||
val sample = extractor.subtitleSample
|
||||
val endIndex = findTokenIndex(sample.data, 1)
|
||||
val lineIndex = findTokenIndex(sample.data, 2)
|
||||
|
||||
val rawDuration = sample.data.decodeToString(endIndex, lineIndex - 1)
|
||||
val durationUs = parseTimecodeUs(rawDuration)
|
||||
|
||||
assHandler.readTrackDialogue(
|
||||
trackId = trackId,
|
||||
start = timeUs / 1000,
|
||||
duration = durationUs / 1000,
|
||||
data = sample.data,
|
||||
offset = lineIndex,
|
||||
length = sample.limit() - lineIndex
|
||||
)
|
||||
}
|
||||
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
|
||||
}
|
||||
|
||||
private fun feedDialogue(timeUs: Long, start: Int, size: Int) {
|
||||
val end = start + size
|
||||
val durationIndex = findTokenIndex(start, end, 1)
|
||||
val lineIndex = findTokenIndex(start, end, 2)
|
||||
if (durationIndex <= start || lineIndex <= durationIndex) return
|
||||
val rawDuration = buf.decodeToString(durationIndex, lineIndex - 1)
|
||||
val durationUs = parseTimecodeUs(rawDuration)
|
||||
if (durationUs == C.TIME_UNSET) return
|
||||
dialogueSink.onDialogue(
|
||||
trackId,
|
||||
timeUs / 1000,
|
||||
durationUs / 1000,
|
||||
buf,
|
||||
lineIndex,
|
||||
end - lineIndex
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTimecodeUs(timeString: String): Long {
|
||||
val matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim { it <= ' ' })
|
||||
if (!matcher.matches()) {
|
||||
@@ -132,32 +72,24 @@ class AssTrackOutput(
|
||||
return timestampUs
|
||||
}
|
||||
|
||||
/** Index just past the [tokenNumber]th comma inside [from, to), or [from]. */
|
||||
private fun findTokenIndex(from: Int, to: Int, tokenNumber: Int): Int {
|
||||
private fun findTokenIndex(array: ByteArray, tokenNumber: Int): Int {
|
||||
if (tokenNumber == 0) return 0
|
||||
var tokensFound = 0
|
||||
for (index in from until to) {
|
||||
if (buf[index] == COMMA && ++tokensFound == tokenNumber) {
|
||||
array.forEachIndexed { index, byte ->
|
||||
if (byte == COMMA && ++tokensFound == tokenNumber) {
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
return from
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun append(src: ByteArray, length: Int) {
|
||||
ensureCapacity(bufLen + length)
|
||||
System.arraycopy(src, 0, buf, bufLen, length)
|
||||
bufLen += length
|
||||
}
|
||||
|
||||
private fun ensureCapacity(needed: Int) {
|
||||
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
|
||||
}
|
||||
private val Long.isValidTs
|
||||
get() = this != C.TIME_UNSET
|
||||
|
||||
private companion object {
|
||||
val SSA_TIMECODE_PATTERN: Pattern =
|
||||
Pattern.compile("""(?:(\d+):)?(\d+):(\d+)[:.](\d+)""")
|
||||
|
||||
const val COMMA = ','.code.toByte()
|
||||
const val INITIAL_BUFFER_SIZE = 1024
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.edde746.plezy.libass.media.extractor
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import java.io.EOFException
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class AssMatroskaExtractorTest {
|
||||
|
||||
@Test
|
||||
fun smallFontIsDeliveredAndNonFontDoesNotConsumeBudget() {
|
||||
val handler = AssHandler()
|
||||
val extractor = extractor(handler)
|
||||
|
||||
attachment(extractor, "font/ttf")
|
||||
extractor.fileData(4, input(4, seed = 11))
|
||||
attachment(extractor, "application/octet-stream")
|
||||
extractor.fileData(9, input(9, seed = 22))
|
||||
|
||||
val pending = handler.fontStore.pendingSnapshot()
|
||||
assertEquals(1, pending.size)
|
||||
assertEquals("fixture-font", pending.single().first)
|
||||
assertArrayEquals(byteArrayOf(11, 12, 13, 14), pending.single().second)
|
||||
assertEquals(4L, extractor.acceptedFontBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perFontLimitAcceptsExactBoundaryAndSkipsOneByteOver() {
|
||||
val handler = AssHandler()
|
||||
val extractor = extractor(handler)
|
||||
val limit = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
|
||||
|
||||
attachment(extractor, "font/otf")
|
||||
extractor.fileData(limit, input(limit))
|
||||
attachment(extractor, "font/otf")
|
||||
extractor.fileData(limit + 1, input(limit + 1))
|
||||
|
||||
assertEquals(limit.toLong(), extractor.acceptedFontBytes)
|
||||
assertEquals(1, handler.fontStore.pendingSnapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregateLimitIsDeterministicAcrossAttachmentEntries() {
|
||||
val handler = AssHandler()
|
||||
val extractor = extractor(handler)
|
||||
val perFont = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
|
||||
|
||||
repeat(2) {
|
||||
attachment(extractor, "font/ttf")
|
||||
extractor.fileData(perFont, input(perFont, seed = it))
|
||||
extractor.endAttachment()
|
||||
}
|
||||
attachment(extractor, "font/ttf")
|
||||
extractor.fileData(1, input(1))
|
||||
|
||||
assertEquals(AssMatroskaExtractor.MAX_TOTAL_FONT_BYTES, extractor.acceptedFontBytes)
|
||||
assertEquals(2, handler.fontStore.pendingSnapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negativeAndZeroSizesAllocateAndDeliverNothing() {
|
||||
val handler = AssHandler()
|
||||
val extractor = extractor(handler)
|
||||
attachment(extractor, "font/woff2")
|
||||
|
||||
assertThrows(ParserException::class.java) {
|
||||
extractor.fileData(-1, input(0))
|
||||
}
|
||||
attachment(extractor, "font/woff2")
|
||||
extractor.fileData(0, input(0))
|
||||
|
||||
assertEquals(0L, extractor.acceptedFontBytes)
|
||||
assertEquals(0, handler.fontStore.pendingSnapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedReadDoesNotChargeAggregateBudgetOrDeliverPartialFont() {
|
||||
val handler = AssHandler()
|
||||
val extractor = extractor(handler)
|
||||
attachment(extractor, "font/ttf")
|
||||
|
||||
assertThrows(EOFException::class.java) {
|
||||
extractor.fileData(1024, input(1024, available = 4))
|
||||
}
|
||||
assertEquals(0L, extractor.acceptedFontBytes)
|
||||
assertEquals(0, handler.fontStore.pendingSnapshot().size)
|
||||
|
||||
attachment(extractor, "font/ttf")
|
||||
extractor.fileData(1024, input(1024))
|
||||
assertEquals(1024L, extractor.acceptedFontBytes)
|
||||
assertEquals(1, handler.fontStore.pendingSnapshot().size)
|
||||
}
|
||||
|
||||
private fun extractor(handler: AssHandler) = TestExtractor(handler)
|
||||
|
||||
private fun attachment(extractor: TestExtractor, mime: String) {
|
||||
extractor.setAttachment(mime)
|
||||
}
|
||||
|
||||
private class TestExtractor(handler: AssHandler) :
|
||||
AssMatroskaExtractor(
|
||||
DefaultSubtitleParserFactory(),
|
||||
handler
|
||||
) {
|
||||
fun setAttachment(mime: String) {
|
||||
startMasterElement(ID_ATTACHED_FILE, 0, 0)
|
||||
stringElement(ID_FILE_NAME, "fixture-font")
|
||||
stringElement(ID_FILE_MIME_TYPE, mime)
|
||||
}
|
||||
|
||||
fun fileData(contentSize: Int, input: ExtractorInput) {
|
||||
binaryElement(ID_FILE_DATA, contentSize, input)
|
||||
}
|
||||
|
||||
fun endAttachment() {
|
||||
endMasterElement(ID_ATTACHED_FILE)
|
||||
}
|
||||
|
||||
override fun onFontRejected(contentSize: Int, acceptedBytes: Long, reason: String) = Unit
|
||||
}
|
||||
|
||||
private fun input(
|
||||
declared: Int,
|
||||
available: Int = declared,
|
||||
seed: Int = 0
|
||||
): ExtractorInput = DefaultExtractorInput(
|
||||
PatternDataReader(available, seed),
|
||||
0,
|
||||
declared.toLong()
|
||||
)
|
||||
|
||||
private class PatternDataReader(
|
||||
private val size: Int,
|
||||
private val seed: Int
|
||||
) : DataReader {
|
||||
private var position = 0
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= size) return C.RESULT_END_OF_INPUT
|
||||
val count = minOf(length, size - position)
|
||||
for (index in 0 until count) {
|
||||
buffer[offset + index] = ((seed + position + index) and 0xFF).toByte()
|
||||
}
|
||||
position += count
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
package com.edde746.plezy.libass.media.text
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Spec for the dialogue feed both demuxer paths rely on: samples arrive in
|
||||
* media3's MatroskaExtractor shape ("Dialogue: 0:00:00:00,<duration>,<rest>")
|
||||
* and the sink must receive the sample start, the parsed duration, and the
|
||||
* payload after the second comma — while every byte is still forwarded
|
||||
* unchanged to the delegate.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AssTrackOutputTest {
|
||||
|
||||
private class CapturedDialogue(val trackId: String?, val startMs: Long, val durationMs: Long, val line: ByteArray)
|
||||
|
||||
private class RecordingSink : AssTrackOutput.DialogueSink {
|
||||
val dialogues = mutableListOf<CapturedDialogue>()
|
||||
|
||||
override fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int) {
|
||||
dialogues.add(CapturedDialogue(trackId, startMs, durationMs, data.copyOfRange(offset, offset + length)))
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingTrackOutput : TrackOutput {
|
||||
val formats = mutableListOf<Format>()
|
||||
val samples = mutableListOf<ByteArray>()
|
||||
val sampleTimes = mutableListOf<Long>()
|
||||
private var buf = ByteArray(1024)
|
||||
private var bufLen = 0
|
||||
|
||||
override fun format(format: Format) {
|
||||
formats.add(format)
|
||||
}
|
||||
|
||||
override fun sampleData(input: androidx.media3.common.DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
|
||||
if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length)
|
||||
val read = input.read(buf, bufLen, length)
|
||||
if (read > 0) bufLen += read
|
||||
return read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length)
|
||||
data.readBytes(buf, bufLen, length)
|
||||
bufLen += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
val start = bufLen - offset - size
|
||||
samples.add(buf.copyOfRange(start, start + size))
|
||||
sampleTimes.add(timeUs)
|
||||
if (offset == 0) bufLen = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun ssaFormat(id: String = "3"): Format = Format.Builder()
|
||||
.setId(id)
|
||||
.setSampleMimeType(MimeTypes.TEXT_SSA)
|
||||
.build()
|
||||
|
||||
private fun deliver(output: AssTrackOutput, sample: String, timeUs: Long) {
|
||||
val bytes = sample.toByteArray(Charsets.UTF_8)
|
||||
output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedsDialogueAndForwardsSampleUnchanged() {
|
||||
val sink = RecordingSink()
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = AssTrackOutput(delegate, sink)
|
||||
output.format(ssaFormat())
|
||||
|
||||
val sample = "Dialogue: 0:00:00:00,0:00:05:00,7,0,Default,,0,0,0,,Hello, world"
|
||||
deliver(output, sample, 90_000_000L)
|
||||
|
||||
assertEquals(1, sink.dialogues.size)
|
||||
val dialogue = sink.dialogues.first()
|
||||
assertEquals("3", dialogue.trackId)
|
||||
assertEquals(90_000L, dialogue.startMs)
|
||||
assertEquals(5_000L, dialogue.durationMs)
|
||||
// Payload after the second comma: the raw MKV/FFmpeg block, commas in the
|
||||
// text field intact.
|
||||
assertEquals("7,0,Default,,0,0,0,,Hello, world", dialogue.line.toString(Charsets.UTF_8))
|
||||
|
||||
// The delegate still receives the full untouched sample.
|
||||
assertEquals(1, delegate.samples.size)
|
||||
assertArrayEquals(sample.toByteArray(Charsets.UTF_8), delegate.samples.first())
|
||||
assertEquals(90_000_000L, delegate.sampleTimes.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresNonAssTracksAndMalformedSamples() {
|
||||
val sink = RecordingSink()
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = AssTrackOutput(delegate, sink)
|
||||
|
||||
// SubRip track: forwarded, never fed to libass.
|
||||
output.format(Format.Builder().setId("1").setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build())
|
||||
deliver(output, "1\n00:00:00,000 --> 00:00:05,000\nplain text", 1_000_000L)
|
||||
assertTrue(sink.dialogues.isEmpty())
|
||||
assertEquals(1, delegate.samples.size)
|
||||
|
||||
// ASS track with an unparseable duration: forwarded, not fed.
|
||||
val assOutput = AssTrackOutput(RecordingTrackOutput(), sink)
|
||||
assOutput.format(ssaFormat())
|
||||
deliver(assOutput, "Dialogue: bogus", 1_000_000L)
|
||||
assertTrue(sink.dialogues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlesChunkedSampleDataAndDataReaderPath() {
|
||||
val sink = RecordingSink()
|
||||
val delegate = RecordingTrackOutput()
|
||||
val output = AssTrackOutput(delegate, sink)
|
||||
output.format(ssaFormat("0"))
|
||||
|
||||
val sample = "Dialogue: 0:00:00:00,0:00:01:50,1,0,Style,,0,0,0,,Line"
|
||||
val bytes = sample.toByteArray(Charsets.UTF_8)
|
||||
val split = bytes.size / 2
|
||||
output.sampleData(ParsableByteArray(bytes.copyOfRange(0, split)), split, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
val rest = bytes.copyOfRange(split, bytes.size)
|
||||
var consumed = 0
|
||||
val reader = androidx.media3.common.DataReader { buffer, offset, length ->
|
||||
if (consumed >= rest.size) {
|
||||
C.RESULT_END_OF_INPUT
|
||||
} else {
|
||||
val toRead = minOf(length, rest.size - consumed)
|
||||
System.arraycopy(rest, consumed, buffer, offset, toRead)
|
||||
consumed += toRead
|
||||
toRead
|
||||
}
|
||||
}
|
||||
while (consumed < rest.size) {
|
||||
output.sampleData(reader, rest.size - consumed, false, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
}
|
||||
output.sampleMetadata(0L, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null)
|
||||
|
||||
assertEquals(1, sink.dialogues.size)
|
||||
assertEquals(1_500L, sink.dialogues.first().durationMs)
|
||||
assertEquals("1,0,Style,,0,0,0,,Line", sink.dialogues.first().line.toString(Charsets.UTF_8))
|
||||
assertArrayEquals(bytes, delegate.samples.first())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user