Compare commits
274
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc8b4c0d9 | ||
|
|
7a4cdc8b5e | ||
|
|
20896a3d48 | ||
|
|
fc96ddc1ab | ||
|
|
4522f42bbf | ||
|
|
a4b88df965 | ||
|
|
810ef002ad | ||
|
|
cb900c255a | ||
|
|
43ab083cda | ||
|
|
3fb90a4948 | ||
|
|
65713858ea | ||
|
|
744e72d76b | ||
|
|
761303de11 | ||
|
|
4a1111f435 | ||
|
|
4b6533d25b | ||
|
|
05ef93c55b | ||
|
|
8d2eca42d4 | ||
|
|
0f3d06668f | ||
|
|
7131be4cc0 | ||
|
|
436fd7d2d5 | ||
|
|
c198bab04c | ||
|
|
2a6f752860 | ||
|
|
16c0e00302 | ||
|
|
6d75e58f52 | ||
|
|
ad1a12c998 | ||
|
|
9fe6dbe9f2 | ||
|
|
304d8a5e33 | ||
|
|
986237491f | ||
|
|
35eef09f72 | ||
|
|
8461e6ba5f | ||
|
|
93d3ec65e2 | ||
|
|
35dce90ea9 | ||
|
|
483691d43a | ||
|
|
b4a84c6a28 | ||
|
|
c5e7ce648b | ||
|
|
7cfd68c491 | ||
|
|
137f2a0e95 | ||
|
|
1bdeca3b5f | ||
|
|
cff6666219 | ||
|
|
b0798cde9f | ||
|
|
417f3a5e36 | ||
|
|
d4d44e6c72 | ||
|
|
658469f954 | ||
|
|
efd33b3a4e | ||
|
|
86498547ba | ||
|
|
5fe1e6dc65 | ||
|
|
1df47ed8e1 | ||
|
|
a7527718df | ||
|
|
7cd5bb64bb | ||
|
|
b38df008ec | ||
|
|
ceab5a5b59 | ||
|
|
6eb0805f8d | ||
|
|
a33c423985 | ||
|
|
1a26b0dd08 | ||
|
|
4cc065b73a | ||
|
|
071ae8e1c2 | ||
|
|
7658587584 | ||
|
|
49fce5ace9 | ||
|
|
057ff98ec1 | ||
|
|
00a6e056b0 | ||
|
|
78fa2c29e3 | ||
|
|
0e1af9faaf | ||
|
|
99b0528623 | ||
|
|
f5c7b95b02 | ||
|
|
d78a7d925e | ||
|
|
62c25e493e | ||
|
|
e543f417ce | ||
|
|
4c2f533eef | ||
|
|
fb048097ab | ||
|
|
4eb3ab4632 | ||
|
|
a910aa95cb | ||
|
|
3703ac47c3 | ||
|
|
c390e0fb0c | ||
|
|
3665d416c4 | ||
|
|
2e1a7130fc | ||
|
|
55382deaf9 | ||
|
|
4436efea72 | ||
|
|
8e9c81fe47 | ||
|
|
093c0596f1 | ||
|
|
908b4a8dac | ||
|
|
8033a308ce | ||
|
|
68c328b469 | ||
|
|
01f04eda42 | ||
|
|
0f016e4e13 | ||
|
|
953acedcab | ||
|
|
be9197eda6 | ||
|
|
a2b3377d91 | ||
|
|
e1b488f860 | ||
|
|
a79084b0a5 | ||
|
|
75704c7a9b | ||
|
|
f2ce587a85 | ||
|
|
947fddf540 | ||
|
|
50a9365be8 | ||
|
|
06ecba5fb5 | ||
|
|
096e285ab0 | ||
|
|
aeb6108654 | ||
|
|
f622ba8efe | ||
|
|
e47b4e0a73 | ||
|
|
9dd8aa4679 | ||
|
|
3f6e4c0716 | ||
|
|
49c117db83 | ||
|
|
d77836ef22 | ||
|
|
92b0524edb | ||
|
|
bb95d54bac | ||
|
|
36b2549506 | ||
|
|
9c6fcd2625 | ||
|
|
24f63a63c5 | ||
|
|
364cdea59b | ||
|
|
ab199b04cd | ||
|
|
ef2ab13abd | ||
|
|
fc061dc38d | ||
|
|
58b510a6c4 | ||
|
|
ec605fd8b7 | ||
|
|
25b2939f0a | ||
|
|
a7354cd3b6 | ||
|
|
eaccef33dc | ||
|
|
2ff3b350ae | ||
|
|
bb642fce03 | ||
|
|
8069683bd3 | ||
|
|
d1d393eec0 | ||
|
|
972b62f6fa | ||
|
|
ea528b5213 | ||
|
|
5a0b595466 | ||
|
|
35b3e97730 | ||
|
|
2f0a6a6bf5 | ||
|
|
d623c2164f | ||
|
|
8383c7b73b | ||
|
|
be863a0c1c | ||
|
|
fe3460ad12 | ||
|
|
ae001d9ff3 | ||
|
|
18c9f710bb | ||
|
|
7937a7e30a | ||
|
|
991184b451 | ||
|
|
c3dbbf6479 | ||
|
|
f56264dcce | ||
|
|
9cdfe7591e | ||
|
|
48f365ab30 | ||
|
|
093109ae09 | ||
|
|
14cae85931 | ||
|
|
9090ad6283 | ||
|
|
8f9ffc76eb | ||
|
|
12d9865a41 | ||
|
|
688bfdacb4 | ||
|
|
cd4432f27c | ||
|
|
5f6d9dc610 | ||
|
|
ce151a91e8 | ||
|
|
922789c014 | ||
|
|
a49f506a0b | ||
|
|
8cdf04122f | ||
|
|
3e1481c238 | ||
|
|
0d24240af4 | ||
|
|
9769c4e092 | ||
|
|
f291425f20 | ||
|
|
1c1bba7e27 | ||
|
|
26e98e898d | ||
|
|
6663353895 | ||
|
|
83c50d93a2 | ||
|
|
3a704a2b9b | ||
|
|
aff6b6576f | ||
|
|
d19ec625cd | ||
|
|
c2bd1d28fd | ||
|
|
ea356a6112 | ||
|
|
69fadc220d | ||
|
|
5611c6785a | ||
|
|
01279e5fbb | ||
|
|
369c6279d6 | ||
|
|
3177290083 | ||
|
|
a0269c6feb | ||
|
|
f2ef15806a | ||
|
|
dfdb38707d | ||
|
|
e9a213807f | ||
|
|
bcd6fe9906 | ||
|
|
c27dc0a1a7 | ||
|
|
f5665df43f | ||
|
|
f1d4be70e2 | ||
|
|
85d1672909 | ||
|
|
5c08b29756 | ||
|
|
672047924c | ||
|
|
e92cc3cebf | ||
|
|
8e1be64d8d | ||
|
|
5a3a1f3c70 | ||
|
|
0ee5672426 | ||
|
|
df0570b861 | ||
|
|
8d284efa39 | ||
|
|
716fe9b51b | ||
|
|
9d13584c00 | ||
|
|
8e5279a487 | ||
|
|
8ad13e94cf | ||
|
|
e0a364e26a | ||
|
|
63f2bedf2c | ||
|
|
ff461d9f71 | ||
|
|
de76c0a515 | ||
|
|
291a22a4a4 | ||
|
|
0b4fd9e8f3 | ||
|
|
ce9556db22 | ||
|
|
9d51f5aadd | ||
|
|
fe79817e76 | ||
|
|
f4ce60611b | ||
|
|
8740a19f36 | ||
|
|
bb3762ed63 | ||
|
|
7437b43207 | ||
|
|
e6be5f9fef | ||
|
|
f0debe2c32 | ||
|
|
109f1eda4d | ||
|
|
be99f27b92 | ||
|
|
3b76cf3948 | ||
|
|
b7a438789f | ||
|
|
33c33c3d57 | ||
|
|
636fd48f40 | ||
|
|
5f397a99d9 | ||
|
|
3364b3c22c | ||
|
|
24a041977b | ||
|
|
e3703892b3 | ||
|
|
feb34caeb7 | ||
|
|
4816e3928f | ||
|
|
660e375248 | ||
|
|
f5ccaab3ab | ||
|
|
f63d0fe49e | ||
|
|
f5488cb7ff | ||
|
|
db4f7a643b | ||
|
|
094be1fa3e | ||
|
|
309a107912 | ||
|
|
21cf1ff8d4 | ||
|
|
f63a4b039a | ||
|
|
961e9c0326 | ||
|
|
4607d165fd | ||
|
|
3a56218a12 | ||
|
|
7ce5a443fd | ||
|
|
f93952ba6f | ||
|
|
1b6a811c07 | ||
|
|
9d51a040c3 | ||
|
|
23b8befe11 | ||
|
|
26dbce0277 | ||
|
|
edaff1fbfc | ||
|
|
eaa1736c4e | ||
|
|
541fc2c097 | ||
|
|
b97a22c213 | ||
|
|
80d3537975 | ||
|
|
8879941d29 | ||
|
|
3b0b407cd0 | ||
|
|
05fd622968 | ||
|
|
f36e20bcad | ||
|
|
d7c4ea0a8b | ||
|
|
dcc06768e0 | ||
|
|
ddc3a4d77c | ||
|
|
58d5d3c4ef | ||
|
|
4872adcde3 | ||
|
|
61ae314c94 | ||
|
|
d82a5c5cd7 | ||
|
|
fdd4c661fe | ||
|
|
439ae1d733 | ||
|
|
a759e8b3c6 | ||
|
|
74d3af3ae1 | ||
|
|
8624c37041 | ||
|
|
7a19f4149e | ||
|
|
860ce1e11a | ||
|
|
2b4875d389 | ||
|
|
e7aa1e4782 | ||
|
|
bbaf5f0f9e | ||
|
|
0f5e5c8b6e | ||
|
|
a9f0532f5f | ||
|
|
fb0613e3db | ||
|
|
9c08c78f6d | ||
|
|
95b013e155 | ||
|
|
d83d0790ba | ||
|
|
bbed260169 | ||
|
|
35061f9f68 | ||
|
|
1d9ffb7427 | ||
|
|
bac2a0d201 | ||
|
|
2a7e5f4f9c | ||
|
|
bc0d14a749 | ||
|
|
f78f65faf5 | ||
|
|
957711a650 | ||
|
|
2cb2c3eb95 |
+2
-2
@@ -3,5 +3,5 @@
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
!scripts/maestro/maestro_fixtures.py
|
||||
!scripts/maestro/maestro_real_jellyfin.py
|
||||
|
||||
+77
-22
@@ -1,8 +1,13 @@
|
||||
name: Build
|
||||
run-name: ${{ inputs.release_tag != '' && format('Release {0}', inputs.release_tag) || format('Build {0}', github.sha) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Tag for a draft release; omit to build Actions artifacts only
|
||||
default: ''
|
||||
type: string
|
||||
build_android:
|
||||
description: Build Android
|
||||
default: true
|
||||
@@ -36,7 +41,7 @@ env:
|
||||
libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev
|
||||
libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev
|
||||
libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev
|
||||
libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev liblua5.2-dev
|
||||
libdisplay-info-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev liblua5.2-dev
|
||||
ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
|
||||
jobs:
|
||||
@@ -271,13 +276,11 @@ jobs:
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Import certificate to keychain
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
@@ -494,6 +497,11 @@ jobs:
|
||||
"data/app.so"
|
||||
"data/icudtl.dat"
|
||||
"data/flutter_assets/NativeAssetsManifest.json"
|
||||
# MSVC runtime bundled by InstallRequiredSystemLibraries in
|
||||
# windows/CMakeLists.txt; without it the app crashes at startup on
|
||||
# machines whose system-wide redist is older than the CI toolset.
|
||||
"msvcp140.dll"
|
||||
"vcruntime140.dll"
|
||||
)
|
||||
$forbiddenFiles = @(
|
||||
"plezy.lib"
|
||||
@@ -765,14 +773,17 @@ jobs:
|
||||
fi
|
||||
echo "BUNDLE_DIR=$bundle_dir" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Linux Packages
|
||||
shell: bash
|
||||
run: |
|
||||
BUILD_DIR="$BUNDLE_DIR" \
|
||||
ARCH_SUFFIX=${{ matrix.arch }} \
|
||||
OUTPUT_DIR="$GITHUB_WORKSPACE" \
|
||||
python3 linux/packaging/build-packages.py
|
||||
|
||||
# Everything below resolves the bundle *before* packaging, so the packages
|
||||
# and the tarball are cut from one identical tree.
|
||||
#
|
||||
# The plane needs the pinned Wayland-enabled libmpv: a distro libmpv still
|
||||
# plays and still does HDR, but silently drops hwdec to vaapi-copy - which
|
||||
# was measured, not assumed. So libmpv travels with us and no artifact
|
||||
# depends on a host one. That removed the host `mpv` dependency, and with it
|
||||
# the transitive pull of everything libmpv itself needs - libass, pulse,
|
||||
# pipewire, fontconfig and the rest. bundle-libs.sh is what supplies those,
|
||||
# so it has to run before packaging too, or the packages would carry libmpv
|
||||
# and nothing it links.
|
||||
- name: Copy libmpv into bundle
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -781,7 +792,7 @@ jobs:
|
||||
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
|
||||
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
|
||||
|
||||
- name: Bundle shared libraries for portable tarball
|
||||
- name: Bundle shared libraries
|
||||
shell: bash
|
||||
run: bash linux/packaging/bundle-libs.sh "$BUNDLE_DIR"
|
||||
|
||||
@@ -789,17 +800,43 @@ jobs:
|
||||
shell: bash
|
||||
run: cp linux/packaging/plezy.sh "$BUNDLE_DIR/plezy.sh"
|
||||
|
||||
- name: Verify no missing dependencies
|
||||
# Same check the package smoke build runs, against the artifact that
|
||||
# actually ships: a library nobody declares is a broken install, and it is
|
||||
# invisible until a user on a clean machine tries to launch.
|
||||
#
|
||||
# This also stands in for the `ldd ./plezy | grep "not found"` step that
|
||||
# used to run after packaging. That one folded ldd's stderr into grep's
|
||||
# input and dropped its exit status, so ldd failing outright - a missing
|
||||
# loader, an exec-format mismatch, no ldd at all - left the match empty and
|
||||
# printed "All dependencies resolved." This guard runs ldd over every
|
||||
# object under lib/ as well as the executable, fails on an unresolved
|
||||
# soname, fails when ldd cannot read an object, and refuses to pass when
|
||||
# the walk found no host libraries at all. Packaging below only reads the
|
||||
# bundle, so a second shell ldd afterwards could only restate a weaker
|
||||
# subset of what this already proved about the very same tree.
|
||||
- name: Verify every unbundled library the bundle needs is declared
|
||||
shell: bash
|
||||
run: python3 linux/packaging/check-bundle-host-deps.py "$BUNDLE_DIR"
|
||||
|
||||
# Last, from the fully resolved tree above. The host-dependency guard is
|
||||
# skipped because the named step above just ran it against this same
|
||||
# bundle; the internal run exists for by-hand packaging outside CI.
|
||||
- name: Build Linux Packages
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$BUNDLE_DIR"
|
||||
MISSING=$(LD_LIBRARY_PATH=lib ldd ./plezy 2>&1 | grep "not found" || true)
|
||||
if [[ -n "$MISSING" ]]; then
|
||||
echo "ERROR: Unresolved dependencies found:" >&2
|
||||
echo "$MISSING" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All dependencies resolved."
|
||||
BUILD_DIR="$BUNDLE_DIR" \
|
||||
ARCH_SUFFIX=${{ matrix.arch }} \
|
||||
OUTPUT_DIR="$GITHUB_WORKSPACE" \
|
||||
PLEZY_SKIP_HOST_DEP_CHECK=1 \
|
||||
python3 linux/packaging/build-packages.py
|
||||
|
||||
# The depends lists reached fpm above; only the packages it wrote can show
|
||||
# they arrived. Same script the smoke build runs, so the two jobs cannot
|
||||
# drift on what counts as declared - and unlike the smoke build, this one
|
||||
# covers arm64 and the artifacts users actually install.
|
||||
- name: Verify the declared dependencies reached the package metadata
|
||||
shell: bash
|
||||
run: python3 linux/packaging/check-package-deps.py "$GITHUB_WORKSPACE" --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Create tarball
|
||||
shell: bash
|
||||
@@ -828,7 +865,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }}
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux && inputs.release_tag != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -847,6 +884,21 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate release tag
|
||||
if: ${{ inputs.release_tag != '' }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Release tag must use semantic versioning: $RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$RELEASE_TAG" != "$VERSION" ]]; then
|
||||
echo "Release tag $RELEASE_TAG does not match pubspec version $VERSION." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download Android artifacts
|
||||
if: ${{ inputs.build_android }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -1011,5 +1063,8 @@ jobs:
|
||||
files: ${{ steps.release-files.outputs.files }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
name: ${{ inputs.release_tag }}
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+174
-6
@@ -9,6 +9,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_linux_packages:
|
||||
description: >
|
||||
Smoke-build the Linux deb/rpm/pacman packages. Off by default: it needs
|
||||
a release build plus fpm, and build.yml only packages from main, so this
|
||||
is the only way to exercise linux/packaging from a branch.
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
@@ -54,7 +62,7 @@ jobs:
|
||||
run: scripts/codegen.sh --check
|
||||
|
||||
- name: Verify translation hygiene
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
run: python3 scripts/checks/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
@@ -67,10 +75,10 @@ jobs:
|
||||
xargs -0 -r dart format --output=none --set-exit-if-changed
|
||||
|
||||
- name: Verify icon consistency
|
||||
run: dart run scripts/check_icon_consistency.dart
|
||||
run: dart run scripts/checks/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/check_analyzer.dart
|
||||
run: dart run scripts/checks/check_analyzer.dart
|
||||
|
||||
- name: Check for unused code
|
||||
run: |
|
||||
@@ -244,7 +252,7 @@ jobs:
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition integrity
|
||||
- name: Verify Linux native acquisition and build plan
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
@@ -279,7 +287,8 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev
|
||||
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
|
||||
libwayland-dev libegl-dev
|
||||
|
||||
- name: Prepare Flutter Linux configuration
|
||||
run: |
|
||||
@@ -295,12 +304,20 @@ jobs:
|
||||
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
|
||||
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
|
||||
|
||||
# `plezy` is the runner itself. Without it nothing in CI ever compiles
|
||||
# my_application.cc, mpv_plugin.cc or the Wayland video plane — the
|
||||
# reliability test targets each pull in only a couple of translation units,
|
||||
# so a break in the rest of linux/runner reached a release build unseen.
|
||||
- name: Build Linux native reliability tests
|
||||
run: |
|
||||
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
|
||||
plezy \
|
||||
mpv_player_lifecycle_test \
|
||||
mpv_player_hdr_output_test \
|
||||
mpv_property_result_contract_test \
|
||||
mpv_gpu_bootstrap_test
|
||||
hdr_metadata_test \
|
||||
plane_geometry_test \
|
||||
video_params_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
@@ -592,3 +609,154 @@ jobs:
|
||||
|
||||
- name: Run website checks
|
||||
run: scripts/ci_website_checks.sh
|
||||
|
||||
|
||||
# The only job that checks packaging against a real artifact. build.yml also
|
||||
# packages Linux, but refuses any ref but refs/heads/main, and
|
||||
# check_linux_package_deps.py can only compare a hand-written list against
|
||||
# CMake - it cannot prove the list reaches the artifact, nor see the
|
||||
# transitive libraries the bundled libmpv drags in. This can, by reading the
|
||||
# built bundle back with ldd.
|
||||
#
|
||||
# Runs on every push to main and on request, but not on pull requests: it
|
||||
# needs a release build plus fpm, which is minutes of runner time that most
|
||||
# changes here have no reason to pay. That buys post-merge detection rather
|
||||
# than pre-merge, which is the deliberate trade. Dispatch it from a branch
|
||||
# with build_linux_packages when touching linux/packaging - which is the only
|
||||
# way to exercise it before merging.
|
||||
linux-packages:
|
||||
name: Linux package smoke build
|
||||
if: ${{ inputs.build_linux_packages || github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
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.
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
|
||||
libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev \
|
||||
libharfbuzz-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev \
|
||||
libva-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev \
|
||||
libdisplay-info-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev \
|
||||
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
|
||||
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') }}
|
||||
|
||||
- name: Build libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
run: bash linux/packaging/build-libmpv.sh
|
||||
|
||||
# The windowing backends the runner depends on, read off the library the
|
||||
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
|
||||
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
|
||||
# in software; X11 and VDPAU are gone with the texture path.
|
||||
- name: Check the built libmpv's backends
|
||||
run: |
|
||||
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
|
||||
echo "== $LIB =="
|
||||
ldd "$LIB" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
|
||||
ldd "$LIB" | grep -q libwayland-client || {
|
||||
echo "::error::the built libmpv does not link libwayland-client, so the video plane cannot work"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build the release bundle
|
||||
run: |
|
||||
flutter pub get --enforce-lockfile --no-example
|
||||
flutter build linux --release
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
|
||||
|
||||
# Mirrors build.yml: resolve the bundle completely, then package from it.
|
||||
# libmpv travels with us, so nothing depends on a host one - which also
|
||||
# removes the transitive pull of everything libmpv links, hence bundle-libs
|
||||
# before packaging rather than after.
|
||||
- name: Copy libmpv into the bundle
|
||||
run: |
|
||||
BUNDLE_LIB=build/linux/x64/release/bundle/lib
|
||||
LIBMPV_DIR=$(dirname "$(find libmpv-prefix -name 'libmpv.so' | head -1)")
|
||||
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
|
||||
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
|
||||
|
||||
- name: Bundle shared libraries
|
||||
run: bash linux/packaging/bundle-libs.sh build/linux/x64/release/bundle
|
||||
|
||||
- name: Copy wrapper script into the bundle
|
||||
run: cp linux/packaging/plezy.sh build/linux/x64/release/bundle/plezy.sh
|
||||
|
||||
# Derives what the resolved bundle still needs from the host and proves
|
||||
# every one of those libraries is declared. This is the check that would
|
||||
# have caught bundling libmpv without also declaring what libmpv links.
|
||||
- name: Verify every unbundled library the bundle needs is declared
|
||||
run: python3 linux/packaging/check-bundle-host-deps.py build/linux/x64/release/bundle
|
||||
|
||||
# OUTPUT_DIR defaults to the repo root; name it so the paths below are not
|
||||
# a guess about where fpm dropped things. The host-dependency guard is
|
||||
# skipped because the named step above just ran it against this same
|
||||
# bundle; the internal run exists for by-hand packaging outside CI.
|
||||
- name: Build the packages
|
||||
run: |
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
python3 linux/packaging/build-packages.py
|
||||
env:
|
||||
OUTPUT_DIR: ${{ github.workspace }}/packages
|
||||
PLEZY_SKIP_HOST_DEP_CHECK: "1"
|
||||
|
||||
# The guard above reconciles the depends lists with the staged bundle; only
|
||||
# the packages themselves can show that list survived fpm. Every name comes
|
||||
# from build-packages.py, so adding a library there is verified here without
|
||||
# a second edit - and this job is off by default, so a hand-copied list
|
||||
# would rot unseen. The release job in build.yml runs the same script
|
||||
# against the artifacts users install, so the assertions cannot drift.
|
||||
- name: Verify the declared dependencies reached the package metadata
|
||||
run: python3 linux/packaging/check-package-deps.py "${{ github.workspace }}/packages"
|
||||
|
||||
# Same resolved bundle the packages were cut from, so the tarball is not a
|
||||
# second, differently-assembled artifact. It is the one to put on a USB for
|
||||
# a foreign machine, because it needs nothing installed.
|
||||
- name: Create the tarball
|
||||
run: |
|
||||
BUNDLE_DIR=build/linux/x64/release/bundle
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
tar -czf "$OUTPUT_DIR/plezy-linux-x64.tar.gz" -C "$BUNDLE_DIR" .
|
||||
echo "=== libmpv travelling in every artifact ==="
|
||||
ldd "$BUNDLE_DIR/lib/libmpv.so" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
|
||||
env:
|
||||
OUTPUT_DIR: ${{ github.workspace }}/packages
|
||||
|
||||
- name: Upload the packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: linux-packages-smoke
|
||||
path: ${{ github.workspace }}/packages/plezy-linux-x64.*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
name: E2E - Maestro
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: maestro-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-maestro:
|
||||
name: Android Maestro suites
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
MAESTRO_VERSION: "2.6.1"
|
||||
MAESTRO_SHA256: "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
id: pub-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
id: gradle-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
|
||||
|
||||
- name: Cache Maestro CLI
|
||||
id: maestro-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
|
||||
|
||||
- name: Install verified Maestro CLI
|
||||
if: steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/maestro.zip"
|
||||
staging="$RUNNER_TEMP/maestro-install"
|
||||
curl --fail --location --retry 3 \
|
||||
--output "$archive" \
|
||||
"https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "${MAESTRO_SHA256} ${archive}" | sha256sum --check -
|
||||
rm -rf "$staging" "$HOME/.maestro/bin" "$HOME/.maestro/lib"
|
||||
mkdir -p "$staging" "$HOME/.maestro"
|
||||
unzip -q "$archive" -d "$staging"
|
||||
mv "$staging/maestro/bin" "$HOME/.maestro/bin"
|
||||
mv "$staging/maestro/lib" "$HOME/.maestro/lib"
|
||||
|
||||
- name: Add Maestro to PATH
|
||||
run: |
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.maestro/bin/maestro" --version
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Build deterministic Jellyfin image for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
|
||||
- name: Build and cache deterministic Jellyfin image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
cache-to: type=gha,mode=max,scope=maestro-jellyfin
|
||||
|
||||
- name: Cache Android 15 AVD
|
||||
id: api35-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
|
||||
|
||||
- name: Generate Android 15 AVD snapshot
|
||||
if: steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 15 AVD snapshot."
|
||||
|
||||
- name: Cache Android 9 AVD
|
||||
id: api28-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
|
||||
|
||||
- name: Generate Android 9 AVD snapshot
|
||||
if: steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 9 AVD snapshot."
|
||||
|
||||
- name: Run Android 15 instrumentation
|
||||
id: api35-instrumentation
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
|
||||
|
||||
- name: Run Android R8 reachability
|
||||
id: api35-r8
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-r8-reachability
|
||||
|
||||
- name: Run Android 15 suites
|
||||
id: api35
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15
|
||||
|
||||
- name: Run Android 9 legacy playback
|
||||
id: api28
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-9
|
||||
# Fork PRs may restore trusted caches, but only trusted events publish cache entries.
|
||||
- name: Save Pub dependency cache
|
||||
if: github.event_name != 'pull_request' && steps.pub-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Gradle cache
|
||||
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Maestro CLI cache
|
||||
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 15 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 9 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload Maestro diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: maestro-android-diagnostics
|
||||
path: |
|
||||
build/maestro
|
||||
build/maestro-offline
|
||||
build/maestro-real-jellyfin
|
||||
build/maestro-media
|
||||
build/maestro-tv
|
||||
build/maestro-recovery
|
||||
build/maestro-legacy
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Report suite failures
|
||||
if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35-r8.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }}
|
||||
run: |
|
||||
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
|
||||
echo "Android R8 reachability outcome: ${{ steps.api35-r8.outcome }}"
|
||||
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
|
||||
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
|
||||
exit 1
|
||||
+1
-3
@@ -18,9 +18,7 @@ migrate_working_dir/
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
# Keep .vscode/ available for contributors' local configuration.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
|
||||
@@ -10,7 +10,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
|
||||
COPY scripts/maestro/maestro_fixtures.py scripts/maestro/maestro_real_jellyfin.py scripts/maestro/prepare_maestro_media.py ./
|
||||
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
|
||||
|
||||
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
|
||||
|
||||
@@ -22,8 +22,8 @@ tags:
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
||||
# occlusion assertion below would pass for the wrong reason.
|
||||
# Wait for the row before opening the sheet so occlusion is tested against the
|
||||
# grid, not an absent or stale match.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
@@ -31,9 +31,8 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
||||
# an occluded row still read as visible to accessibility and to Maestro.
|
||||
# The grid remains mounted under the Stack; BlockSemantics is required for
|
||||
# occluded rows to disappear from Maestro's accessibility tree.
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
|
||||
@@ -16,25 +16,24 @@ tags:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
# TV playback starts with chrome hidden (#1765), so wait for its loading label
|
||||
# rather than treating the Pause button as readiness. A failed open reaches the
|
||||
# transport assertions below.
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
notVisible: "Overview"
|
||||
timeout: 30000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
||||
# full chrome, so subtitles stay readable.
|
||||
# Pause first — it also freezes the clip, so the seek assertions below cannot
|
||||
# race the end of a short fixture item.
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s)^(Play|Pause)$"
|
||||
# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
|
||||
# the end of this short fixture.
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paused"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
# D-pad seeking reports through the skip badge, never the scrub bar, and
|
||||
# consecutive presses in one direction stack into a running total.
|
||||
# D-pad seeks use the cumulative skip badge, not the scrub bar.
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 10 seconds$"
|
||||
@@ -49,7 +48,7 @@ tags:
|
||||
visible: "Playing"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Pause$"
|
||||
# Select stays the deliberate way to bring the chrome back.
|
||||
# Select is the explicit way to restore the chrome.
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(Play|Pause)$"
|
||||
|
||||
@@ -7,9 +7,21 @@ tags:
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- tapOn:
|
||||
point: "6%,22%"
|
||||
- tapOn: "Search"
|
||||
# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
|
||||
# while percentage coordinates are not portable across TV sizes.
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible:
|
||||
text: "Search"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
@@ -24,13 +36,19 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
# The player starts with chrome hidden (#1765); wait for its loading label
|
||||
# rather than using Pause as readiness.
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
notVisible: "(?s)^Play S1E1$"
|
||||
timeout: 30000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
|
||||
- extendedWaitUntil:
|
||||
visible: "Next Episode"
|
||||
visible: "(?s)^Cancel$"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
@@ -42,7 +60,10 @@ tags:
|
||||
platform: Android
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- assertNotVisible: "Next Episode"
|
||||
# Dismissal is animated, so give it a bounded wait rather than one instant look.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Cancel$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
|
||||
@@ -8,10 +8,8 @@ tags:
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
||||
# needs some playable movie, so accept any of them rather than pinning one and
|
||||
# failing whenever the tie-break lands elsewhere.
|
||||
# All seeded titles share dateadded, so accept any playable Recently Added movie
|
||||
# instead of relying on an unstable tie-break.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
timeout: 15000
|
||||
|
||||
@@ -7,20 +7,9 @@ tags:
|
||||
- settings
|
||||
- sheets
|
||||
---
|
||||
# Guards 8e1904dd and c48cbf70 together.
|
||||
#
|
||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
||||
# answered the platform pop with a TV-only dedup marker that could already be
|
||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
||||
# "Manage Libraries is still there after one Back" assertion below.
|
||||
#
|
||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
||||
# assertions pin that an open sheet actually hides the rows underneath.
|
||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
||||
# so it must not inherit whichever layout the previous flow left behind.
|
||||
# Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back
|
||||
# dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
|
||||
# Start clean because preceding TV flows may leave Force TV mode enabled.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
@@ -29,10 +18,8 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
||||
# excluded on purpose: it is also the sheet's own title.
|
||||
# The barrier removes covered rows from semantics; the sheet title remains
|
||||
# visible because it belongs to the sheet.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
@@ -41,15 +28,13 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
# One Back closed only the sheet: Settings is still the current route.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
||||
# dismissal has to work exactly like the first.
|
||||
# Reopening verifies the one-shot pop deduplication marker was not stranded.
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
|
||||
@@ -7,20 +7,10 @@ tags:
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
||||
# pending pass, and the wait below is long enough to outlive both timers.
|
||||
#
|
||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
||||
# streams, so both picks below are genuine changes away from the automatic one.
|
||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
||||
# from an earlier run would pre-select the very rows this flow picks, making
|
||||
# every assertion below vacuous.
|
||||
# Guards 468d6804: a user pick must retire TrackManager's pending automatic
|
||||
# selection pass, or the server preference can overwrite it up to 30s later.
|
||||
# The codec fixture supplies non-default audio and subtitle choices, and clean
|
||||
# state prevents remembered selections from making the assertions vacuous.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
|
||||
@@ -7,16 +7,9 @@ tags:
|
||||
- settings
|
||||
- i18n
|
||||
---
|
||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
||||
# proves the generated locale is wired end to end.
|
||||
#
|
||||
# This flow mutates a persisted pref, and the app language decides every
|
||||
# selector after the tap, so it starts from a cleared install rather than
|
||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
||||
# hunting for English strings in a Turkish tree.
|
||||
# Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation
|
||||
# labels proves the generated locale is wired end to end. Start clean because
|
||||
# the language preference persists and affects every later selector.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
@@ -54,9 +47,7 @@ tags:
|
||||
- assertVisible: "Keşfet"
|
||||
- assertNotVisible: "(?s)^Libraries.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
||||
# plain launchApp, so leaving the device in Turkish would break every later
|
||||
# flow in the suite.
|
||||
# Restore English before leaving the device; the locale preference outlives launchApp.
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
|
||||
@@ -7,21 +7,11 @@ tags:
|
||||
- tv
|
||||
- settings
|
||||
---
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
||||
# settings navigation generally.
|
||||
#
|
||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
||||
# the semantics tree either way, so this flow passes identically on both sides
|
||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
||||
# only hold for the box it was written on. Row geometry belongs to
|
||||
# test/widgets/setting_tile_test.dart.
|
||||
#
|
||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
||||
# mode on the first touch event and the TV number spinner only builds in
|
||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
||||
# spinner for a plain text field.
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e.
|
||||
# This flow intentionally does not pin 15b54e2e's row density: semantics stay
|
||||
# stable across that change, and Maestro has no DPR-relative height assertion.
|
||||
# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
|
||||
# the TV number spinner.
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
||||
#
|
||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
||||
# data and cold-starts straight back to Home, which measures ~16s.
|
||||
#
|
||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
||||
# Reach a signed-in Home without repeating onboarding. A plain launch preserves
|
||||
# stored state (~16s versus ~59s for retyping credentials); fall back to full
|
||||
# onboarding when no session exists so each flow remains runnable alone.
|
||||
- launchApp
|
||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
||||
# every flow pays for a full re-onboarding it did not need.
|
||||
# Wait for the splash to resolve before deciding whether onboarding is needed.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
|
||||
@@ -10,10 +10,8 @@ appId: com.edde746.plezy
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
||||
# on every onboarding. These taps normally find nothing, so gate them on
|
||||
# a single visibility check instead.
|
||||
# Optional taps still pay Maestro's full search cost when they miss; guard them
|
||||
# with one visibility check instead.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboarding for a device that already reports itself as a TV.
|
||||
#
|
||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
||||
# onboards through the handheld layout and then walks Settings to flip
|
||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
||||
# re-assert what PlatformDetector already reports.
|
||||
# Onboard a device that reports itself as a TV; unlike the phone flow, it starts
|
||||
# in TV layout and needs no Force TV mode walk.
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
@@ -32,9 +27,8 @@ appId: com.edde746.plezy
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
||||
# slow. The code path needs a second device to approve, so fall back to the
|
||||
# username form the phone flow already uses.
|
||||
# Quick Connect is awkward on a remote and needs another device, so use the
|
||||
# username form as fallback.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Username|Cancel)$"
|
||||
timeout: 30000
|
||||
|
||||
@@ -17,10 +17,8 @@ appId: com.edde746.plezy
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
# A card announces its watch state three ways, and the codec samples keep a
|
||||
# resume position on the server once any earlier flow has played them, so the
|
||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
||||
# fixture container that is not thrown away between suites.
|
||||
# Codec cards retain watch state and resume position between flows, so accept
|
||||
# any valid watch-state label to keep this subflow rerunnable.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
timeout: 15000
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach Settings from a signed-in Home on a phone layout.
|
||||
#
|
||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
||||
# issued while the previous route is still settling opens nothing. The guarded
|
||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
||||
# already used by flows/07_profiles_settings.yaml.
|
||||
# Reach phone Settings from signed-in Home. The profile menu is an overlay
|
||||
# sheet; retry while the previous route settles.
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
|
||||
+9
-10
@@ -51,32 +51,31 @@ Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Androi
|
||||
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/run_maestro.py media # Codec playback and track selection
|
||||
python3 scripts/maestro/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/maestro/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/maestro/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
python3 scripts/maestro/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
```
|
||||
|
||||
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
|
||||
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
|
||||
|
||||
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
|
||||
`.maestro/regression_flows/`. Automatic PR groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/maestro/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
|
||||
independent tests. A manual-only classification must state why the flow cannot run automatically and must not be
|
||||
described as CI coverage. `.github/workflows/e2e.yml` runs only automatic targets and uploads diagnostics on failure.
|
||||
independent tests. A manual-only classification must state why the flow cannot run automatically.
|
||||
|
||||
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
|
||||
manual target rather than automatic PR coverage. Run them only against the pre-seeded Jellyfin fixture and a disposable
|
||||
manual target rather than an automatic group. Run them only against the pre-seeded Jellyfin fixture and a disposable
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
python3 scripts/maestro/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
```
|
||||
|
||||
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
|
||||
@@ -103,7 +102,7 @@ Update a production image only through a reviewed change:
|
||||
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
|
||||
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
|
||||
with the prior digest and pre-change backup, not by changing the image reference alone.
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
|
||||
5. Run `python3 scripts/checks/check_container_image_pins.py`, `python3 scripts/checks/test_check_container_image_pins.py`, and
|
||||
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
|
||||
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
|
||||
a digest is unavailable.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.11.0"
|
||||
sha256 "72a6a74d2f8f4ac76d83d005ff28957b3abcc9d80d7d575710df031d12c1c8cf"
|
||||
version "2.15.0"
|
||||
sha256 "ad1ada9278d42d706f9e17f0a1fc7a3d8b02ef25536c71674848699955bce9d6"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Plezy
|
||||
</h1>
|
||||
|
||||
A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
|
||||
A modern client for Plex, Jellyfin, and Emby on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
|
||||
|
||||
<p>
|
||||
<a href="https://plezy.app">Website</a> ·
|
||||
@@ -38,9 +38,9 @@ Package managers:
|
||||
brew tap edde746/plezy https://github.com/edde746/plezy
|
||||
brew install --cask plezy
|
||||
```
|
||||
- [AUR](https://aur.archlinux.org/packages/plezy-bin) (Arch Linux) - Community maintained by [@jianglai](https://github.com/jianglai):
|
||||
- [Pacman](https://archlinux.org/packages/extra/x86_64/plezy/) (Arch Linux) - Official package:
|
||||
```bash
|
||||
yay -S plezy-bin
|
||||
sudo pacman -S plezy
|
||||
```
|
||||
- **WinGet** (Windows):
|
||||
```bash
|
||||
@@ -52,11 +52,11 @@ Package managers:
|
||||
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
|
||||
- Libraries, collections, and playlists — video and audio
|
||||
- Discover hub — Continue Watching, Next Up, trending, and recommendations
|
||||
- Cross-server search across every connected Plex and Jellyfin server
|
||||
- Cross-server search across every connected Plex, Jellyfin, and Emby server
|
||||
- Filtering, sorting, and alphabetical jump navigation
|
||||
- Folder browsing and folder playback — home-video libraries open in folder view
|
||||
- Resolution, HDR/Dolby Vision, and audio-format badges on cards and detail pages
|
||||
- Favorites and unwatched library filters[^jf]
|
||||
- Favorites and unwatched library filters[^mb]
|
||||
- Extras — trailers, deleted scenes, behind-the-scenes
|
||||
|
||||
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
|
||||
@@ -119,9 +119,9 @@ Package managers:
|
||||
|
||||
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
|
||||
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
|
||||
- Multiple servers at once — Plex and Jellyfin side by side
|
||||
- Multiple servers at once — Plex, Jellyfin, and Emby side by side
|
||||
- Profiles with per-profile downloads, watch state, and settings; Plex Home switching with PIN
|
||||
- Jellyfin local-server discovery, multiple URLs per server, and Quick Connect sign-in
|
||||
- Jellyfin and Emby local-server discovery and multiple URLs per server; Quick Connect sign-in[^jf]
|
||||
- TV layout options — corner spotlight backdrop, full-card artwork, and Force TV mode on desktop
|
||||
- Customizable keyboard shortcuts[^desktop]
|
||||
- Metadata and artwork editing
|
||||
@@ -129,9 +129,10 @@ Package managers:
|
||||
- Localized in English plus 21 translations
|
||||
|
||||
[^jf]: Jellyfin only.
|
||||
[^mb]: Jellyfin and Emby only.
|
||||
[^plex]: Plex only.
|
||||
[^connect]: Requires connecting the service under Settings > Services.
|
||||
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV.
|
||||
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, tvOS, and Linux — Linux needs a colour-managed Wayland compositor. Dolby Vision on Android and Apple TV.
|
||||
[^pass]: Desktop, Android TV, and Apple TV.
|
||||
[^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer.
|
||||
[^pip]: Android, iOS, and macOS — not on Android TV or Apple TV.
|
||||
@@ -148,7 +149,7 @@ Package managers:
|
||||
|
||||
### Prerequisites
|
||||
- Flutter SDK 3.44.0+
|
||||
- A Plex account or Jellyfin server with user credentials
|
||||
- A Plex account, or a Jellyfin or Emby server with user credentials
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -189,7 +190,7 @@ scripts/setup_hooks.sh
|
||||
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic
|
||||
python3 scripts/maestro/run_maestro.py basic
|
||||
```
|
||||
|
||||
## Contributing
|
||||
@@ -203,5 +204,5 @@ Plezy is licensed under [GPL-3.0](LICENSE).
|
||||
## Acknowledgments
|
||||
|
||||
- Built with [Flutter](https://flutter.dev)
|
||||
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
|
||||
- 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)
|
||||
|
||||
@@ -15,8 +15,7 @@ linter:
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
||||
# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
@@ -27,7 +26,7 @@ dart_code_linter:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
rules:
|
||||
# --- Flutter rules (on top of recommended) ---
|
||||
# Flutter-specific rules.
|
||||
- avoid-border-all
|
||||
- avoid-shrink-wrap-in-lists
|
||||
- avoid-expanded-as-spacer
|
||||
@@ -37,7 +36,7 @@ dart_code_linter:
|
||||
- prefer-define-hero-tag
|
||||
- use-setstate-synchronously
|
||||
|
||||
# --- Additional useful Dart rules ---
|
||||
# Additional Dart rules.
|
||||
- avoid-cascade-after-if-null
|
||||
- avoid-collection-methods-with-unrelated-types
|
||||
- avoid-unnecessary-type-assertions
|
||||
@@ -48,7 +47,7 @@ dart_code_linter:
|
||||
- prefer-enums-by-name
|
||||
- prefer-commenting-analyzer-ignores
|
||||
|
||||
# --- Disable noisy rules from recommended preset ---
|
||||
# Disabled noisy rules.
|
||||
- no-magic-number: false
|
||||
- avoid-dynamic: false
|
||||
- format-comment: false
|
||||
|
||||
@@ -64,7 +64,7 @@ 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"
|
||||
|
||||
val media3Version = "1.10.1"
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
@@ -324,6 +324,11 @@ android {
|
||||
buildToolsVersion = "36.1.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
// Android Automotive OS driver-distraction state (CarUxRestrictionsManager). This is a platform
|
||||
// stub, not a shipped dependency: the classes exist only on AAOS images, so every use is guarded
|
||||
// by FEATURE_AUTOMOTIVE and the manifest declares `uses-library android.car required=false`.
|
||||
useLibrary("android.car")
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -331,8 +336,6 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
@@ -513,6 +516,12 @@ dependencies {
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||
|
||||
// Only used to cancel the legacy periodic shelf refresh job (2.13.0's
|
||||
// removed ShelfRefreshWorker) that WorkManager persisted on updated
|
||||
// devices. Same version background_downloader pins, so the merged
|
||||
// classpath stays coherent.
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-decoder:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
@@ -532,6 +541,7 @@ dependencies {
|
||||
// Real android.util.* implementations for tests exercising media3 classes
|
||||
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testImplementation("androidx.work:work-testing:2.11.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+92
@@ -0,0 +1,92 @@
|
||||
package com.edde746.plezy.car
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Verifies the platform half of the driver-distraction gate on a real device.
|
||||
*
|
||||
* Everything here is invisible to the Dart unit tests: `android.car` is a compile-time stub, so a
|
||||
* missing `useLibrary`, a wrong `uses-library` declaration, a car service that refuses to connect,
|
||||
* or an unresolvable default display all compile fine and merely make [CarRestrictionsMonitor.start]
|
||||
* return false — at which point Plezy silently falls back to lifecycle gating and parked background
|
||||
* audio never works, with no crash to notice.
|
||||
*
|
||||
* On a non-automotive device the monitor must stay inert instead of throwing, which is the other
|
||||
* half of the contract.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CarRestrictionsMonitorTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val isCar = context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
|
||||
|
||||
@Test
|
||||
fun reportsSupportOnlyOnACarAndNeverThrows() {
|
||||
val monitor = CarRestrictionsMonitor(context)
|
||||
val answered = CountDownLatch(1)
|
||||
try {
|
||||
val started = monitor.start { answered.countDown() }
|
||||
if (isCar) {
|
||||
assertTrue("android.car is present but the UX-restriction signal did not connect", started)
|
||||
// The connect never blocks, so readiness arrives on the main thread while this test thread
|
||||
// waits. A timeout here means the car service never handed over a verdict.
|
||||
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
|
||||
assertTrue("a verdict arrived without marking the monitor supported", monitor.supported)
|
||||
} else {
|
||||
assertFalse("a non-automotive device must not claim car restrictions", started)
|
||||
assertFalse(monitor.supported)
|
||||
}
|
||||
} finally {
|
||||
monitor.release()
|
||||
}
|
||||
assertFalse("release() must drop support so a stale verdict cannot leak", monitor.supported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aParkedEmulatorReportsNoDistractionOptimization() {
|
||||
if (!isCar) return
|
||||
val monitor = CarRestrictionsMonitor(context)
|
||||
val answered = CountDownLatch(1)
|
||||
try {
|
||||
assertTrue(monitor.start { answered.countDown() })
|
||||
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
|
||||
// The suite runs on a parked vehicle (no VHAL driving injection), which is the state that
|
||||
// must permit background audio. A restricted verdict here means the gate would keep music
|
||||
// tied to the foreground exactly as before.
|
||||
assertFalse(
|
||||
"parked car reported that distraction optimization is required",
|
||||
monitor.requiresDistractionOptimization
|
||||
)
|
||||
} finally {
|
||||
monitor.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theServiceConnectionRouteUsedBelowAndroidElevenAlsoReportsTheVehicle() {
|
||||
if (!isCar) return
|
||||
// No Android 9 or 10 Automotive image is published, so the only way to exercise the path those
|
||||
// head units take is to force it here: the deprecated API is present on every version.
|
||||
val monitor = CarRestrictionsMonitor(context, forceLegacyConnect = true)
|
||||
val answered = CountDownLatch(1)
|
||||
try {
|
||||
assertTrue("the ServiceConnection route failed to start", monitor.start { answered.countDown() })
|
||||
assertTrue("car service never reported through the legacy route", answered.await(20, TimeUnit.SECONDS))
|
||||
assertTrue(monitor.supported)
|
||||
assertFalse(
|
||||
"parked car reported that distraction optimization is required",
|
||||
monitor.requiresDistractionOptimization
|
||||
)
|
||||
} finally {
|
||||
monitor.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
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.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The hardware half of the #1790 contract, and the half a JVM fake cannot reach.
|
||||
*
|
||||
* `DefaultAudioSink` charges a static, process-wide counter on every flush and only discharges it
|
||||
* from `Listener::onReleased` — the same callback the player surfaces as
|
||||
* [AnalyticsListener.onAudioTrackReleased]. Any lasting imbalance stops media3 escalating audio
|
||||
* failures at all, which is how a failed `AudioTrack` turned into a permanent buffering hang.
|
||||
*
|
||||
* `onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased` once per answered
|
||||
* flush, so counting both against a real sink, across the reuse and eviction cycles the wrapper's
|
||||
* cache actually creates, measures the invariant directly.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AudioOutputReleaseAccountingTest {
|
||||
|
||||
private companion object {
|
||||
const val SEEK_COUNT = 4
|
||||
const val STATE_TIMEOUT_SECONDS = 20L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyFlushIsAnsweredAcrossSeeksAndAConfigChange() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
// Different channel counts give the sink two different output configurations, so the second
|
||||
// item cannot reuse the first one's parked AudioTrack and has to evict it.
|
||||
val surround = copyFixture(context, "ffmpeg/surround_5_1.flac")
|
||||
val stereo = copyFixture(context, "ffmpeg/stereo.flac")
|
||||
|
||||
val playbackThread = HandlerThread("plezy-release-accounting-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val initialized = AtomicInteger()
|
||||
val released = AtomicInteger()
|
||||
|
||||
try {
|
||||
handler.runAndWait {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
// Decoded PCM is the only output the cache parks, so it is the path whose reuse and
|
||||
// eviction the accounting has to survive.
|
||||
shouldBlockDirectAudioOutput = { format ->
|
||||
format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
|
||||
}
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
|
||||
playerReference.set(player)
|
||||
player.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
initialized.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onAudioTrackReleased(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
released.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onPlayerErrorChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
error: androidx.media3.common.PlaybackException?
|
||||
) {
|
||||
if (error != null) errorReference.set(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val player = playerReference.get()
|
||||
play(handler, player, surround)
|
||||
awaitReady(handler, player)
|
||||
|
||||
// Each seek flushes the sink: the output is parked and handed straight back, so every one of
|
||||
// them has to settle its own flush or the counter drifts up for the rest of the process.
|
||||
repeat(SEEK_COUNT) {
|
||||
handler.runAndWait { player.seekTo(0) }
|
||||
awaitReady(handler, player)
|
||||
}
|
||||
|
||||
val afterSeeks = initialized.get() - released.get()
|
||||
assertNull("playback failed before the config change", errorReference.get())
|
||||
assertTrue("expected the seeks to rebuild the audio output", initialized.get() > 1)
|
||||
assertEquals(
|
||||
"after $SEEK_COUNT seeks only the live acquisition may be outstanding " +
|
||||
"(initialized=${initialized.get()}, released=${released.get()})",
|
||||
1,
|
||||
afterSeeks
|
||||
)
|
||||
|
||||
// Config change: the parked 5.1 track cannot serve stereo, so it is evicted for real.
|
||||
play(handler, player, stereo)
|
||||
awaitReady(handler, player)
|
||||
|
||||
assertNull("playback failed after the config change", errorReference.get())
|
||||
assertEquals(
|
||||
"an eviction must not leave a second acquisition outstanding " +
|
||||
"(initialized=${initialized.get()}, released=${released.get()})",
|
||||
1,
|
||||
initialized.get() - released.get()
|
||||
)
|
||||
} finally {
|
||||
handler.runAndWait { playerReference.get()?.release() }
|
||||
playbackThread.quitSafely()
|
||||
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
|
||||
surround.delete()
|
||||
stereo.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same invariant on a real bitstream route, which is the output the reporter's device was
|
||||
* failing to build. Needs a live HDMI sink that advertises encoded surround, so it skips on a
|
||||
* phone or a TV set to PCM rather than passing without having exercised anything.
|
||||
*/
|
||||
@Test
|
||||
fun everyFlushIsAnsweredOnABitstreamRoute() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val movieAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
val capabilities = AudioCapabilities.getCapabilities(context, movieAttributes, null)
|
||||
val fixture = when {
|
||||
capabilities.supportsEncoding(C.ENCODING_E_AC3) -> "ffmpeg/surround_5_1_eac3.mka"
|
||||
capabilities.supportsEncoding(C.ENCODING_DTS) -> "ffmpeg/surround_5_1_dts.mka"
|
||||
else -> null
|
||||
}
|
||||
assumeTrue("the current audio route does not advertise E-AC3 or DTS bitstream", fixture != null)
|
||||
|
||||
val media = copyFixture(context, fixture!!)
|
||||
val playbackThread = HandlerThread("plezy-bitstream-accounting-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val initialized = AtomicInteger()
|
||||
val released = AtomicInteger()
|
||||
val sawEncodedOutput = AtomicReference(false)
|
||||
|
||||
try {
|
||||
handler.runAndWait {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
shouldBlockDirectAudioOutput = { false }
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
|
||||
playerReference.set(player)
|
||||
player.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
initialized.incrementAndGet()
|
||||
if (!isPcmEncoding(audioTrackConfig.encoding)) sawEncodedOutput.set(true)
|
||||
}
|
||||
|
||||
override fun onAudioTrackReleased(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
released.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onPlayerErrorChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
error: androidx.media3.common.PlaybackException?
|
||||
) {
|
||||
if (error != null) errorReference.set(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val player = playerReference.get()
|
||||
play(handler, player, media)
|
||||
awaitReady(handler, player)
|
||||
repeat(SEEK_COUNT) {
|
||||
handler.runAndWait { player.seekTo(0) }
|
||||
awaitReady(handler, player)
|
||||
}
|
||||
|
||||
assertNull("bitstream playback failed", errorReference.get())
|
||||
assumeTrue(
|
||||
"the route advertised encoded surround but the sink still decoded to PCM",
|
||||
sawEncodedOutput.get()
|
||||
)
|
||||
assertEquals(
|
||||
"a bitstream output must answer every flush too " +
|
||||
"(initialized=${initialized.get()}, released=${released.get()})",
|
||||
1,
|
||||
initialized.get() - released.get()
|
||||
)
|
||||
} finally {
|
||||
handler.runAndWait { playerReference.get()?.release() }
|
||||
playbackThread.quitSafely()
|
||||
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
|
||||
media.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun play(handler: Handler, player: ExoPlayer, file: File) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
handler.runAndWait {
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(file)))
|
||||
player.setMediaSource(source)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for the player to reach READY, which is the point an AudioTrack has been acquired. */
|
||||
private fun awaitReady(handler: Handler, player: ExoPlayer) {
|
||||
val ready = CountDownLatch(1)
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY || playbackState == Player.STATE_ENDED) ready.countDown()
|
||||
}
|
||||
}
|
||||
handler.runAndWait {
|
||||
if (player.playbackState == Player.STATE_READY || player.playbackState == Player.STATE_ENDED) {
|
||||
ready.countDown()
|
||||
} else {
|
||||
player.addListener(listener)
|
||||
}
|
||||
}
|
||||
val reached = ready.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
handler.runAndWait { player.removeListener(listener) }
|
||||
assertTrue("timed out waiting for the player to become ready", reached)
|
||||
// The AudioTrack is acquired on the first buffer handed to the sink, which lands just after
|
||||
// READY; give the playback thread a beat to get there before counting.
|
||||
handler.runAndWait { }
|
||||
Thread.sleep(250)
|
||||
handler.runAndWait { }
|
||||
}
|
||||
|
||||
private fun Handler.runAndWait(block: () -> Unit) {
|
||||
val done = CountDownLatch(1)
|
||||
val failure = AtomicReference<Throwable>()
|
||||
post {
|
||||
try {
|
||||
block()
|
||||
} catch (error: Throwable) {
|
||||
failure.set(error)
|
||||
} finally {
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue("timed out running on the playback thread", done.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
failure.get()?.let { throw it }
|
||||
}
|
||||
|
||||
private fun copyFixture(targetContext: Context, fixture: String): File {
|
||||
val output = File.createTempFile("release-accounting-", null, targetContext.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Pins the side-loaded subtitle identity contract against the real media3 the
|
||||
* app links, on a real device.
|
||||
*
|
||||
* `ExoPlayerCore` tags each `MediaItem.SubtitleConfiguration` with
|
||||
* [ExternalSubtitleIds.idFor] and recovers it from the `Format` the track
|
||||
* selector reports. media3 does not hand that id back verbatim:
|
||||
* `DefaultMediaSourceFactory` merges side-loaded subtitles with the primary
|
||||
* source, and `MergingMediaPeriod` prefixes every child format id with its
|
||||
* period index. Matching the raw id therefore classifies every sidecar as an
|
||||
* embedded track and drops its URI, which is what broke Plex sidecar
|
||||
* subtitles (#1713).
|
||||
*
|
||||
* A JVM test can only assert the parsing rule. This asserts that the rule
|
||||
* still matches what media3 actually emits.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalSubtitleIdentityTest {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ExternalSubtitleIdentityTest"
|
||||
const val SRT = "1\n00:00:00,500 --> 00:00:05,000\nplezy sidecar identity\n\n"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sideLoadedSubtitleIdSurvivesMedia3Merging() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val media = copyAsset(instrumentation.context, context, "ffmpeg/planar_5_1.m4a")
|
||||
val subtitle = File.createTempFile("sidecar-", ".srt", context.cacheDir).apply {
|
||||
writeText(SRT)
|
||||
}
|
||||
|
||||
val thread = HandlerThread("plezy-sidecar-identity-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val settled = CountDownLatch(1)
|
||||
val playerRef = AtomicReference<ExoPlayer>()
|
||||
val errorRef = AtomicReference<Throwable>()
|
||||
val textFormatId = AtomicReference<String>()
|
||||
val textGroupCount = AtomicReference(0)
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val player = ExoPlayer.Builder(context).setLooper(thread.looper).build()
|
||||
playerRef.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorRef.set(error)
|
||||
settled.countDown()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
val text = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
if (text.isEmpty()) return
|
||||
textGroupCount.set(text.size)
|
||||
textFormatId.set(text.first().mediaTrackGroup.getFormat(0).id)
|
||||
settled.countDown()
|
||||
}
|
||||
})
|
||||
// setMediaItem (not setMediaSource) so DefaultMediaSourceFactory owns
|
||||
// the subtitle configuration exactly as ExoPlayerCore.open does.
|
||||
player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setUri(Uri.fromFile(media))
|
||||
.setSubtitleConfigurations(
|
||||
listOf(
|
||||
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subtitle))
|
||||
.setId(ExternalSubtitleIds.idFor(0))
|
||||
.setLabel("Plezy sidecar")
|
||||
.setLanguage("en")
|
||||
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
player.prepare()
|
||||
} catch (error: Throwable) {
|
||||
errorRef.set(error)
|
||||
settled.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = settled.await(30, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerRef.get()?.release()
|
||||
thread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
thread.join(5_000)
|
||||
media.delete()
|
||||
subtitle.delete()
|
||||
|
||||
assertTrue("Player teardown timed out", teardownFinished)
|
||||
assertNull("Playback failed", errorRef.get())
|
||||
assertTrue("Timed out before any text track group was reported", finished)
|
||||
assertEquals("Expected exactly one side-loaded text group", 1, textGroupCount.get())
|
||||
|
||||
val reportedId = textFormatId.get()
|
||||
Log.i(TAG, "media3 reported side-loaded subtitle Format.id=$reportedId")
|
||||
assertNotNull("Side-loaded subtitle reported a null Format.id", reportedId)
|
||||
|
||||
// The contract ExoPlayerCore depends on.
|
||||
assertTrue(
|
||||
"Side-loaded subtitle was not recognised as external (Format.id=$reportedId)",
|
||||
ExternalSubtitleIds.isExternal(reportedId)
|
||||
)
|
||||
assertEquals(
|
||||
"Side-loaded subtitle did not resolve to its configuration index (Format.id=$reportedId)",
|
||||
0,
|
||||
ExternalSubtitleIds.indexOf(reportedId)
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyAsset(instrumentationContext: Context, targetContext: Context, asset: String): File {
|
||||
val output = File.createTempFile("sidecar-primary-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(asset).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
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.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Speed changes arrive while the carrier is already live (#1804). Nothing re-asks the sink unless
|
||||
* capabilities are invalidated, so this drives the real path: play on the carrier, change speed,
|
||||
* and require the renderer to actually move TrueHD onto a decoder and keep the clock advancing.
|
||||
*
|
||||
* Skips itself on hardware that never takes the carrier; there is no transition to observe there.
|
||||
*/
|
||||
class TrueHdSpeedTransitionTest {
|
||||
private companion object {
|
||||
const val TAG = "TrueHdSpeed"
|
||||
const val SETTLE_MS = 4_000L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavingUnitSpeedMovesTrueHdOffTheCarrierAndKeepsPlaying() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixture = copyFixture(context)
|
||||
val thread = HandlerThread("truehd-speed-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val playing = CountDownLatch(1)
|
||||
val audioDecoder = AtomicReference<String?>(null)
|
||||
val trackEncoding = AtomicReference<Int>(-1)
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
|
||||
handler.post {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
}
|
||||
// Mirrors ExoPlayerCore: without this flag DefaultTrackSelector silently drops every
|
||||
// renderer-capability invalidation, so the carrier could never be re-evaluated.
|
||||
val selector = DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
)
|
||||
}
|
||||
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
|
||||
player.set(exo)
|
||||
exo.addAnalyticsListener(
|
||||
object : AnalyticsListener {
|
||||
override fun onAudioDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
audioDecoder.set(decoderName)
|
||||
Log.i(TAG, "audio decoder: $decoderName")
|
||||
}
|
||||
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
config: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
trackEncoding.set(config.sampleRate)
|
||||
Log.i(TAG, "AudioTrack rate=${config.sampleRate} buffer=${config.bufferSize}")
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
|
||||
if (isPlaying) playing.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.prepare()
|
||||
exo.playWhenReady = true
|
||||
}
|
||||
|
||||
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
|
||||
Thread.sleep(SETTLE_MS)
|
||||
|
||||
val carrierEncoding = trackEncoding.get()
|
||||
val decoderBefore = audioDecoder.get()
|
||||
Log.i(TAG, "==== BEFORE: rate=$carrierEncoding decoder=$decoderBefore ====")
|
||||
|
||||
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
|
||||
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
|
||||
// therefore the 192kHz carrier rate with no decoder instantiated.
|
||||
if (carrierEncoding != IecCarrier.SAMPLE_RATE || decoderBefore != null) {
|
||||
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
|
||||
teardown(handler, player, thread, fixture)
|
||||
return
|
||||
}
|
||||
|
||||
val positionBefore = positionOf(handler, player)
|
||||
handler.post { player.get()?.setPlaybackSpeed(1.5f) }
|
||||
Thread.sleep(SETTLE_MS)
|
||||
|
||||
val encodingAfter = trackEncoding.get()
|
||||
val decoderAfter = audioDecoder.get()
|
||||
val positionAfter = positionOf(handler, player)
|
||||
Log.i(
|
||||
TAG,
|
||||
"==== AFTER: rate=$encodingAfter decoder=$decoderAfter " +
|
||||
"position=${positionBefore}ms -> ${positionAfter}ms ===="
|
||||
)
|
||||
|
||||
// Returning to 1x has to re-offer the carrier, or one speed nudge costs Atmos for the session.
|
||||
trackEncoding.set(-1)
|
||||
handler.post { player.get()?.setPlaybackSpeed(1f) }
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val rateRestored = trackEncoding.get()
|
||||
Log.i(TAG, "==== RESTORED: rate=$rateRestored ====")
|
||||
|
||||
teardown(handler, player, thread, fixture)
|
||||
|
||||
assertEquals(
|
||||
"returning to 1x must put TrueHD back on the carrier",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
rateRestored
|
||||
)
|
||||
assertNotEquals(
|
||||
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
encodingAfter
|
||||
)
|
||||
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
|
||||
assertTrue("the clock must keep advancing after the switch", positionAfter > positionBefore)
|
||||
// The whole point of the switch: the speed the user asked for has to actually apply. At 1.5x
|
||||
// the media clock must outrun the 4s of wall time spent waiting.
|
||||
assertTrue(
|
||||
"playback must run faster than real time after the switch, advanced " +
|
||||
"${positionAfter - positionBefore}ms in ${SETTLE_MS}ms",
|
||||
positionAfter - positionBefore > SETTLE_MS * 6 / 5
|
||||
)
|
||||
}
|
||||
|
||||
private fun positionOf(handler: Handler, player: AtomicReference<ExoPlayer?>): Long {
|
||||
val value = AtomicReference(0L)
|
||||
val done = CountDownLatch(1)
|
||||
handler.post {
|
||||
value.set(player.get()?.currentPosition ?: 0L)
|
||||
done.countDown()
|
||||
}
|
||||
done.await(5, TimeUnit.SECONDS)
|
||||
return value.get()
|
||||
}
|
||||
|
||||
private fun teardown(
|
||||
handler: Handler,
|
||||
player: AtomicReference<ExoPlayer?>,
|
||||
thread: HandlerThread,
|
||||
fixture: File
|
||||
) {
|
||||
val done = CountDownLatch(1)
|
||||
handler.post {
|
||||
player.get()?.release()
|
||||
done.countDown()
|
||||
}
|
||||
done.await(10, TimeUnit.SECONDS)
|
||||
thread.quitSafely()
|
||||
thread.join(5_000)
|
||||
fixture.delete()
|
||||
}
|
||||
|
||||
private fun copyFixture(
|
||||
context: android.content.Context,
|
||||
asset: String = "ffmpeg/truehd_speed_repro.mka"
|
||||
): File {
|
||||
val output = File.createTempFile("truehd-speed-", null, context.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(asset)
|
||||
.use { input -> output.outputStream().use { input.copyTo(it) } }
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* The container announces 48kHz, which selection trusts, but the bitstream is genuinely 44.1kHz
|
||||
* TrueHD, which the carrier does not cover (#1804). The packer emits nothing in that state, so
|
||||
* the sink has to hand the stream to the decoder rather than play silence.
|
||||
*/
|
||||
@Test
|
||||
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
if (!supportsIecCarrier(context)) {
|
||||
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
||||
return
|
||||
}
|
||||
val fixture = copyFixture(context, "ffmpeg/truehd_mismatch_repro.mka")
|
||||
val thread = HandlerThread("truehd-mismatch-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val playing = CountDownLatch(1)
|
||||
val audioDecoder = AtomicReference<String?>(null)
|
||||
val trackRate = AtomicReference(-1)
|
||||
val rateSequence = java.util.Collections.synchronizedList(mutableListOf<Int>())
|
||||
val diagnostics = java.util.Collections.synchronizedList(mutableListOf<String>())
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
|
||||
handler.post {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
audioDiagnosticsLogger = { _, _, message ->
|
||||
diagnostics.add(message)
|
||||
Log.i(TAG, "mismatch sink: $message")
|
||||
}
|
||||
}
|
||||
val selector = DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
)
|
||||
}
|
||||
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
|
||||
player.set(exo)
|
||||
exo.addAnalyticsListener(
|
||||
object : AnalyticsListener {
|
||||
override fun onAudioDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
audioDecoder.set(decoderName)
|
||||
Log.i(TAG, "mismatch audio decoder: $decoderName")
|
||||
}
|
||||
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
config: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
trackRate.set(config.sampleRate)
|
||||
rateSequence.add(config.sampleRate)
|
||||
Log.i(TAG, "mismatch AudioTrack rate=${config.sampleRate}")
|
||||
}
|
||||
|
||||
override fun onAudioInputFormatChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
format: androidx.media3.common.Format,
|
||||
decoderReuseEvaluation: androidx.media3.exoplayer.DecoderReuseEvaluation?
|
||||
) {
|
||||
Log.i(TAG, "mismatch INPUT format: mime=${format.sampleMimeType} rate=${format.sampleRate} ch=${format.channelCount}")
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
|
||||
if (isPlaying) playing.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.prepare()
|
||||
exo.playWhenReady = true
|
||||
}
|
||||
|
||||
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionFirst = positionOf(handler, player)
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionSecond = positionOf(handler, player)
|
||||
|
||||
val decoder = audioDecoder.get()
|
||||
val rate = trackRate.get()
|
||||
Log.i(
|
||||
TAG,
|
||||
"==== MISMATCH RESULT: rates=$rateSequence decoder=$decoder rate=$rate " +
|
||||
"position=${positionFirst}ms -> ${positionSecond}ms ===="
|
||||
)
|
||||
teardown(handler, player, thread, fixture)
|
||||
|
||||
// Without this the test would also pass on a build where the carrier was never selected at all:
|
||||
// the mismatch fires on the first access unit, before the carrier writes a burst, so no 192kHz
|
||||
// AudioTrack is ever opened and the rate sequence alone cannot tell the two apart.
|
||||
assertTrue(
|
||||
"the carrier must have been entered and then left at runtime, saw: $diagnostics",
|
||||
diagnostics.any { it.contains("via IEC 61937 carrier") } &&
|
||||
diagnostics.any { it.contains("44.1kHz-family rate its container did not") }
|
||||
)
|
||||
assertTrue("a decoder must take the stream over instead of the carrier", decoder != null)
|
||||
assertNotEquals(
|
||||
"the stream must not still be riding the carrier",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
rate
|
||||
)
|
||||
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
<!-- android:installLocation="auto" is what makes the app eligible to be moved to adoptable
|
||||
storage (a USB drive adopted by an Android TV, an adopted SD card), which relocates the
|
||||
private data directory — and therefore downloads — along with the APK. Plezy declares none
|
||||
of the components this is unsafe for: no app widget, IME, live wallpaper, device admin,
|
||||
accessibility service, sync adapter or account authenticator. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
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" />
|
||||
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
<!-- PIP and media session foreground service permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
||||
|
||||
<!-- Background download notifications and foreground service -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
|
||||
@@ -24,15 +27,11 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
|
||||
|
||||
<!-- Android TV support (not required, but allow detection) -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!-- Android TV Watch Next integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- Touchscreen not required for TV -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<!-- Android Automotive OS support -->
|
||||
<uses-feature android:name="android.hardware.type.automotive" android:required="false" />
|
||||
|
||||
<application
|
||||
@@ -44,6 +43,9 @@
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<!-- Driver-distraction state on Android Automotive OS. Optional: the platform library is
|
||||
absent on every other form factor, and CarRestrictionsMonitor falls back silently. -->
|
||||
<uses-library android:name="android.car" android:required="false" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -54,10 +56,6 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
@@ -68,7 +66,6 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<!-- Allow app to appear in Android TV launcher -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Deep link handler for Watch Next items -->
|
||||
@@ -79,7 +76,6 @@
|
||||
<data android:scheme="plezy" android:host="play"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- FileProvider for sharing downloaded files with external players -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="com.edde746.plezy.fileprovider"
|
||||
|
||||
@@ -10,18 +10,24 @@ import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.edde746.plezy.car.CarRestrictionsMonitor
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
@@ -45,6 +51,20 @@ class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
||||
|
||||
// Flutter's TextInputPlugin issues showSoftInput before the FlutterView is
|
||||
// the IMM's served view (the InputConnection restart is deferred to the
|
||||
// next channel message), so on TV the D-pad-driven first open is dropped
|
||||
// with "Ignoring showSoftInput() as view ... is not served" and never
|
||||
// retried (flutter/flutter#177360). These bounded retries re-issue the
|
||||
// show once the view is served; the restart budget repairs the sibling
|
||||
// failure mode where the keyboard shows but its key session never bound
|
||||
// ("Ignoring onBind: cur seq=-1"), leaving Gboard blind to D-pad
|
||||
// (#1051, #1079).
|
||||
private const val IME_SHOW_RETRY_LIMIT = 4
|
||||
private const val IME_SHOW_RETRY_INTERVAL_MS = 300L
|
||||
private const val IME_LEAK_RESTART_BUDGET = 2
|
||||
private const val IME_LEAK_RESTART_MIN_INTERVAL_MS = 1000L
|
||||
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
|
||||
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
|
||||
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
|
||||
@@ -74,8 +94,18 @@ class MainActivity : FlutterActivity() {
|
||||
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
|
||||
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 var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var carRestrictions: CarRestrictionsMonitor? = null
|
||||
private var carRestrictionsChannel: MethodChannel? = null
|
||||
private var nativeTextInputFocused = false
|
||||
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
|
||||
private var imeShowAttempts = 0
|
||||
private var imeLeakRestartBudget = 0
|
||||
private var imeRestartedOnShow = false
|
||||
private var imeWasVisible = false
|
||||
private var lastImeLeakRestartUptime = 0L
|
||||
private var imeVisibilityListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
private var originalWindowBrightness: Float? = null
|
||||
private var flutterTextureView: FlutterTextureView? = null
|
||||
private var flutterSurfaceReconnectPending = false
|
||||
@@ -89,7 +119,6 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto PiP state
|
||||
private var autoPipReady = false
|
||||
private var autoPipWidth: Int = 16
|
||||
private var autoPipHeight: Int = 9
|
||||
@@ -159,6 +188,82 @@ class MainActivity : FlutterActivity() {
|
||||
return forward
|
||||
}
|
||||
|
||||
private fun inputMethodManager(): InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
|
||||
private fun flutterView(): View? = findViewById(FLUTTER_VIEW_ID)
|
||||
|
||||
// Re-issues a soft-input show that the engine dropped because the
|
||||
// FlutterView was not yet the IMM's served view when TextInput.show ran
|
||||
// (flutter/flutter#177360). Flutter never retries on its own — its Dart
|
||||
// side believes the keyboard is already up — so without this the first
|
||||
// D-pad-driven open on TV can silently do nothing.
|
||||
private val imeShowRetry = object : Runnable {
|
||||
override fun run() {
|
||||
if (!nativeTextInputFocused) return
|
||||
if (isImeVisible()) return
|
||||
val view = flutterView()
|
||||
val imm = inputMethodManager()
|
||||
if (view != null && imm.isActive(view)) {
|
||||
logTextInputDiag { "imeShowRetry re-showing attempt=$imeShowAttempts ${describeImeState()}" }
|
||||
imm.showSoftInput(view, 0)
|
||||
} else {
|
||||
logTextInputDiag { "imeShowRetry waiting attempt=$imeShowAttempts served=${view != null && imm.isActive(view)}" }
|
||||
}
|
||||
imeShowAttempts++
|
||||
if (imeShowAttempts < IME_SHOW_RETRY_LIMIT) {
|
||||
imeRecoveryHandler.postDelayed(this, IME_SHOW_RETRY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNativeTextInputSession() {
|
||||
imeShowAttempts = 0
|
||||
imeLeakRestartBudget = IME_LEAK_RESTART_BUDGET
|
||||
imeRestartedOnShow = false
|
||||
imeRecoveryHandler.removeCallbacks(imeShowRetry)
|
||||
imeRecoveryHandler.postDelayed(imeShowRetry, IME_SHOW_RETRY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun endNativeTextInputSession() {
|
||||
imeRecoveryHandler.removeCallbacks(imeShowRetry)
|
||||
}
|
||||
|
||||
private fun restartNativeTextInput(reason: String) {
|
||||
val view = flutterView() ?: return
|
||||
logTextInputDiag { "restartInput reason=$reason ${describeImeState()}" }
|
||||
inputMethodManager().restartInput(view)
|
||||
}
|
||||
|
||||
// A visible IME owns D-pad navigation: a healthy Gboard consumes these keys
|
||||
// at the ImeInputStage, before the app. One arriving here therefore means
|
||||
// the IME's key session never bound ("Ignoring onBind: cur seq=-1") — the
|
||||
// Chromecast/Google TV failure of #1051/#1079. Repair by rebinding, and eat
|
||||
// the press so Flutter focus cannot wander behind the stuck keyboard. The
|
||||
// bounded budget guarantees keys flow again (and Flutter can close the
|
||||
// session) if rebinding cannot heal the device.
|
||||
private fun consumeLeakedImeNavigationKey(event: KeyEvent): Boolean {
|
||||
if (!nativeTextInputFocused || imeLeakRestartBudget <= 0) return false
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> Unit
|
||||
else -> return false
|
||||
}
|
||||
if (!isImeVisible()) return false
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now - lastImeLeakRestartUptime >= IME_LEAK_RESTART_MIN_INTERVAL_MS) {
|
||||
lastImeLeakRestartUptime = now
|
||||
imeLeakRestartBudget--
|
||||
restartNativeTextInput("leaked-dpad-while-ime-visible")
|
||||
}
|
||||
}
|
||||
logTextInputDiag { "consuming leaked IME key ${describeKeyEvent(event)} budget=$imeLeakRestartBudget" }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getAndroidTvDetection(): Map<String, Any> {
|
||||
val pm = packageManager
|
||||
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||
@@ -429,6 +534,24 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
)
|
||||
|
||||
// Watch IME visibility so a fresh session can be rebound the moment the
|
||||
// keyboard first shows: on Chromecast-class devices the initial bind can
|
||||
// land against a stale sequence, leaving the IME without a key session
|
||||
// (D-pad dead, #1051/#1079). One restartInput at first-show — before the
|
||||
// user has typed or moved the key highlight — repairs it invisibly.
|
||||
val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val visible = isImeVisible()
|
||||
if (visible == imeWasVisible) return@OnGlobalLayoutListener
|
||||
imeWasVisible = visible
|
||||
logTextInputDiag { "ime visibility changed visible=$visible ${describeImeState()}" }
|
||||
if (visible && nativeTextInputFocused && !imeRestartedOnShow) {
|
||||
imeRestartedOnShow = true
|
||||
restartNativeTextInput("first-show-rebind")
|
||||
}
|
||||
}
|
||||
window.decorView.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
|
||||
imeVisibilityListener = visibilityListener
|
||||
|
||||
// Handle Watch Next deep link from initial launch
|
||||
handleWatchNextIntent(intent)
|
||||
}
|
||||
@@ -443,6 +566,9 @@ class MainActivity : FlutterActivity() {
|
||||
if (isDpadKeyCode(event.keyCode)) {
|
||||
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
|
||||
}
|
||||
// Reaching the activity means the ImeInputStage already declined this
|
||||
// key, so consumption below cannot starve a healthy IME.
|
||||
if (consumeLeakedImeNavigationKey(event)) return true
|
||||
val handled = super.dispatchKeyEvent(event)
|
||||
if (isDpadKeyCode(event.keyCode)) {
|
||||
logTextInputDiag {
|
||||
@@ -460,6 +586,12 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
externalPlayerChannel.dispose()
|
||||
endNativeTextInputSession()
|
||||
imeVisibilityListener?.let { window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(it) }
|
||||
imeVisibilityListener = null
|
||||
carRestrictions?.release()
|
||||
carRestrictions = null
|
||||
carRestrictionsChannel = null
|
||||
activityStarted = false
|
||||
flutterSurfaceReconnectPending = false
|
||||
flutterTextureView = null
|
||||
@@ -474,6 +606,29 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Connects the car UX-restriction monitor on first use, retrying while the platform signal is
|
||||
// unavailable: a car service that was not ready during startup can still answer later, and on a
|
||||
// phone every attempt fails cheaply on the FEATURE_AUTOMOTIVE check. The connect itself never
|
||||
// blocks, so this is safe on the main thread; readiness arrives through the callback below.
|
||||
private fun startCarRestrictionsIfNeeded() {
|
||||
val existing = carRestrictions
|
||||
if (existing?.supported == true) return
|
||||
val monitor = existing ?: CarRestrictionsMonitor(applicationContext).also { carRestrictions = it }
|
||||
monitor.start { restricted ->
|
||||
runOnUiThread {
|
||||
// `supported` rides along because it can go false again when the car service dies, and Dart
|
||||
// must then fall back to lifecycle gating rather than read a stale verdict.
|
||||
carRestrictionsChannel?.invokeMethod(
|
||||
"onChanged",
|
||||
mapOf(
|
||||
"supported" to monitor.supported,
|
||||
"requiresDistractionOptimization" to restricted
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFlutterShellArgs(): FlutterShellArgs {
|
||||
val args = super.getFlutterShellArgs()
|
||||
selectedFlutterRenderer = selectFlutterRenderer()
|
||||
@@ -602,6 +757,28 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val carChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CAR_RESTRICTIONS_CHANNEL)
|
||||
carRestrictionsChannel = carChannel
|
||||
carChannel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getState" -> {
|
||||
startCarRestrictionsIfNeeded()
|
||||
val monitor = carRestrictions
|
||||
val supported = monitor?.supported == true
|
||||
result.success(
|
||||
mapOf(
|
||||
"supported" to supported,
|
||||
// Tells Dart the difference between "this device has no car service" and "the verdict
|
||||
// is coming": only the latter is worth waiting for.
|
||||
"pending" to (monitor?.pending == true),
|
||||
"requiresDistractionOptimization" to (supported && monitor.requiresDistractionOptimization)
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
handleDeviceAdjustmentCall(call.method, call.arguments, result)
|
||||
}
|
||||
@@ -614,6 +791,11 @@ class MainActivity : FlutterActivity() {
|
||||
logTextInputDiag {
|
||||
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
|
||||
}
|
||||
if (nativeTextInputFocused && !oldValue) {
|
||||
startNativeTextInputSession()
|
||||
} else if (!nativeTextInputFocused && oldValue) {
|
||||
endNativeTextInputSession()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.edde746.plezy.car
|
||||
|
||||
import android.car.Car
|
||||
import android.car.drivingstate.CarUxRestrictions
|
||||
import android.car.drivingstate.CarUxRestrictionsManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
* Reads Android Automotive OS driver-distraction state straight from the platform.
|
||||
*
|
||||
* Plezy previously derived "may audio play?" from the Flutter activity lifecycle, which conflates
|
||||
* two different things: a car that is driving, and a car that is parked with Plezy simply not in
|
||||
* the foreground. Only the first must silence playback (car app quality `DD-2`/`DD-3`); the second
|
||||
* is ordinary background audio. [CarUxRestrictionsManager] is the signal the platform documents for
|
||||
* apps where lifecycle reaction is not sufficient.
|
||||
*
|
||||
* Bound explicitly to [Display.DEFAULT_DISPLAY] — the driver's screen — by creating a display
|
||||
* context for it. An application context would NOT do: it has no associated display, so
|
||||
* `CarUxRestrictionsManager` falls back to the current user's assigned main display, which under
|
||||
* multi-user Android Automotive OS can be a passenger screen. UX restrictions legitimately differ
|
||||
* per display, but audio is not per-display, and `DD-2` requires audio to stop when the user starts
|
||||
* driving unless the device declares `com.android.car.background_audio_while_driving`. Reading the
|
||||
* driver display therefore keeps a passenger-display session stricter than it may need to be, which
|
||||
* is the safe direction; do not rebind this without pairing it with that feature check. If the
|
||||
* default display cannot be resolved, [start] fails closed and the caller keeps lifecycle gating.
|
||||
*
|
||||
* Connection uses the [Car.CarServiceLifecycleListener] overload with
|
||||
* [Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT], which is not a preference:
|
||||
*
|
||||
* - `Car.createCar(Context)` blocks the calling thread polling for the car service (AOSP polls
|
||||
* every 50 ms up to 100 times, so up to five seconds) and is reached from the main thread here.
|
||||
* - Worse, a client created without a lifecycle listener is terminated when the car service dies:
|
||||
* the library calls `finishClient()`, which for anything that is not literally an `Activity` or
|
||||
* `Service` — a display context included — runs `Process.killProcess(myPid())`. A routine car
|
||||
* service update would take the whole app down.
|
||||
*
|
||||
* With the listener, `createCar` never blocks and a service restart arrives as `ready = false`
|
||||
* instead. Every `ready = true` re-fetches the manager and re-registers the listener, because the
|
||||
* old manager holds a dead binder: its calls then return null and are swallowed, which would look
|
||||
* like a permanently parked car rather than a failure.
|
||||
*
|
||||
* `android.car` is a platform library that only exists on Android Automotive OS images, hence the
|
||||
* `useLibrary` compile-time stub, the `uses-library required=false` manifest entry, and the
|
||||
* defensive [Throwable] catches: on a phone the classes are simply absent.
|
||||
*/
|
||||
class CarRestrictionsMonitor(
|
||||
private val context: Context,
|
||||
/**
|
||||
* Forces the pre-Android-11 [ServiceConnection] route on a device that also has the newer one, so
|
||||
* instrumentation can exercise it: no Android 9 or 10 Automotive system image is published.
|
||||
*/
|
||||
private val forceLegacyConnect: Boolean = false
|
||||
) {
|
||||
|
||||
private var car: Car? = null
|
||||
private var manager: CarUxRestrictionsManager? = null
|
||||
private var onChanged: ((Boolean) -> Unit)? = null
|
||||
private var connecting = false
|
||||
private var legacyConnection = false
|
||||
|
||||
/**
|
||||
* True while a live car connection can answer; until then callers must not trust
|
||||
* [requiresDistractionOptimization]. Goes back to false if the car service dies.
|
||||
*/
|
||||
var supported: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Latest platform verdict. Defaults to restricted so a half-connected car can never be read as
|
||||
* "free to play"; [supported] is what gates whether this value is used at all.
|
||||
*/
|
||||
var requiresDistractionOptimization: Boolean = true
|
||||
private set
|
||||
|
||||
/**
|
||||
* A connection exists but has not produced a verdict yet — the car service is starting, or died
|
||||
* and is expected back. Callers that gate behaviour on the verdict should wait rather than treat
|
||||
* this like a phone, where no answer is ever coming.
|
||||
*/
|
||||
val pending: Boolean
|
||||
get() = connecting && !supported
|
||||
|
||||
/**
|
||||
* Begins connecting to the car service. Returns whether this device can have the signal at all,
|
||||
* NOT whether it is ready: readiness arrives through [onChanged], and [supported] reports it.
|
||||
* A false return leaves the caller on its previous (lifecycle-derived) behaviour for good.
|
||||
*/
|
||||
fun start(onChanged: (Boolean) -> Unit): Boolean {
|
||||
if (supported) return true
|
||||
val existing = car
|
||||
if (existing != null) {
|
||||
// A connection exists but is not observing the vehicle: the manager lookup or the listener
|
||||
// registration failed. Retry it here, because the car service is already ready and will not
|
||||
// announce itself again — without this the signal would stay dark for the whole process.
|
||||
this.onChanged = onChanged
|
||||
if (existing.isConnected) bind(existing) else reconnectLegacyIfNeeded()
|
||||
return true
|
||||
}
|
||||
if (connecting) return true
|
||||
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
|
||||
|
||||
return try {
|
||||
// Fail closed when the driver display cannot be resolved: an unbound context would silently
|
||||
// report some other display's restrictions (see the class doc).
|
||||
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return false
|
||||
val driverDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY) ?: return false
|
||||
val displayContext = context.createDisplayContext(driverDisplay)
|
||||
this.onChanged = onChanged
|
||||
connecting = true
|
||||
if (!forceLegacyConnect && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
connectWithLifecycleListener(displayContext)
|
||||
} else {
|
||||
connectWithServiceConnection(displayContext)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Car UX restrictions unavailable; falling back to lifecycle gating", error)
|
||||
release()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Android 11 and newer: the car service announces readiness and death through one callback. */
|
||||
private fun connectWithLifecycleListener(displayContext: Context): Boolean {
|
||||
val created = Car.createCar(
|
||||
displayContext,
|
||||
null,
|
||||
Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT
|
||||
) { connectedCar: Car, ready: Boolean ->
|
||||
// Use the Car handed to the listener: on the synchronous path — the car service is already
|
||||
// up and this runs on the main thread — it fires inside createCar, before it returns.
|
||||
if (ready) bind(connectedCar) else unbind()
|
||||
}
|
||||
// Cold service: the listener has not run, so this is the only reference to the pending
|
||||
// connection and release() would otherwise leave it bound for the process's lifetime.
|
||||
if (car == null) car = created
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 9 and 10 head units, which have no [Car.CarServiceLifecycleListener].
|
||||
*
|
||||
* A [ServiceConnection] is the safe alternative, not merely an older one: the process kill in
|
||||
* `finishClient()` is reached only by a client that supplies neither callback, and binding here is
|
||||
* asynchronous, so nothing blocks. Kept behind a version check because naming the newer overload
|
||||
* on such a device raises `NoSuchMethodError`.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun connectWithServiceConnection(displayContext: Context): Boolean {
|
||||
legacyConnection = true
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
car?.let { bind(it) }
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
unbind()
|
||||
// Android 10 unbinds inside its own disconnect handling, so the binding that would have
|
||||
// reconnected is gone; newer platforms keep it and race us harmlessly.
|
||||
reconnectLegacyIfNeeded()
|
||||
}
|
||||
}
|
||||
val created = Car.createCar(displayContext, connection)
|
||||
if (created == null) {
|
||||
// The platform returns null when the car service loader is unavailable. Clearing the
|
||||
// connection state matters: leaving it set would report a pending verdict for the rest of the
|
||||
// process, and every later start would short-circuit instead of trying again.
|
||||
Log.w(TAG, "Car service loader unavailable; falling back to lifecycle gating for now")
|
||||
release()
|
||||
return false
|
||||
}
|
||||
car = created
|
||||
created.connect()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun reconnectLegacyIfNeeded() {
|
||||
if (!legacyConnection) return
|
||||
val existing = car ?: return
|
||||
if (existing.isConnected || existing.isConnecting) return
|
||||
try {
|
||||
existing.connect()
|
||||
} catch (error: IllegalStateException) {
|
||||
// The platform's own reconnect won the race, which is the outcome we wanted anyway.
|
||||
Log.w(TAG, "Car service reconnect already in progress", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adopts a ready car service: fresh manager, fresh listener, fresh verdict. */
|
||||
private fun bind(connectedCar: Car) {
|
||||
car = connectedCar
|
||||
try {
|
||||
val uxManager = connectedCar.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager
|
||||
if (uxManager == null) {
|
||||
Log.w(TAG, "Car service is ready but has no UX restrictions manager")
|
||||
return
|
||||
}
|
||||
// Register before reading, per the platform's own guidance: registration subscribes to future
|
||||
// changes only, it does not replay the current one. Reading first would drop a transition that
|
||||
// lands between the two binder calls, and publishing the stale "parked" afterwards would then
|
||||
// override lifecycle gating and permit audio for the whole drive.
|
||||
//
|
||||
// Identity-guarded because a retry re-enters bind() with the same manager instance (Car caches
|
||||
// managers until the service dies): a second registration is at best redundant and at worst a
|
||||
// platform refusal. `manager` is assigned after the listener sticks, so it only ever names a
|
||||
// manager that is actually observing the vehicle.
|
||||
if (manager !== uxManager) {
|
||||
uxManager.registerListener { restrictions: CarUxRestrictions -> adoptVerdict(restrictions.isRequiresDistractionOptimization) }
|
||||
manager = uxManager
|
||||
}
|
||||
val current = uxManager.currentCarUxRestrictions
|
||||
if (current == null) {
|
||||
// The service holds no restrictions for this display (yet). Claiming a verdict here would
|
||||
// publish the restricted default with `supported = true`, and on a car that then stays
|
||||
// parked no transition ever arrives to correct it — playback would refuse to start for the
|
||||
// whole session. Stay pending instead: Dart keeps lifecycle gating (parked, foregrounded
|
||||
// video plays; while driving the platform blocks the activity), the listener above adopts
|
||||
// the first real verdict, and every later getState retries this read.
|
||||
Log.w(TAG, "Car service has no UX restrictions for this display; keeping lifecycle gating until it answers")
|
||||
return
|
||||
}
|
||||
adoptVerdict(current.isRequiresDistractionOptimization)
|
||||
} catch (error: Throwable) {
|
||||
// Registration is the failure the caller's retry is meant to survive, so keep the connection
|
||||
// owned (it is in `car`) and report nothing rather than half-observing the vehicle.
|
||||
Log.w(TAG, "Failed to observe car UX restrictions", error)
|
||||
manager = null
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
/** A real verdict from the vehicle: only now may callers trust [requiresDistractionOptimization]. */
|
||||
private fun adoptVerdict(restricted: Boolean) {
|
||||
supported = true
|
||||
publish(restricted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a dead car service. The [Car] itself is kept: its `BIND_AUTO_CREATE` binding is what
|
||||
* reconnects, and disconnecting would end that for good.
|
||||
*/
|
||||
private fun unbind() {
|
||||
Log.w(TAG, "Car service went away; falling back to lifecycle gating until it returns")
|
||||
manager = null
|
||||
supported = false
|
||||
requiresDistractionOptimization = true
|
||||
onChanged?.invoke(true)
|
||||
}
|
||||
|
||||
private fun publish(restricted: Boolean) {
|
||||
requiresDistractionOptimization = restricted
|
||||
onChanged?.invoke(restricted)
|
||||
}
|
||||
|
||||
/** Unregisters and disconnects. Safe to call when never started. */
|
||||
fun release() {
|
||||
try {
|
||||
manager?.unregisterListener()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Failed to unregister car UX restrictions listener", error)
|
||||
}
|
||||
try {
|
||||
car?.disconnect()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Failed to disconnect from the car service", error)
|
||||
}
|
||||
manager = null
|
||||
car = null
|
||||
onChanged = null
|
||||
connecting = false
|
||||
legacyConnection = false
|
||||
supported = false
|
||||
requiresDistractionOptimization = true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CarRestrictions"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Decides which audio outputs `RawPositionOutputProvider` may park in its reuse cache instead of
|
||||
* releasing on a sink flush.
|
||||
*
|
||||
* The cache exists so a seek does not tear down and rebuild the hardware audio pipeline, which
|
||||
* costs 7-10s of silence on Android TV boxes that reinitialize tunneled output slowly (Sony
|
||||
* Bravia class). That win is real for decoded PCM, but parking a *bitstream* output is a
|
||||
* different trade: a direct/passthrough `AudioTrack` occupies the platform's direct output for as
|
||||
* long as it is held, and several HDMI HALs expose only one. A parked AC3 track therefore blocks
|
||||
* the next AC3 track from being created at all, and the failure surfaces as
|
||||
* `UnsupportedOperationException: Cannot create AudioTrack` rather than as a slow seek (#1790).
|
||||
*
|
||||
* Parking also has to stay honest about media3's process-wide pending-release accounting: a
|
||||
* cached output is *not* releasing, so `RawPositionAudioOutput` signals `onReleased` immediately
|
||||
* for it. That is only true because nothing scarce is being held, which is exactly what this
|
||||
* policy guarantees.
|
||||
*
|
||||
* Offload outputs are excluded for the same reason plus their separate gapless/end-of-stream
|
||||
* state machine, which the cache does not model.
|
||||
*/
|
||||
internal object AudioOutputCachePolicy {
|
||||
|
||||
/**
|
||||
* `AudioTrack.flush()` only reliably resets a stopped track from N MR1 onwards; below that the
|
||||
* cache would hand back a track carrying the previous playback's state.
|
||||
*/
|
||||
const val MIN_SDK_INT = 25
|
||||
|
||||
fun mayCache(encoding: Int, isOffload: Boolean, sdkInt: Int): Boolean {
|
||||
if (sdkInt < MIN_SDK_INT) return false
|
||||
if (isOffload) return false
|
||||
return isPcmEncoding(encoding)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MimeTypes
|
||||
@@ -25,16 +31,65 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT
|
||||
|
||||
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
|
||||
|
||||
/**
|
||||
* The DTS-family mimes the bundled FFmpeg decoder claims (`FfmpegLibrary` maps both to `dca`).
|
||||
*
|
||||
* DTS Express (`audio/vnd.dts.hd;profile=lbr`) and DTS:X (`audio/vnd.dts.uhd`) are deliberately
|
||||
* excluded: FFmpeg does not claim them, so hiding their platform decoders would leave those
|
||||
* streams with no decoder at all.
|
||||
*/
|
||||
internal fun isFfmpegDtsMimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_DTS || mimeType == MimeTypes.AUDIO_DTS_HD
|
||||
|
||||
/**
|
||||
* Whether a DTS stream should decode in the app's FFmpeg decoder instead of a platform
|
||||
* MediaCodec decoder.
|
||||
*
|
||||
* Platform DTS decoders cannot be trusted with decode. On Amlogic-based Google TV boxes (the
|
||||
* Onn family) DTS decode is license-gated in firmware, so `c2.amlogic.audio.decoder.dtshd`
|
||||
* initialises, drains and advances the playback position while rendering silence — and collapses
|
||||
* 5.1 to stereo where it does produce sound (#1995). FFmpeg decodes the whole `dca` family to
|
||||
* full multichannel PCM everywhere, which is what mpv and Kodi ship on the same hardware.
|
||||
*
|
||||
* Scoped to streams that are actually going to decode: [directOutputBlocked] (passthrough off,
|
||||
* downmix, normalization, the AudioTrack-failure blocklist) or a route that cannot bitstream DTS
|
||||
* in any shape ([routeCanBitstreamDts] false — the Android TV default leaves passthrough on, so
|
||||
* the setting alone cannot identify the decode path). Bitstream-capable routes are left exactly
|
||||
* alone: media3 selects direct output before it ever consults the decoder list, and leaving the
|
||||
* platform decoder visible there keeps the hardware-decoder tunneling gate unchanged.
|
||||
*/
|
||||
internal fun shouldForceFfmpegDtsDecode(
|
||||
mimeType: String,
|
||||
directOutputBlocked: () -> Boolean,
|
||||
routeCanBitstreamDts: () -> Boolean
|
||||
): Boolean = isFfmpegDtsMimeType(mimeType) && (directOutputBlocked() || !routeCanBitstreamDts())
|
||||
|
||||
/**
|
||||
* Linear PCM output encodings, i.e. the sink decoded the bitstream instead of
|
||||
* passing it through. Mirrors the platform's `AudioFormat.ENCODING_PCM_*` set.
|
||||
*/
|
||||
internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
AudioFormat.ENCODING_PCM_8BIT,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
AudioFormat.ENCODING_PCM_FLOAT,
|
||||
AudioFormat.ENCODING_PCM_24BIT_PACKED,
|
||||
AudioFormat.ENCODING_PCM_32BIT -> true
|
||||
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,
|
||||
"eac3" to C.ENCODING_E_AC3,
|
||||
"dts" to C.ENCODING_DTS,
|
||||
"dts-hd" to C.ENCODING_DTS_HD,
|
||||
"truehd" to C.ENCODING_DOLBY_TRUEHD
|
||||
"dts" to C.ENCODING_DTS
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -43,23 +98,36 @@ private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
* 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 DTS-HD by downgrading to the DTS core (and E-AC3 JOC to E-AC3)
|
||||
* for receivers that decode only the base layer, and it also rejects channel counts
|
||||
* above the route's PCM maximum, which does not apply to an IEC 61937 carrier. mpv
|
||||
* additionally treats `dts,dts-hd` as `dts-hd` alone, so accepting the downgrade would
|
||||
* name DTS-HD MA to a core-only receiver and lose DTS as well.
|
||||
* 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.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
|
||||
.filter { (_, encoding) -> supportsEncoding(encoding) }
|
||||
.joinToString(",") { (codec, _) -> codec }
|
||||
|
||||
/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */
|
||||
/**
|
||||
* [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.
|
||||
* - The receiver must decode the codec itself — the raw encoding on the current
|
||||
* [AudioCapabilities] — because IEC 61937 is transport, not transcoding.
|
||||
*/
|
||||
// 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)
|
||||
@@ -72,3 +140,146 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
}
|
||||
return mpvSpdifCodecs(capabilities::supportsEncoding)
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */
|
||||
private const val MPV_IEC_SAMPLE_RATE = 48_000
|
||||
private const val MPV_IEC_CHANNEL_COUNT = 2
|
||||
|
||||
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_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_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_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.
|
||||
*
|
||||
* This is Kodi's test, and deliberately not media3's. Kodi asks the AudioTrack layer whether it can
|
||||
* size a buffer for one exact tuple — `getMinBufferSize(rate, mask, encoding) > 0` — and gates the
|
||||
* carrier on the 192kHz/7.1 IEC combination specifically. media3 instead asks the audio policy
|
||||
* layer about the encoding, which on the boxes measured for this issue answers "TrueHD is
|
||||
* offload-capable" and says nothing about whether a raw TrueHD track will ever drain.
|
||||
*
|
||||
* Both are consulted: `getMinBufferSize` proves a track can be built, and a route oracle proves
|
||||
* the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
|
||||
* not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to
|
||||
* initialise.
|
||||
*
|
||||
* The oracle is tiered by what the platform offers:
|
||||
* - API 33+: `getDirectPlaybackSupport`, whose bitstream flag also rules out offload-only answers.
|
||||
* - API 29–32: `AudioTrack.isDirectPlaybackSupported` for the same tuple. Coarser — it cannot tell
|
||||
* bitstream from offload — but an IEC 61937 track is PCM-shaped by definition, so direct support
|
||||
* for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way
|
||||
* and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails
|
||||
* AudioTrack initialisation, which the audio recovery path answers by force-decoding.
|
||||
* - API 24–28: no runtime oracle exists, so the HDMI `AudioDeviceInfo` must explicitly advertise
|
||||
* IEC 61937 at 192kHz/8ch. Shield Experience 8.x is API 28, and the previous flat `false` on
|
||||
* this tier force-decoded TrueHD on routes that genuinely carry it (#1991). A route that
|
||||
* advertises and still refuses the track fails AudioTrack initialisation into the same
|
||||
* recovery path as the tier above.
|
||||
*/
|
||||
internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND) },
|
||||
// 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(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only
|
||||
* consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`)
|
||||
* on 33+, [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32, and
|
||||
* [hdmiRouteAdvertised] (explicit HDMI `AudioDeviceInfo` advertisement) on 24–28 (#1991).
|
||||
*/
|
||||
internal fun iecRouteSupported(
|
||||
sdkInt: Int,
|
||||
canSizeBuffer: () -> Boolean,
|
||||
bitstreamSupported: () -> Boolean,
|
||||
directPlaybackSupported: () -> Boolean,
|
||||
hdmiRouteAdvertised: () -> Boolean
|
||||
): Boolean = when {
|
||||
// ENCODING_IEC61937 itself only exists from API 24.
|
||||
sdkInt < Build.VERSION_CODES.N -> false
|
||||
!canSizeBuffer() -> false
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
|
||||
sdkInt >= Build.VERSION_CODES.Q -> directPlaybackSupported()
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private fun iecBitstreamSupported(format: AudioFormat): Boolean = try {
|
||||
val support = AudioManager.getDirectPlaybackSupport(format, movieAudioAttributes())
|
||||
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses.
|
||||
private fun iecDirectPlaybackSupported(format: AudioFormat): Boolean = try {
|
||||
AudioTrack.isDirectPlaybackSupported(format, movieAudioAttributes())
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an HDMI output *explicitly* advertises IEC 61937 at [sampleRate]/[channelCount] —
|
||||
* the only oracle below API 29 (#1991).
|
||||
*
|
||||
* Empty `AudioDeviceInfo` capability arrays mean "unspecified" and deliberately fail this
|
||||
* check: an unvouched IEC track that initialises on a route that then renders it as PCM plays
|
||||
* the carrier as full-scale noise, which the AudioTrack-init recovery path cannot catch.
|
||||
*/
|
||||
private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCount: Int): Boolean = try {
|
||||
val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device ->
|
||||
(device.type == AudioDeviceInfo.TYPE_HDMI || device.type == AudioDeviceInfo.TYPE_HDMI_ARC) &&
|
||||
device.encodings.contains(AudioFormat.ENCODING_IEC61937) &&
|
||||
device.sampleRates.contains(sampleRate) &&
|
||||
device.channelCounts.contains(channelCount)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "HDMI route inspection failed; not offering IEC 61937 output", error)
|
||||
false
|
||||
}
|
||||
|
||||
/** 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)
|
||||
.setChannelMask(channelMask)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
.getPlatformAudioAttributes()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Decision logic for the buffering stall watchdog (#1790).
|
||||
*
|
||||
* A renderer that can never become ready pins ExoPlayer in `STATE_BUFFERING` with a full buffer
|
||||
* and a frozen clock. The reported case is an `AudioTrack` that fails to initialize: media3 logs a
|
||||
* sink error, keeps retrying, and — when its process-wide pending-release accounting has been
|
||||
* knocked out of balance — never converts the failure into a `PlaybackException`. Nothing else
|
||||
* notices. The frame watchdog wants `STATE_READY` and zero rendered frames, the decoder-hang check
|
||||
* is cancelled by the first frame, [ResumeStallPolicy] needs `isPlaying` and deliberately treats a
|
||||
* frozen clock as "not a decoder stall", [EndOfStreamPolicy] needs the position past the duration,
|
||||
* and media3's own stuck-buffering detector needs an *empty* buffer. So the user sits on a spinner
|
||||
* with no error and no way out.
|
||||
*
|
||||
* This watchdog covers exactly that hole: buffering, intending to play, holding data, and not
|
||||
* moving. A starved buffer is left alone — that is an ordinary network stall and the loader is the
|
||||
* right thing to wait for.
|
||||
*/
|
||||
internal object BufferingStallPolicy {
|
||||
|
||||
/** Poll interval while buffering. */
|
||||
const val CHECK_INTERVAL_MS = 2_000L
|
||||
|
||||
/**
|
||||
* How long a buffering player may hold enough data to start and still not move before it is
|
||||
* called stalled. Long enough to sit out a slow decoder handover or a track reselection, short
|
||||
* enough that a user has not yet given up and force-quit.
|
||||
*
|
||||
* Only time spent above [MIN_BUFFER_AHEAD_MS] counts: a rebuffer that takes a minute to refill
|
||||
* is the loader doing its job, and the clock restarts once it is done.
|
||||
*/
|
||||
const val STALL_TIMEOUT_MS = 12_000L
|
||||
|
||||
/** Position movement that counts as progress rather than clock jitter. */
|
||||
const val PROGRESS_EPSILON_MS = 250L
|
||||
|
||||
/**
|
||||
* Media buffered ahead of the position before a motionless player is anomalous at all.
|
||||
*
|
||||
* A fallback bar, used only until media3 has answered for itself (see [evaluate]). It has to
|
||||
* clear `DefaultLoadControl`'s own play-start bar with room to spare, or the watchdog would
|
||||
* indict the player for obeying it: below [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS]
|
||||
* the load control deliberately keeps playback in `STATE_BUFFERING`, and the two evaluate on
|
||||
* independent schedules.
|
||||
*
|
||||
* Measured in playout time, not media time, because `shouldStartPlayback` divides buffered media
|
||||
* duration by the playback speed: at 2x the same bar needs twice the media, and comparing raw
|
||||
* media duration would call a legitimately rebuffering fast-forward stalled.
|
||||
*/
|
||||
const val MIN_BUFFER_AHEAD_MS = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + 2_000L
|
||||
|
||||
enum class Verdict {
|
||||
/** The position moved; re-baseline and keep watching. */
|
||||
HEALTHY,
|
||||
|
||||
/** Enough data to start, not moving, but not for long enough yet. */
|
||||
WAITING,
|
||||
|
||||
/** Not moving because there is not yet enough to play. An ordinary rebuffer; not ours. */
|
||||
STARVED,
|
||||
|
||||
/** Enough data to start, playback is wanted, and nothing has moved. */
|
||||
STALLED
|
||||
}
|
||||
|
||||
/**
|
||||
* Every signal here can only ever *permit* an indictment; none of them vetoes one, because a
|
||||
* signal that can go stale would otherwise wedge this watchdog shut in exactly the failure it
|
||||
* exists to catch.
|
||||
*
|
||||
* [loadControlReady] is media3's own `shouldStartPlayback` verdict when it has given one, which is
|
||||
* the most faithful answer available — but it is only asked while the renderers report ready, and
|
||||
* a renderer that never becomes ready is that failure. A `false` recorded before the renderer got
|
||||
* stuck would never be revised, so it is treated as no answer rather than as a denial.
|
||||
*
|
||||
* [loading] false means the load control stopped asking for data, whether because its duration
|
||||
* thresholds are met or because its byte target is full. On a high bitrate stream that byte cap is
|
||||
* reached well below [MIN_BUFFER_AHEAD_MS] (see [LoadControlPolicy]), and without this signal a
|
||||
* genuinely stuck player there would look like an ordinary rebuffer forever. A network stall is
|
||||
* the opposite: the loader keeps wanting data it cannot get, so [loading] stays true and none of
|
||||
* the three signals fires.
|
||||
*/
|
||||
fun evaluate(
|
||||
elapsedMs: Long,
|
||||
baselinePositionMs: Long,
|
||||
currentPositionMs: Long,
|
||||
bufferedPositionMs: Long,
|
||||
playbackSpeed: Float = 1f,
|
||||
loadControlReady: Boolean? = null,
|
||||
loading: Boolean = true
|
||||
): Verdict {
|
||||
val speed = if (playbackSpeed > 0f) playbackSpeed else 1f
|
||||
val bufferedPlayoutMs = ((bufferedPositionMs - currentPositionMs) / speed).toLong()
|
||||
val readyToPlay = loadControlReady == true || !loading || bufferedPlayoutMs >= MIN_BUFFER_AHEAD_MS
|
||||
return when {
|
||||
currentPositionMs - baselinePositionMs >= PROGRESS_EPSILON_MS -> Verdict.HEALTHY
|
||||
!readyToPlay -> Verdict.STARVED
|
||||
elapsedMs < STALL_TIMEOUT_MS -> Verdict.WAITING
|
||||
else -> Verdict.STALLED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [verdict] means the elapsed stall clock was measuring something the watchdog is not
|
||||
* responsible for, and must restart.
|
||||
*
|
||||
* Without this a long rebuffer accrues the whole timeout while starved, and the first poll after
|
||||
* the buffer refills reports a stall that never happened.
|
||||
*/
|
||||
fun resetsStallClock(verdict: Verdict): Boolean = verdict == Verdict.HEALTHY || verdict == Verdict.STARVED
|
||||
}
|
||||
+62
-5
@@ -8,14 +8,21 @@ 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 enables approximate seeking for MKV files without Cues.
|
||||
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
|
||||
*
|
||||
* When the underlying extractor reports an [SeekMap.Unseekable] seek map (i.e. the MKV
|
||||
* has no Cues element), this wrapper replaces it with a proportional byte-position
|
||||
* estimate and scans for the nearest Cluster boundary after seeking.
|
||||
* 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(
|
||||
@@ -38,6 +45,9 @@ class CuelessSeekExtractorWrapper(
|
||||
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) {
|
||||
@@ -125,7 +135,13 @@ class CuelessSeekExtractorWrapper(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
|
||||
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) {
|
||||
@@ -137,6 +153,11 @@ class CuelessSeekExtractorWrapper(
|
||||
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
|
||||
@@ -166,4 +187,40 @@ class CuelessSeekExtractorWrapper(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Packs DTS-HD (Master Audio) access units into IEC 61937 "DTS type IV" bursts (#1988).
|
||||
*
|
||||
* Some HDMI routes advertise `ENCODING_DTS_HD` but cannot actually carry Master Audio: Amazon
|
||||
* specifies the Fire TV Stick 4K Max as "DTS-HD passthrough, basic profile", and Android has one
|
||||
* ambiguous encoding constant for both profiles until API 34. Handing media3's raw path full MA
|
||||
* frames there initialises an AudioTrack that drains normally and renders silence. The same
|
||||
* devices do bitstream the 192kHz/7.1 `ENCODING_IEC61937` carrier — the split Kodi models with
|
||||
* its "AudioTrack (IEC)" sink, and the one TrueHD already rides here (#1804, #1863) — so DTS-HD
|
||||
* is packed onto that carrier instead.
|
||||
*
|
||||
* The algorithm is a port of FFmpeg's `spdif_header_dts`/`spdif_header_dts4`
|
||||
* (libavformat/spdifenc.c, n8.1) at `dtshd_rate=768000`, the rate that fills the 8-channel/192kHz
|
||||
* carrier; Kodi's `CAEBitstreamPacker::PackDTSHD` produces the same bytes.
|
||||
*
|
||||
* Output is one complete IEC 61937 burst per access unit:
|
||||
*
|
||||
* - 8-byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x11|subtype<<8 Pd=aligned bytes`
|
||||
* - 10-byte DTS-HD start code, 16-bit big-endian payload size, then the access unit, all 16-bit
|
||||
* byte-swapped
|
||||
* - zero padding to the burst repetition period the core frame duration maps to
|
||||
* (512 samples at 48kHz — the shape of essentially all Master Audio — is 32768 bytes)
|
||||
*
|
||||
* Unlike MAT's fixed frames, a Master Audio peak can genuinely exceed the carrier. FFmpeg answers
|
||||
* by stripping such units to the always-fitting core substream and holding that for
|
||||
* `dtshd_fallback_time` (60s) so receivers do not flap between core and MA decoding; that
|
||||
* behavior is ported as-is and pinned by the golden fixture.
|
||||
*
|
||||
* Not thread safe; the sink drives it from the playback thread only.
|
||||
*/
|
||||
internal class DtsHdIecPacker : IecCarrierPacker {
|
||||
|
||||
internal companion object {
|
||||
private const val BURST_HEADER_SIZE = 8
|
||||
private const val SYNCWORD1 = 0xF872
|
||||
private const val SYNCWORD2 = 0x4E1F
|
||||
private const val IEC61937_DTSHD = 0x11
|
||||
|
||||
/** Core and extension-substream sync words, big-endian raw framing. */
|
||||
private const val SYNC_CORE = 0x7FFE8001
|
||||
private const val SYNC_EXSS = 0x64582025
|
||||
|
||||
/**
|
||||
* Little-endian and 14-bit core framings, from S/PDIF and DTS-in-WAV captures. They have no
|
||||
* period mapping here (FFmpeg's HD path refuses them too), so they latch the stream
|
||||
* unsupported and it decodes.
|
||||
*/
|
||||
private const val SYNC_CORE_LE = 0xFE7F0180.toInt()
|
||||
private const val SYNC_CORE_14B_BE = 0x1FFFE800
|
||||
private const val SYNC_CORE_14B_LE = 0xFF1F00E8.toInt()
|
||||
|
||||
/**
|
||||
* The carrier's IEC 60958 frame rate as FFmpeg's two-channel model counts it: the burst
|
||||
* repetition period is `period = 768000 * coreSamples / coreRate` IEC 60958 frames of 4 bytes,
|
||||
* which is the same byte rate as [IecCarrier]'s 8 channels at 192kHz.
|
||||
*/
|
||||
private const val CARRIER_IEC958_RATE = 768_000
|
||||
|
||||
/** Core SFREQ index to Hz (`ff_dca_sample_rates`); zero marks invalid indices. */
|
||||
private val CORE_SAMPLE_RATES = intArrayOf(
|
||||
0, 8000, 16000, 32000, 0, 0, 11025, 22050, 44100, 0, 0, 12000, 24000, 48000, 96000, 192000
|
||||
)
|
||||
|
||||
/** Precedes every burst payload; `spdifenc.c`'s `dtshd_start_code`. */
|
||||
private val START_CODE = byteArrayOf(0x01, 0, 0, 0, 0, 0, 0, 0, 0xFE.toByte(), 0xFE.toByte())
|
||||
|
||||
/** Seconds of core-only output after an overflow; FFmpeg's `dtshd_fallback_time` default. */
|
||||
private const val HD_STRIP_SECONDS = 60
|
||||
|
||||
/** Minimum bytes needed to read a core header through its SFREQ field. */
|
||||
private const val MIN_CORE_HEADER_LENGTH = 9
|
||||
|
||||
/** IEC 61937-11 subtype for a burst repetition period in IEC 60958 frames, or -1. */
|
||||
private fun subtypeForPeriod(period: Long): Int = when (period) {
|
||||
512L -> 0
|
||||
1024L -> 1
|
||||
2048L -> 2
|
||||
4096L -> 3
|
||||
8192L -> 4
|
||||
16384L -> 5
|
||||
else -> -1
|
||||
}
|
||||
|
||||
private fun readSyncWord(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 24) or
|
||||
((data[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((data[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 3].toInt() and 0xFF)
|
||||
|
||||
/** Reads [count] (≤ 24) bits big-endian starting at absolute bit [bitPosition]. */
|
||||
private fun readBits(data: ByteArray, bitPosition: Int, count: Int): Int {
|
||||
var result = 0
|
||||
var position = bitPosition
|
||||
var remaining = count
|
||||
while (remaining > 0) {
|
||||
val byte = data[position ushr 3].toInt() and 0xFF
|
||||
val bitsLeftInByte = 8 - (position and 7)
|
||||
val take = minOf(bitsLeftInByte, remaining)
|
||||
result = (result shl take) or ((byte shr (bitsLeftInByte - take)) and ((1 shl take) - 1))
|
||||
position += take
|
||||
remaining -= take
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** `NBLKS + 1`: PCM blocks of 32 samples in the core frame at [offset]. */
|
||||
private fun coreBlocks(data: ByteArray, offset: Int): Int {
|
||||
val word = ((data[offset + 4].toInt() and 0xFF) shl 8) or (data[offset + 5].toInt() and 0xFF)
|
||||
return ((word shr 2) and 0x7F) + 1
|
||||
}
|
||||
|
||||
/** `FSIZE + 1`: the core frame's byte length. */
|
||||
private fun coreFrameSize(data: ByteArray, offset: Int): Int {
|
||||
val bits = ((data[offset + 5].toInt() and 0xFF) shl 16) or
|
||||
((data[offset + 6].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 7].toInt() and 0xFF)
|
||||
return ((bits shr 4) and 0x3FFF) + 1
|
||||
}
|
||||
|
||||
/** The core `SFREQ` field mapped to Hz; 0 for reserved indices. */
|
||||
private fun coreSampleRate(data: ByteArray, offset: Int): Int = CORE_SAMPLE_RATES[((data[offset + 8].toInt() and 0xFF) shr 2) and 0x0F]
|
||||
|
||||
/**
|
||||
* Total byte length of the extension substream at [offset] (`nuBits4ExSSFsize + 1`), or 0
|
||||
* when the header does not fit in [limit].
|
||||
*
|
||||
* Header layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
|
||||
* bHeaderSizeType(1), then a header-size field of 8 or 12 bits and this size field of 16 or
|
||||
* 20 bits — at most 75 bits, so 10 bytes cover every shape.
|
||||
*/
|
||||
private fun extensionSubstreamSize(data: ByteArray, offset: Int, limit: Int): Int {
|
||||
if (offset + 10 > limit) return 0
|
||||
val base = offset shl 3
|
||||
val wide = readBits(data, base + 42, 1) == 1
|
||||
val sizeFieldPosition = base + 43 + if (wide) 12 else 8
|
||||
return readBits(data, sizeFieldPosition, if (wide) 20 else 16) + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Burst geometry learned from the stream's core framing; zero until the first unit packs. */
|
||||
private var burstBytes = 0
|
||||
|
||||
private var burstBacking = Array(2) { ByteArray(0) }
|
||||
private var burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]) }
|
||||
private var burstIndex = 0
|
||||
|
||||
/** Bytes each reusable buffer was dirtied to, so padding only clears what a prior burst wrote. */
|
||||
private val dirtyEnd = intArrayOf(0, 0)
|
||||
|
||||
/** Assembles start code + size + access unit before the byte swap; sized with the bursts. */
|
||||
private var payloadScratch = ByteArray(0)
|
||||
|
||||
/** Access units still to strip to their core after an overflow. */
|
||||
private var hdStripRemaining = 0
|
||||
|
||||
/**
|
||||
* Latched when a burst had to be stripped to the core substream, so the sink can log the
|
||||
* downgrade once. Cleared by [reset].
|
||||
*/
|
||||
var strippedToCore = false
|
||||
private set
|
||||
|
||||
override var unsupportedStream = false
|
||||
private set
|
||||
|
||||
override fun reset() {
|
||||
// Buffers and the learned burst geometry survive; only per-stream state drops. The flag is
|
||||
// re-learned from the next unit; leaving it latched would silence every later stream.
|
||||
hdStripRemaining = 0
|
||||
strippedToCore = false
|
||||
unsupportedStream = false
|
||||
}
|
||||
|
||||
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
|
||||
if (offset + 4 > limit) return 0
|
||||
when (readSyncWord(data, offset)) {
|
||||
SYNC_CORE -> Unit
|
||||
SYNC_EXSS -> {
|
||||
// A stray HD frame without its core, seen at stream starts. Its size is walkable, so it
|
||||
// is consumed as a unit and dropped by packAccessUnit; FFmpeg discards these too.
|
||||
val size = extensionSubstreamSize(data, offset, limit)
|
||||
return if (size < 4 || offset + size > limit) 0 else size
|
||||
}
|
||||
SYNC_CORE_LE, SYNC_CORE_14B_BE, SYNC_CORE_14B_LE -> {
|
||||
unsupportedStream = true
|
||||
return 0
|
||||
}
|
||||
else -> return 0
|
||||
}
|
||||
|
||||
if (offset + MIN_CORE_HEADER_LENGTH > limit) return 0
|
||||
val coreSize = coreFrameSize(data, offset)
|
||||
var end = offset + coreSize
|
||||
if (coreSize < MIN_CORE_HEADER_LENGTH || end > limit) return 0
|
||||
// Master Audio glues one or more extension substreams to the core; they belong to this unit.
|
||||
while (end + 4 <= limit && readSyncWord(data, end) == SYNC_EXSS) {
|
||||
val size = extensionSubstreamSize(data, end, limit)
|
||||
if (size < 4 || end + size > limit) return 0
|
||||
end += size
|
||||
}
|
||||
return end - offset
|
||||
}
|
||||
|
||||
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
|
||||
if (length < MIN_CORE_HEADER_LENGTH) return null
|
||||
when (readSyncWord(data, offset)) {
|
||||
SYNC_CORE -> Unit
|
||||
// The stray leading HD frame accessUnitLength admitted; there is no core to derive a
|
||||
// period from, so it is dropped rather than carried.
|
||||
SYNC_EXSS -> return null
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val blocks = coreBlocks(data, offset)
|
||||
val coreSize = coreFrameSize(data, offset)
|
||||
val sampleRate = coreSampleRate(data, offset)
|
||||
if (sampleRate == 0) {
|
||||
unsupportedStream = true
|
||||
return null
|
||||
}
|
||||
|
||||
val coreSamples = blocks shl 5
|
||||
val periodProduct = CARRIER_IEC958_RATE.toLong() * coreSamples
|
||||
val period = periodProduct / sampleRate
|
||||
val newSubtype = if (periodProduct % sampleRate != 0L) -1 else subtypeForPeriod(period)
|
||||
if (newSubtype < 0) {
|
||||
// 44.1kHz-family cores and exotic frame lengths map to no IEC 61937-11 repetition period at
|
||||
// this carrier rate; FFmpeg refuses them the same way, so the stream decodes instead.
|
||||
unsupportedStream = true
|
||||
return null
|
||||
}
|
||||
setBurstGeometry((period * 4).toInt())
|
||||
|
||||
// FFmpeg's overflow answer: a Master Audio peak the carrier cannot hold strips this and the
|
||||
// next ~60 seconds of units to the always-fitting core substream, so the receiver does not
|
||||
// flap between core and MA decoding.
|
||||
if (START_CODE.size + 2 + length > burstBytes - BURST_HEADER_SIZE) {
|
||||
hdStripRemaining = sampleRate * HD_STRIP_SECONDS / coreSamples
|
||||
}
|
||||
var payloadSize = length
|
||||
if (hdStripRemaining > 0 && coreSize <= length) {
|
||||
payloadSize = coreSize
|
||||
hdStripRemaining--
|
||||
strippedToCore = true
|
||||
}
|
||||
val payloadBytes = START_CODE.size + 2 + payloadSize
|
||||
if (payloadBytes > burstBytes - BURST_HEADER_SIZE) {
|
||||
// Even the bare core overflows this period (only possible for very short core frames);
|
||||
// nothing can be carried.
|
||||
unsupportedStream = true
|
||||
return null
|
||||
}
|
||||
|
||||
START_CODE.copyInto(payloadScratch, 0)
|
||||
payloadScratch[START_CODE.size] = (payloadSize ushr 8).toByte()
|
||||
payloadScratch[START_CODE.size + 1] = (payloadSize and 0xFF).toByte()
|
||||
data.copyInto(payloadScratch, START_CODE.size + 2, offset, offset + payloadSize)
|
||||
// A final lone byte goes out MSB-aligned; its swap partner must be zero, not stale scratch.
|
||||
if (payloadBytes and 1 == 1) payloadScratch[payloadBytes] = 0
|
||||
|
||||
burstIndex = burstIndex xor 1
|
||||
val backing = burstBacking[burstIndex]
|
||||
putLittleEndianShort(backing, 0, SYNCWORD1)
|
||||
putLittleEndianShort(backing, 2, SYNCWORD2)
|
||||
putLittleEndianShort(backing, 4, IEC61937_DTSHD or (newSubtype shl 8))
|
||||
// Aligned so (Pd & 0xF) == 0x8, which some receivers reportedly require; FFmpeg and Kodi
|
||||
// both apply the same quirk.
|
||||
putLittleEndianShort(backing, 6, ((payloadBytes + 0x17) and 0x0F.inv()) - BURST_HEADER_SIZE)
|
||||
|
||||
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
|
||||
var source = 0
|
||||
var destination = BURST_HEADER_SIZE
|
||||
val swappedPayload = payloadBytes + (payloadBytes and 1)
|
||||
while (source < swappedPayload) {
|
||||
backing[destination] = payloadScratch[source + 1]
|
||||
backing[destination + 1] = payloadScratch[source]
|
||||
source += 2
|
||||
destination += 2
|
||||
}
|
||||
if (destination < dirtyEnd[burstIndex]) {
|
||||
java.util.Arrays.fill(backing, destination, dirtyEnd[burstIndex], 0)
|
||||
}
|
||||
dirtyEnd[burstIndex] = destination
|
||||
|
||||
val burst = burstBuffers[burstIndex]
|
||||
burst.limit(burstBytes)
|
||||
burst.position(0)
|
||||
return burst
|
||||
}
|
||||
|
||||
private fun setBurstGeometry(newBurstBytes: Int) {
|
||||
if (newBurstBytes == burstBytes) return
|
||||
burstBytes = newBurstBytes
|
||||
burstBacking = Array(2) { ByteArray(newBurstBytes) }
|
||||
burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN) }
|
||||
dirtyEnd[0] = 0
|
||||
dirtyEnd[1] = 0
|
||||
payloadScratch = ByteArray(newBurstBytes)
|
||||
}
|
||||
|
||||
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
@@ -215,8 +215,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
@Volatile private var assVideoLatencyFrames = 0
|
||||
private var subtitlePositionPercent: Int = 100
|
||||
private var subtitleFontSize: Float = 55f
|
||||
private var subtitleAnchorToScreen: Boolean = false
|
||||
private var lastSubtitleCues: List<Cue> = emptyList()
|
||||
|
||||
// Retain the inferred composition-plane aspect across empty cue groups.
|
||||
private var bitmapSubtitlePlaneAspect: Float? = null
|
||||
|
||||
// Tracks whether a text track was selected on the previous onTracksChanged so we
|
||||
// can detect the transition to "no subtitle" and clear the painted overlays (#1387).
|
||||
private var hadSelectedTextTrack: Boolean = false
|
||||
@@ -275,6 +279,29 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private var resumeStallRecoveryCount = 0
|
||||
private var loggedResumeStallCap = false
|
||||
|
||||
// Buffering stall watchdog (#1790): a renderer that never becomes ready pins the player in
|
||||
// STATE_BUFFERING with a full buffer and a frozen clock, and — when media3 absorbs the
|
||||
// underlying failure instead of raising it — nothing else ever notices. Armed on every
|
||||
// transition into buffering; see BufferingStallPolicy.
|
||||
private var bufferingStallRunnable: Runnable? = null
|
||||
private var bufferingStallSinceMs = 0L
|
||||
private var bufferingStallBaselinePositionMs = 0L
|
||||
|
||||
/**
|
||||
* The live load control, so the buffering watchdog can ask whether media3 itself considers the
|
||||
* buffer sufficient to start rather than re-deriving that from durations.
|
||||
*/
|
||||
private var observingLoadControl: ObservingLoadControl? = null
|
||||
|
||||
/**
|
||||
* The read-ahead limits this session actually resolved to, kept so [getStats] can report them.
|
||||
* Both ceilings matter and the smaller one binds: with `prioritizeTimeOverSizeThresholds`
|
||||
* disabled the byte target stops the loader even below `minBufferMs`, so a raised Maximum
|
||||
* Buffer that changes nothing on a UHD remux is explained by these two numbers side by side.
|
||||
*/
|
||||
private var resolvedTargetBufferBytes: Int? = null
|
||||
private var resolvedBufferDurations: LoadControlPolicy.BufferDurations? = null
|
||||
|
||||
// Decoder hang detection: tracks gap between decoder init and first rendered frame
|
||||
private var decoderHangRunnable: Runnable? = null
|
||||
private var decoderInitName: String? = null
|
||||
@@ -287,6 +314,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private var lastAudioRecoveryReason: String? = null
|
||||
private var lastAudioSinkError: String? = null
|
||||
private var loggedEwasteEac3Workaround: Boolean = false
|
||||
private val loggedDtsAppDecoderMimes = mutableSetOf<String>()
|
||||
private var lastTrueHdDirectOutputLogKey: String? = null
|
||||
private var loggedDecodedPcmTunnelingGuard: Boolean = false
|
||||
private var hasRenderedVideoFrameForMedia: Boolean = false
|
||||
@@ -314,6 +342,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
@Volatile private var detectedFrameRate: Float = -1f
|
||||
private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT)
|
||||
|
||||
// Frame rate the media server reported for the open item, or -1 when unknown.
|
||||
// Supplied per open because the extractors media3 uses for direct play (Matroska,
|
||||
// MP4) never populate Format.frameRate, and the tunneled path renders no frames
|
||||
// back to the app for detectedFrameRate to derive one from.
|
||||
@Volatile private var contentFrameRate: Float = -1f
|
||||
|
||||
@Volatile private var fpsTimestampCount = 0
|
||||
private var assSyncFrameCount = 0L
|
||||
|
||||
@@ -323,8 +357,17 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
// Track state for event emission
|
||||
private var lastPosition: Long = 0
|
||||
|
||||
/** Position to use for fallback: max of current position and pending start position. */
|
||||
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs)
|
||||
/**
|
||||
* Highest position actually reached in the current media generation, or the last explicit seek
|
||||
* target. [lastPosition] tracks the emitted timeline and follows the player down as well as up,
|
||||
* so a renderer that reports 0 while its clock is dead would otherwise hand recovery a 0ms
|
||||
* resume point — which is how an audio recovery restarted a resumed episode from the top
|
||||
* (#1790). Recovery reads this instead.
|
||||
*/
|
||||
private var lastKnownGoodPositionMs: Long = 0L
|
||||
|
||||
/** Position to use for fallback: the furthest of the tracked positions. */
|
||||
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs, lastKnownGoodPositionMs)
|
||||
private var lastDuration: Long = 0
|
||||
private var lastBufferedPosition: Long = 0
|
||||
private var positionUpdateRunnable: Runnable? = null
|
||||
@@ -525,7 +568,11 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
bufferSizeBytes: Int? = null,
|
||||
bufferSizeAuto: Boolean = false,
|
||||
tunnelingEnabled: Boolean = true,
|
||||
audioPassthroughEnabled: Boolean = false
|
||||
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"
|
||||
): Boolean {
|
||||
if (isInitialized) {
|
||||
Log.d(TAG, "Already initialized")
|
||||
@@ -684,7 +731,9 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
val handler = AssHandler()
|
||||
assHandler = handler
|
||||
|
||||
val assParserFactory = AssSubtitleParserFactory(handler)
|
||||
// PGS goes through PgsCompositionParser; media3's parser drops all but one
|
||||
// composition object and blanks palette-only fade updates (#1953).
|
||||
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
|
||||
|
||||
// Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
|
||||
// wrap MP4 extractors with DV converter when enabled.
|
||||
@@ -696,7 +745,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
extractorsFactory.createExtractors().map { extractor ->
|
||||
when {
|
||||
extractor is MatroskaExtractor -> {
|
||||
val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler)
|
||||
val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler)
|
||||
val inner = if (doviEnabled) {
|
||||
DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
|
||||
emitLog(level, prefix, message)
|
||||
@@ -722,7 +771,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
|
||||
.setSubtitleParserFactory(assParserFactory)
|
||||
.setSubtitleParserFactory(subtitleParserFactory)
|
||||
playbackMediaSourceFactory = mediaSourceFactory
|
||||
|
||||
// Wrap text renderers with subtitle delay support
|
||||
@@ -748,20 +797,32 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
}
|
||||
|
||||
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
|
||||
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
|
||||
resolvedTargetBufferBytes = targetBufferBytes
|
||||
resolvedBufferDurations = bufferDurations
|
||||
|
||||
val loadControl = DefaultLoadControl.Builder().apply {
|
||||
setTargetBufferBytes(targetBufferBytes)
|
||||
setPrioritizeTimeOverSizeThresholds(false)
|
||||
if (availableMB <= 2048) {
|
||||
setBufferDurationsMs(15_000, 50_000, 1_000, 5_000)
|
||||
} else {
|
||||
setBufferDurationsMs(30_000, 60_000, 1_000, 5_000)
|
||||
}
|
||||
}.build()
|
||||
// Generic setter only. media3 1.9 added ...ForStreaming/...ForLocalPlayback variants and a
|
||||
// latch that stops mirroring these into the local-playback fields the moment either is
|
||||
// called, which would silently give file:// playback its own defaults.
|
||||
setBufferDurationsMs(
|
||||
bufferDurations.minBufferMs,
|
||||
bufferDurations.maxBufferMs,
|
||||
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
|
||||
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
}.build().let { ObservingLoadControl(it).also { observing -> observingLoadControl = observing } }
|
||||
emitLog(
|
||||
"info",
|
||||
"init",
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
|
||||
"heap=${largeHeapMB}MB, available=${availableMB}MB), tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
|
||||
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
|
||||
"(${resolvedTier.name.lowercase()}), " +
|
||||
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
|
||||
)
|
||||
|
||||
exoPlayer = ExoPlayer.Builder(activity)
|
||||
@@ -963,6 +1024,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
// classifies an EOF by position against duration.
|
||||
if (currentPosition != lastPosition && terminalErrorGeneration != currentMediaGeneration) {
|
||||
lastPosition = currentPosition
|
||||
if (currentPosition > lastKnownGoodPositionMs) lastKnownGoodPositionMs = currentPosition
|
||||
delegate?.onPropertyChange("time-pos", currentPosition / 1000.0)
|
||||
}
|
||||
|
||||
@@ -1004,6 +1066,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
private fun resetPlaybackProgress(startPositionMs: Long) {
|
||||
lastPosition = startPositionMs
|
||||
lastKnownGoodPositionMs = startPositionMs
|
||||
lastDuration = 0L
|
||||
lastBufferedPosition = 0L
|
||||
// Dart already seeds the visible timeline before open. Emitting native
|
||||
@@ -1053,11 +1116,31 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private fun renderSubtitleCues(cues: List<Cue>) {
|
||||
val textCues = cues.filter { it.bitmap == null }
|
||||
val bitmapCues = cues.filter { it.bitmap != null }
|
||||
updateBitmapSubtitlePlaneAspect(bitmapCues)
|
||||
val outgoing = SubtitleCueLayout.layout(textCues, subtitlePositionPercent, subtitleFontSize)
|
||||
subtitleView?.setCues(outgoing)
|
||||
bitmapSubtitleView?.setCues(bitmapCues)
|
||||
}
|
||||
|
||||
private fun updateBitmapSubtitlePlaneAspect(bitmapCues: List<Cue>) {
|
||||
val inferredAspect = bitmapCues.firstNotNullOfOrNull { cue ->
|
||||
val bitmap = cue.bitmap ?: return@firstNotNullOfOrNull null
|
||||
SubtitleViewLayout.bitmapPlaneAspect(
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
cue.size,
|
||||
cue.bitmapHeight
|
||||
)
|
||||
} ?: return
|
||||
val previousAspect = bitmapSubtitlePlaneAspect
|
||||
if (previousAspect != null && kotlin.math.abs(inferredAspect / previousAspect - 1f) <= 0.001f) return
|
||||
|
||||
bitmapSubtitlePlaneAspect = inferredAspect
|
||||
lastVideoSize?.let {
|
||||
updateSubtitleViewSize(it.width, it.height, it.pixelWidthHeightRatio)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsPlayingChanged(isPlaying: Boolean) {
|
||||
Log.d(TAG, "onIsPlayingChanged: $isPlaying")
|
||||
// A pause/resume can occur entirely between watchdog polls while the
|
||||
@@ -1083,8 +1166,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
when (state) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
delegate?.onPropertyChange("paused-for-cache", true)
|
||||
armBufferingStallWatchdog()
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
cancelBufferingStallWatchdog()
|
||||
// Restore start position if it was lost during track reselection
|
||||
// (e.g. tunneling state change in onTracksChanged triggers renderer teardown)
|
||||
if (pendingStartPositionMs > 0L) {
|
||||
@@ -1120,6 +1205,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
stopFrameWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
delegate?.onPropertyChange("eof-reached", true)
|
||||
delegate?.onEvent("end-file", mapOf("reason" to "eof"))
|
||||
}
|
||||
@@ -1170,12 +1256,13 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling the text track produces no trailing empty CueGroup, and no new
|
||||
// video frame re-renders the libass overlay while paused, so the last SRT/VTT
|
||||
// cue stays painted on the SubtitleViews and the last ASS frame stays on the
|
||||
// overlay. AssHandler (registered before this listener) has already nulled the
|
||||
// libass track by now, so re-rendering the last position clears it. Gate on the
|
||||
// transition to avoid redundant clears on every track change. (#1387)
|
||||
// Disabling the text track produces no trailing empty CueGroup, so the last
|
||||
// SRT/VTT cue stays painted on the SubtitleViews. AssHandler (registered
|
||||
// before this listener) has already nulled the libass track by now, so the
|
||||
// invalidate pushes one request through the atlas pipeline, which swaps an
|
||||
// explicit blank frame for a trackless render — required while paused, where
|
||||
// no video frame triggers one. Gate on the transition to avoid redundant
|
||||
// clears on every track change. (#1387, #1884)
|
||||
val hasSelectedText = hasSelectedTextTrack(tracks)
|
||||
if (!hasSelectedText && hadSelectedTextTrack) {
|
||||
lastSubtitleCues = emptyList()
|
||||
@@ -1206,6 +1293,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
emitSeekable(false, force = true)
|
||||
|
||||
// If native DV7 failed, retry with conversion before falling to MPV
|
||||
@@ -1346,7 +1434,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
val player = exoPlayer ?: return false
|
||||
val uri = currentMediaUri ?: return false
|
||||
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
|
||||
val savedPosition = maxOf(player.currentPosition, effectivePosition)
|
||||
val savedPlayWhenReady = player.playWhenReady
|
||||
val previousDecoder = decoderInitName
|
||||
pendingTrackRestore = pendingTrackRestore ?: captureTrackRestore()?.also { restore ->
|
||||
@@ -1368,6 +1456,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
applyTrackSelectorPolicy(
|
||||
reason = "video decoder recovery",
|
||||
forceSelector = true,
|
||||
@@ -1387,6 +1476,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
setCurrentMediaSource(player, uri, savedPosition)
|
||||
player.prepare()
|
||||
player.playWhenReady = savedPlayWhenReady
|
||||
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
|
||||
armBufferingStallWatchdog()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1413,33 +1504,51 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean {
|
||||
if (!isAudioTrackError(error.errorCode)) return false
|
||||
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
|
||||
format.sampleMimeType?.startsWith("audio/") == true
|
||||
}
|
||||
return recoverAudioOutputInPlace(
|
||||
label = PlaybackException.getErrorCodeName(error.errorCode),
|
||||
reason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}",
|
||||
fallbackFormat = errorFormat,
|
||||
blockDirectOutput = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-prepares the current media with the audio output forced onto a path that has not just
|
||||
* failed. Shared by the [PlaybackException] route and by the buffering stall watchdog, which
|
||||
* reaches the same failure without an exception ever being raised (#1790).
|
||||
*/
|
||||
private fun recoverAudioOutputInPlace(
|
||||
label: String,
|
||||
reason: String,
|
||||
fallbackFormat: Format?,
|
||||
blockDirectOutput: Boolean
|
||||
): Boolean {
|
||||
val player = exoPlayer ?: return false
|
||||
val uri = currentMediaUri ?: return false
|
||||
if (audioRecoveryAttempts >= MAX_AUDIO_RECOVERY_ATTEMPTS) {
|
||||
emitLog(
|
||||
"warn",
|
||||
"audio-recovery",
|
||||
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for ${PlaybackException.getErrorCodeName(error.errorCode)}"
|
||||
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for $label"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
val selectedFormat = selectedAudioFormat()
|
||||
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
|
||||
format.sampleMimeType?.startsWith("audio/") == true
|
||||
}
|
||||
val recoveryFormat = selectedFormat ?: errorFormat
|
||||
val recoveryFormat = selectedAudioFormat() ?: fallbackFormat
|
||||
val actions = mutableListOf<String>()
|
||||
|
||||
recoveryFormat?.sampleMimeType
|
||||
?.takeIf { isEncodedAudioMimeType(it) }
|
||||
?.let { mimeType ->
|
||||
if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
|
||||
actions.add("force-decoded-pcm($mimeType)")
|
||||
if (blockDirectOutput) {
|
||||
recoveryFormat?.sampleMimeType
|
||||
?.takeIf { isEncodedAudioMimeType(it) }
|
||||
?.let { mimeType ->
|
||||
if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
|
||||
actions.add("force-decoded-pcm($mimeType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if (!tunnelingDisabledForAudioRecovery) {
|
||||
tunnelingDisabledForAudioRecovery = true
|
||||
actions.add("disable-tunneling")
|
||||
@@ -1447,7 +1556,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
if (actions.isEmpty()) actions.add("reload")
|
||||
|
||||
audioRecoveryAttempts++
|
||||
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
|
||||
val savedPosition = maxOf(player.currentPosition, effectivePosition)
|
||||
val savedPlayWhenReady = player.playWhenReady
|
||||
val previousAudioTrackConfig = lastAudioTrackConfig
|
||||
pendingStartPositionMs = savedPosition
|
||||
@@ -1455,11 +1564,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
audioDecoderInitName = null
|
||||
lastAudioTrackConfig = null
|
||||
lastAudioRecoveryAction = actions.joinToString(",")
|
||||
lastAudioRecoveryReason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}"
|
||||
lastAudioRecoveryReason = reason
|
||||
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
applyTrackSelectorPolicy(reason = "audio recovery", forceSelector = true)
|
||||
|
||||
emitLog(
|
||||
@@ -1473,6 +1583,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
setCurrentMediaSource(player, uri, savedPosition)
|
||||
player.prepare()
|
||||
player.playWhenReady = savedPlayWhenReady
|
||||
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
|
||||
armBufferingStallWatchdog()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1673,7 +1785,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
videoHeight,
|
||||
pixelRatio,
|
||||
resizeMode,
|
||||
videoZoomScale
|
||||
videoZoomScale,
|
||||
subtitleAnchorToScreen
|
||||
)
|
||||
val bitmapDimensions = SubtitleViewLayout.bitmapDimensions(
|
||||
containerWidth,
|
||||
@@ -1681,8 +1794,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
pixelRatio,
|
||||
resizeMode,
|
||||
videoZoomScale
|
||||
bitmapSubtitlePlaneAspect
|
||||
)
|
||||
|
||||
activity.runOnUiThread {
|
||||
@@ -1877,14 +1989,15 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
subtitleTrackGroupMap[trackId] = trackGroup
|
||||
val isSelected = group.isSelected
|
||||
|
||||
// Detect external (side-loaded) subtitle by the ID prefix set in open()
|
||||
val isExternal = format.id?.startsWith("external_") == true
|
||||
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
|
||||
// Detect external (side-loaded) subtitle by the ID set in open(). media3
|
||||
// rewrites merged child ids, so the tag is not the whole id.
|
||||
val isExternal = ExternalSubtitleIds.isExternal(format.id)
|
||||
val externalIndex = ExternalSubtitleIds.indexOf(format.id)
|
||||
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
|
||||
val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
|
||||
val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null
|
||||
|
||||
Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
|
||||
Log.d(TAG, "Subtitle track $groupIndex: formatId=${format.id}, codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
|
||||
|
||||
val track = mutableMapOf<String, Any?>(
|
||||
"type" to "sub",
|
||||
@@ -2206,7 +2319,20 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// DTS that is going to decode must not decode in a platform codec: on license-gated
|
||||
// Amlogic boxes (the Onn family) the platform decoder drains normally while rendering
|
||||
// silence (#1995). Bitstream-capable routes are untouched — media3 selects direct output
|
||||
// before consulting the decoder list, and the visible platform decoder keeps the
|
||||
// tunneling gate as it was.
|
||||
val forceDts = shouldForceFfmpegDtsDecode(
|
||||
mimeType,
|
||||
directOutputBlocked = { shouldBlockDirectAudioOutput(dtsProbeFormat(mimeType), "decoder selection") },
|
||||
routeCanBitstreamDts = { routeCanBitstreamDts(mimeType) }
|
||||
)
|
||||
if (forceDts && loggedDtsAppDecoderMimes.add(mimeType)) {
|
||||
emitLog("info", "decoder", "Using app decoder for $mimeType; the stream will decode and platform DTS decoders render silence on license-gated devices")
|
||||
}
|
||||
return forceDts
|
||||
}
|
||||
|
||||
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
|
||||
@@ -2315,6 +2441,33 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current route can bitstream [mimeType] at all: media3's raw direct path
|
||||
* ([AudioCapabilities]) or, for DTS-HD, the IEC 61937 carrier ([IecCarrierSink]). When this
|
||||
* is false the stream decodes regardless of the passthrough setting.
|
||||
*/
|
||||
private fun routeCanBitstreamDts(mimeType: String): Boolean {
|
||||
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
|
||||
val audioAttributes = buildMovieAudioAttributes()
|
||||
return try {
|
||||
AudioCapabilities
|
||||
.getCapabilities(activity, audioAttributes, null)
|
||||
.isPassthroughPlaybackSupported(dtsProbeFormat(mimeType), audioAttributes)
|
||||
} catch (e: Exception) {
|
||||
// An unanswerable probe biases toward FFmpeg decode. A wrong "can't bitstream" is benign
|
||||
// (bypass still wins before decoder selection); a wrong "can" leaves the silent platform
|
||||
// decode path reachable.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** DTS selection probe at the family's common shape; decoder selection only knows the mime. */
|
||||
private fun dtsProbeFormat(mimeType: String): Format = Format.Builder()
|
||||
.setSampleMimeType(mimeType)
|
||||
.setChannelCount(6)
|
||||
.setSampleRate(48_000)
|
||||
.build()
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isDirectPlaybackSupportedApi29(
|
||||
@@ -2536,6 +2689,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
val player = exoPlayer ?: return null
|
||||
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
|
||||
return tunnelingUserEnabled &&
|
||||
!DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate) &&
|
||||
(player.playbackParameters.speed == 1f) &&
|
||||
!tunnelingDisabledForCodec &&
|
||||
!tunnelingDisabledForAssSubtitles &&
|
||||
@@ -2631,15 +2785,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
return selectedAudioGroup.mediaTrackGroup.getFormat(0)
|
||||
}
|
||||
|
||||
private fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
AudioFormat.ENCODING_PCM_8BIT,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
AudioFormat.ENCODING_PCM_FLOAT,
|
||||
AudioFormat.ENCODING_PCM_24BIT_PACKED,
|
||||
AudioFormat.ENCODING_PCM_32BIT -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun formatAudioSummary(format: Format): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("mime=${format.sampleMimeType ?: "unknown"}")
|
||||
@@ -3123,6 +3268,114 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
}
|
||||
|
||||
// Buffering stall watchdog (#1790) — see BufferingStallPolicy for the coverage argument.
|
||||
// Main-thread only, like the watchdogs above.
|
||||
|
||||
private fun armBufferingStallWatchdog() {
|
||||
cancelBufferingStallWatchdog()
|
||||
val player = exoPlayer ?: return
|
||||
bufferingStallSinceMs = System.currentTimeMillis()
|
||||
bufferingStallBaselinePositionMs = player.currentPosition
|
||||
// Fresh window: do not judge it on a verdict media3 gave for the previous one.
|
||||
observingLoadControl?.reset()
|
||||
val mediaGeneration = currentMediaGeneration
|
||||
bufferingStallRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (mediaGeneration != currentMediaGeneration) return
|
||||
if (disposing || !isInitialized) return
|
||||
val current = exoPlayer ?: return
|
||||
if (current.playbackState != Player.STATE_BUFFERING) {
|
||||
cancelBufferingStallWatchdog()
|
||||
return
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// Paused mid-buffer: nothing is meant to progress, so the clock does not run.
|
||||
if (!current.playWhenReady) {
|
||||
bufferingStallSinceMs = now
|
||||
bufferingStallBaselinePositionMs = current.currentPosition
|
||||
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
|
||||
return
|
||||
}
|
||||
|
||||
val elapsedMs = now - bufferingStallSinceMs
|
||||
val verdict = BufferingStallPolicy.evaluate(
|
||||
elapsedMs = elapsedMs,
|
||||
baselinePositionMs = bufferingStallBaselinePositionMs,
|
||||
currentPositionMs = current.currentPosition,
|
||||
bufferedPositionMs = current.bufferedPosition,
|
||||
// The load control's bar is in playout time, so a fast-forward legitimately needs
|
||||
// proportionally more media. Only used when media3 has not answered for itself yet.
|
||||
playbackSpeed = current.playbackParameters.speed,
|
||||
// media3's own verdict, which also covers the byte-target release no duration comparison
|
||||
// can express — but it is only asked once the renderers are ready.
|
||||
loadControlReady = observingLoadControl?.startPlaybackVerdict,
|
||||
// A loader that has stopped asking for data has all it wants, whatever the duration says.
|
||||
// This is the signal that survives a renderer which never becomes ready.
|
||||
loading = current.isLoading
|
||||
)
|
||||
if (verdict == BufferingStallPolicy.Verdict.STALLED) {
|
||||
cancelBufferingStallWatchdog()
|
||||
recoverFromBufferingStall(current, mediaGeneration, elapsedMs)
|
||||
return
|
||||
}
|
||||
// Moving again, or still short of the load control's play-start threshold: either way the
|
||||
// clock was not measuring a stall this watchdog owns, so it restarts.
|
||||
if (BufferingStallPolicy.resetsStallClock(verdict)) {
|
||||
bufferingStallSinceMs = now
|
||||
bufferingStallBaselinePositionMs = current.currentPosition
|
||||
}
|
||||
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(bufferingStallRunnable!!, BufferingStallPolicy.CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun cancelBufferingStallWatchdog() {
|
||||
bufferingStallRunnable?.let { handler.removeCallbacks(it) }
|
||||
bufferingStallRunnable = null
|
||||
}
|
||||
|
||||
private fun recoverFromBufferingStall(player: ExoPlayer, mediaGeneration: Int, stalledMs: Long) {
|
||||
val positionMs = player.currentPosition
|
||||
val playWhenReady = player.playWhenReady
|
||||
val sinkError = lastAudioSinkError
|
||||
emitLog(
|
||||
"warn",
|
||||
"buffering-stall",
|
||||
"No progress for ${stalledMs}ms at ${positionMs}ms while buffering " +
|
||||
"(buffered=${player.bufferedPosition}ms, audio=${selectedAudioFormat()?.let { formatAudioSummary(it) } ?: "unknown"}, " +
|
||||
"tunneling=$currentTunneledPlayback, lastSinkError=${sinkError ?: "none"})"
|
||||
)
|
||||
|
||||
// A sink error with no exception behind it is the shape media3 absorbs, so take the audio
|
||||
// path off bitstream. Without one, the reload alone is the unstick.
|
||||
if (recoverAudioOutputInPlace(
|
||||
label = "buffering stall",
|
||||
reason = "buffering stall after ${stalledMs}ms: ${sinkError ?: "no sink error reported"}",
|
||||
fallbackFormat = null,
|
||||
blockDirectOutput = sinkError != null
|
||||
)
|
||||
) {
|
||||
// recoverAudioOutputInPlace re-arms the watchdog itself, so the retry is watched and can
|
||||
// escalate to the handover below.
|
||||
return
|
||||
}
|
||||
|
||||
// In-place recovery is spent. Hand over to the MPV backend, which has its own audio path,
|
||||
// rather than leaving the user on a spinner.
|
||||
val uri = currentMediaUri ?: return
|
||||
requestFormatFallback(
|
||||
mediaGeneration = mediaGeneration,
|
||||
uri = uri,
|
||||
// Not the raw position: a failed sink can report 0, which would restart a resumed episode from
|
||||
// the beginning. Every other fallback path hands over the tracked position for the same reason.
|
||||
positionMs = maxOf(positionMs, effectivePosition),
|
||||
playWhenReady = playWhenReady,
|
||||
errorMessage = "Playback stalled while buffering for ${stalledMs}ms"
|
||||
)
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
fun open(
|
||||
@@ -3132,18 +3385,21 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
autoPlay: Boolean,
|
||||
mediaGeneration: Int,
|
||||
isLive: Boolean = false,
|
||||
externalSubtitleList: List<Map<String, Any?>>? = null
|
||||
externalSubtitleList: List<Map<String, Any?>>? = null,
|
||||
contentFrameRate: Float = -1f
|
||||
) {
|
||||
if (!isInitialized) return
|
||||
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
resumeStallRecoveryCount = 0
|
||||
loggedResumeStallCap = false
|
||||
|
||||
// Reset FPS detection for new content
|
||||
detectedFrameRate = -1f
|
||||
this.contentFrameRate = contentFrameRate
|
||||
fpsTimestampCount = 0
|
||||
assSyncFrameCount = 0
|
||||
|
||||
@@ -3191,6 +3447,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
externalSubtitleUris.clear()
|
||||
externalSubtitleContainerUris.clear()
|
||||
lastSubtitleCues = emptyList()
|
||||
bitmapSubtitlePlaneAspect = null
|
||||
hadSelectedTextTrack = false
|
||||
audioTrackGroupMap.clear()
|
||||
subtitleTrackGroupMap.clear()
|
||||
@@ -3222,7 +3479,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
|
||||
(if (isForced) C.SELECTION_FLAG_FORCED else 0)
|
||||
val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
|
||||
.setId("external_$index")
|
||||
.setId(ExternalSubtitleIds.idFor(index))
|
||||
.setLabel(title ?: "External")
|
||||
.setLanguage(language)
|
||||
.setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
|
||||
@@ -3254,14 +3511,30 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
)
|
||||
emitSeekable(false, force = true)
|
||||
|
||||
// Only here: this is the one caller that is a genuinely new item. The recovery, DV-mode and
|
||||
// subtitle reloads all reuse setCurrentMediaSource for the *same* stream, and clearing
|
||||
// per-stream audio decisions there would undo them and loop.
|
||||
renderersFactory?.beginMediaItem()
|
||||
|
||||
exoPlayer?.apply {
|
||||
setCurrentMediaSource(this, uri, startPositionMs)
|
||||
prepare()
|
||||
playWhenReady = autoPlay
|
||||
// Same reason as the recovery reloads: replacing the source while the previous item was
|
||||
// already buffering produces no state change, so nothing would arm the watchdog for this
|
||||
// generation. The runnable cancels itself as soon as the player is not buffering.
|
||||
armBufferingStallWatchdog()
|
||||
}
|
||||
|
||||
val sourceLabel = if (isLive) "live HLS" else "media"
|
||||
emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
|
||||
emitLog(
|
||||
"info",
|
||||
"media",
|
||||
"Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, " +
|
||||
"contentFps=${if (contentFrameRate > 0f) contentFrameRate.toString() else "unknown"}, " +
|
||||
"sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled, " +
|
||||
"tunnelingStatus=${exoPlayer?.let(::getTunnelingStatus) ?: "n/a"}"
|
||||
)
|
||||
}
|
||||
|
||||
fun setAudioDelay(seconds: Double) {
|
||||
@@ -3450,7 +3723,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
val uri = currentMediaUri ?: return false
|
||||
if (currentMediaIsLive) return false
|
||||
|
||||
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
|
||||
val savedPosition = maxOf(player.currentPosition, effectivePosition)
|
||||
val savedPlayWhenReady = player.playWhenReady
|
||||
pendingTrackRestore = captureTrackRestore()?.also { restore ->
|
||||
emitLog(
|
||||
@@ -3478,6 +3751,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
|
||||
applyTrackSelectorPolicy(
|
||||
reason = "DV reload",
|
||||
@@ -3489,6 +3763,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
setCurrentMediaSource(player, uri, savedPosition)
|
||||
player.prepare()
|
||||
player.playWhenReady = savedPlayWhenReady
|
||||
// Replacing the source while the player is already buffering produces no state change, and that
|
||||
// change is the only thing that arms the stall watchdog — so arm it here or a reload that never
|
||||
// becomes ready has nothing left watching it.
|
||||
armBufferingStallWatchdog()
|
||||
emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
|
||||
return true
|
||||
}
|
||||
@@ -3507,6 +3785,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
exoPlayer?.stop()
|
||||
emitSeekable(false, force = true)
|
||||
setVisible(false)
|
||||
@@ -3526,6 +3805,18 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
pendingStartPositionMs = 0L
|
||||
player.seekTo(clampedPositionMs)
|
||||
lastPosition = clampedPositionMs
|
||||
lastKnownGoodPositionMs = clampedPositionMs
|
||||
// A seek discards whatever the stall watchdog was measuring: it compares against a baseline
|
||||
// position, and the policy reads a lower position as stalled, so a backward seek during a
|
||||
// legitimate buffered wait would inherit the pre-seek stall time and trip recovery on the next
|
||||
// poll. Re-baseline here so the new position gets the full timeout.
|
||||
if (bufferingStallRunnable != null) {
|
||||
bufferingStallSinceMs = System.currentTimeMillis()
|
||||
bufferingStallBaselinePositionMs = clampedPositionMs
|
||||
// The load control's recorded verdict belongs to the buffer this seek just discarded, and the
|
||||
// policy prefers it over the live loader state, so a stale answer would decide the new window.
|
||||
observingLoadControl?.reset()
|
||||
}
|
||||
delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
|
||||
}
|
||||
|
||||
@@ -3585,7 +3876,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
val existingIndex = externalSubtitleUris.indexOf(uri)
|
||||
val isNew = existingIndex < 0
|
||||
val index = if (isNew) externalSubtitles.size else existingIndex
|
||||
val formatId = "external_$index"
|
||||
val formatId = ExternalSubtitleIds.idFor(index)
|
||||
|
||||
if (isNew) {
|
||||
// SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
|
||||
@@ -3631,10 +3922,14 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
setCurrentMediaSource(player, mediaUri, savedPosition)
|
||||
player.prepare()
|
||||
player.playWhenReady = savedPlayWhenReady
|
||||
// Same-state source replacement: media3 reports no change when the player was already
|
||||
// buffering, so arm here or this window keeps the previous one's watchdog and verdict.
|
||||
armBufferingStallWatchdog()
|
||||
} else {
|
||||
// Already attached — select the existing track via override.
|
||||
// Already attached — select the existing track via override. The
|
||||
// reported id carries media3's merge prefixes, so compare the tag.
|
||||
val trackId = subtitleTrackGroupMap.entries
|
||||
.firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
|
||||
.firstOrNull { (_, group) -> ExternalSubtitleIds.indexOf(group.getFormat(0).id) == index }
|
||||
?.key
|
||||
if (trackId != null) {
|
||||
selectSubtitleTrack(trackId)
|
||||
@@ -3685,7 +3980,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
bgOpacity: Int,
|
||||
subtitlePosition: Int = 100,
|
||||
bold: Boolean = false,
|
||||
italic: Boolean = false
|
||||
italic: Boolean = false,
|
||||
anchorToScreen: Boolean = false
|
||||
) {
|
||||
activity.runOnUiThread {
|
||||
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
|
||||
@@ -3733,6 +4029,18 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
subtitlePositionPercent = clampedPosition
|
||||
subtitleFontSize = fontSize
|
||||
|
||||
// Anchor-to-screen (#1730): resize the text SubtitleView to the full
|
||||
// container so default-placed cues land in the letterbox bars.
|
||||
val anchorChanged = subtitleAnchorToScreen != anchorToScreen
|
||||
subtitleAnchorToScreen = anchorToScreen
|
||||
if (anchorChanged) {
|
||||
lastVideoSize?.let { vs ->
|
||||
if (vs.width > 0 && vs.height > 0) {
|
||||
updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cue-level positioning handles default VTT/SRT placement, whose line
|
||||
// numbers bypass SubtitleView bottom padding. Authored VTT line positions
|
||||
// are preserved in applySubtitlePosition().
|
||||
@@ -3754,7 +4062,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
Log.w(TAG, "Failed to set ASS font scale: ${e.message}")
|
||||
}
|
||||
|
||||
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale")
|
||||
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, anchorToScreen=$anchorToScreen, assScale=$scale")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3926,6 +4234,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
// Buffer metrics
|
||||
"bufferedPositionMs" to player.bufferedPosition,
|
||||
"currentPositionMs" to player.currentPosition,
|
||||
// Both read-ahead ceilings. The smaller binds, so a Maximum Buffer that appears to do
|
||||
// nothing on a high bitrate file is explained by the byte target sitting beside it.
|
||||
"bufferTargetBytes" to resolvedTargetBufferBytes,
|
||||
"bufferMaxMs" to resolvedBufferDurations?.maxBufferMs,
|
||||
"totalBufferedDurationMs" to player.totalBufferedDuration,
|
||||
// Playback state
|
||||
"playbackSpeed" to player.playbackParameters.speed,
|
||||
@@ -3971,6 +4283,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private fun getTunnelingStatus(player: ExoPlayer): String {
|
||||
if (currentTunneledPlayback) return "Active"
|
||||
if (!tunnelingUserEnabled) return "Disabled by user"
|
||||
if (DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate)) return "Off (unreliable on this device)"
|
||||
if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)"
|
||||
if (audioNormalizationEnabled) return "Off (loudness normalization)"
|
||||
if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)"
|
||||
@@ -4010,6 +4323,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
cancelBufferingStallWatchdog()
|
||||
stopPositionUpdates()
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
// releasePending (not clearVideoFrameRate): on the ExoPlayer→MPV fallback
|
||||
|
||||
@@ -61,9 +61,16 @@ class ExoPlayerPlugin :
|
||||
val headers: Map<String, String>?,
|
||||
val startPositionMs: Long,
|
||||
val hasStartPosition: Boolean,
|
||||
val autoPlay: Boolean,
|
||||
/**
|
||||
* Whether to start playing once open. Mutable because a play or pause arriving while this
|
||||
* request is queued behind a backend handover has no player to reach — the outgoing core is
|
||||
* being disposed and the replacement does not exist yet — so the latest intent is recorded here
|
||||
* instead. Without that, a pause the caller was told succeeded is silently undone by the open.
|
||||
*/
|
||||
var autoPlay: Boolean,
|
||||
val isLive: Boolean,
|
||||
val externalSubtitles: List<Map<String, Any?>>?,
|
||||
val contentFrameRate: Float,
|
||||
private val result: MethodChannel.Result?
|
||||
) {
|
||||
private val completed = AtomicBoolean(false)
|
||||
@@ -294,6 +301,9 @@ class ExoPlayerPlugin :
|
||||
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
|
||||
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
|
||||
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
|
||||
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
|
||||
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
|
||||
configuredBufferSizeBytes = bufferSizeBytes
|
||||
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
|
||||
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
|
||||
@@ -319,6 +329,14 @@ class ExoPlayerPlugin :
|
||||
fallbackInProgress = false
|
||||
}
|
||||
|
||||
// An initialize without an intervening dispose would otherwise orphan the previous core
|
||||
// along with its ExoPlayer, audio sink, codecs and surface views — nothing else holds a
|
||||
// reference, so they would never be released.
|
||||
playerCore?.let { stale ->
|
||||
playerCore = null
|
||||
stale.dispose()
|
||||
}
|
||||
|
||||
try {
|
||||
val core = ExoPlayerCore(currentActivity).apply {
|
||||
delegate = this@ExoPlayerPlugin
|
||||
@@ -329,7 +347,8 @@ class ExoPlayerPlugin :
|
||||
bufferSizeBytes = bufferSizeBytes,
|
||||
bufferSizeAuto = bufferSizeAuto,
|
||||
tunnelingEnabled = tunnelingEnabled,
|
||||
audioPassthroughEnabled = audioPassthroughEnabled
|
||||
audioPassthroughEnabled = audioPassthroughEnabled,
|
||||
bufferTier = bufferTier
|
||||
)
|
||||
if (!success) {
|
||||
if (playerCore === core) playerCore = null
|
||||
@@ -371,6 +390,8 @@ class ExoPlayerPlugin :
|
||||
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
|
||||
val isLive = call.argument<Boolean>("isLive") ?: false
|
||||
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
|
||||
// Server-reported frame rate for this item; -1 when the metadata did not carry one.
|
||||
val contentFrameRate = call.argument<Number>("contentFrameRate")?.toFloat() ?: -1f
|
||||
|
||||
if (uri == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'uri'", null)
|
||||
@@ -391,6 +412,7 @@ class ExoPlayerPlugin :
|
||||
autoPlay = autoPlay,
|
||||
isLive = isLive,
|
||||
externalSubtitles = externalSubtitles?.map { it.toMap() },
|
||||
contentFrameRate = contentFrameRate,
|
||||
result = result
|
||||
)
|
||||
terminalEventGeneration = null
|
||||
@@ -450,7 +472,8 @@ class ExoPlayerPlugin :
|
||||
autoPlay = autoPlay,
|
||||
mediaGeneration = request.mediaGeneration,
|
||||
isLive = isLive,
|
||||
externalSubtitleList = request.externalSubtitles
|
||||
externalSubtitleList = request.externalSubtitles,
|
||||
contentFrameRate = request.contentFrameRate
|
||||
)
|
||||
request.success()
|
||||
}
|
||||
@@ -811,6 +834,9 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
|
||||
private fun handlePlay(result: MethodChannel.Result) {
|
||||
// A backend handover leaves nothing to command: record the intent on the queued open instead,
|
||||
// so the replacement starts the way the caller last asked.
|
||||
pendingOpen?.autoPlay = true
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("pause", "no", result)
|
||||
return
|
||||
@@ -822,6 +848,10 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
|
||||
private fun handlePause(result: MethodChannel.Result) {
|
||||
// See handlePlay. This direction matters more: the caller is told the pause succeeded, and a
|
||||
// queued open that still carried autoPlay would start playing anyway — on a car, that is
|
||||
// playback after the vehicle said no.
|
||||
pendingOpen?.autoPlay = false
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("pause", "yes", result)
|
||||
return
|
||||
@@ -1039,6 +1069,7 @@ class ExoPlayerPlugin :
|
||||
val subtitlePosition = call.argument<Number>("subtitlePosition")?.toInt() ?: 100
|
||||
val bold = call.argument<Boolean>("bold") ?: false
|
||||
val italic = call.argument<Boolean>("italic") ?: false
|
||||
val anchorToScreen = call.argument<Boolean>("anchorToScreen") ?: false
|
||||
|
||||
if (usingMpvFallback) {
|
||||
// MPV fallback handles styling via setProperty, no-op here
|
||||
@@ -1046,7 +1077,7 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic)
|
||||
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic, anchorToScreen)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
@@ -1281,8 +1312,10 @@ class ExoPlayerPlugin :
|
||||
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")
|
||||
core.setProperty("vo", "gpu")
|
||||
core.setProperty("ao", "audiotrack")
|
||||
|
||||
if (bufferSize != null && bufferSize > 0) {
|
||||
@@ -1386,6 +1419,8 @@ class ExoPlayerPlugin :
|
||||
autoPlay = playWhenReady,
|
||||
isLive = false,
|
||||
externalSubtitles = currentExternalSubtitles?.map { it.toMap() },
|
||||
// The mpv fallback core paces frames itself; the rate only gates ExoPlayer tunneling.
|
||||
contentFrameRate = -1f,
|
||||
result = null
|
||||
)
|
||||
fallbackInProgress = true
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Identity of a side-loaded subtitle across media3's media-source merging.
|
||||
*
|
||||
* Each `MediaItem.SubtitleConfiguration` is tagged with [idFor], and the tag is
|
||||
* read back off the `Format` the track selector reports. The tag does not
|
||||
* survive verbatim: `DefaultMediaSourceFactory.createMediaSource` always wraps
|
||||
* the primary source plus one source per subtitle configuration in a
|
||||
* `MergingMediaSource`, and since media3 1.3.0 `MergingMediaPeriod.onPrepared`
|
||||
* rewrites every child format id to `"<periodIndex>:<originalId>"`. A second
|
||||
* merge — the one this player builds for container sidecars — prefixes it
|
||||
* again. So the same configuration can surface as `external_0`,
|
||||
* `1:external_0`, or `0:1:external_0`.
|
||||
*
|
||||
* Only the final `:`-separated segment carries the tag, which is why matching
|
||||
* on the whole id silently classifies every sidecar as an embedded track.
|
||||
*/
|
||||
internal object ExternalSubtitleIds {
|
||||
private const val PREFIX = "external_"
|
||||
|
||||
/** Tag written onto the [index]th side-loaded subtitle configuration. */
|
||||
fun idFor(index: Int): String = "$PREFIX$index"
|
||||
|
||||
/** Whether [formatId] identifies a side-loaded subtitle. */
|
||||
fun isExternal(formatId: String?): Boolean = tagOf(formatId) != null
|
||||
|
||||
/**
|
||||
* Index passed to [idFor], or null when [formatId] is not a side-loaded
|
||||
* subtitle or carries a tag this build did not write.
|
||||
*/
|
||||
fun indexOf(formatId: String?): Int? = tagOf(formatId)?.toIntOrNull()
|
||||
|
||||
private fun tagOf(formatId: String?): String? {
|
||||
// substringAfterLast returns the whole string when no ':' is present, which
|
||||
// is the unmerged case.
|
||||
val segment = formatId?.substringAfterLast(':') ?: return null
|
||||
return if (segment.startsWith(PREFIX)) segment.removePrefix(PREFIX) else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* The IEC 61937 carrier tuple every packed bitstream rides: 192kHz, 7.1, PCM-16 shaped.
|
||||
*
|
||||
* This is the high-bitrate HDMI shape Kodi uses for both TrueHD/MAT and DTS-HD Master Audio, and
|
||||
* the one [supportsIecCarrier] probes. The 44.1kHz family would need a 176.4kHz sibling, which is
|
||||
* deliberately not built; streams from that family decode instead.
|
||||
*/
|
||||
internal object IecCarrier {
|
||||
const val SAMPLE_RATE = 192_000
|
||||
const val CHANNEL_COUNT = 8
|
||||
const val BYTES_PER_FRAME = CHANNEL_COUNT * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one codec's bitstream into access units and packs them into IEC 61937 bursts for
|
||||
* [IecCarrierSink].
|
||||
*
|
||||
* Implementations are not thread safe; the sink drives them from the playback thread only.
|
||||
*/
|
||||
internal interface IecCarrierPacker {
|
||||
|
||||
/**
|
||||
* Length in bytes of the access unit starting at [offset], or 0 when no unit boundary is
|
||||
* recognised there. May latch [unsupportedStream] when the boundary is recognisable but names a
|
||||
* framing this carrier cannot ride.
|
||||
*/
|
||||
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int
|
||||
|
||||
/**
|
||||
* Packs one access unit, returning a completed burst or null when this unit did not finish one.
|
||||
*
|
||||
* Returned buffers are owned by the packer and reused, alternating between two so a burst handed
|
||||
* downstream stays valid while the next one fills. Callers must submit or copy it before the
|
||||
* second following call.
|
||||
*/
|
||||
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer?
|
||||
|
||||
/**
|
||||
* True when the bitstream announced a shape this carrier cannot ride. The packer emits nothing
|
||||
* in that state; the sink latches the stream onto the decoder instead.
|
||||
*/
|
||||
val unsupportedStream: Boolean
|
||||
|
||||
/** Drops all carrier state. Called on flush/seek: bursts must not straddle a discontinuity. */
|
||||
fun reset()
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.media.AudioDeviceInfo
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.AuxEffectInfo
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.util.Clock
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Routes Dolby TrueHD (#1804) and DTS-HD Master Audio (#1988) through an IEC 61937 carrier, and
|
||||
* everything else through the normal sink.
|
||||
*
|
||||
* Android will not bitstream raw TrueHD on the TV routes measured for #1804: the platform reports
|
||||
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
|
||||
* bitstream-capable. Raw `ENCODING_DTS_HD` is worse on Fire OS: the route advertises it, the
|
||||
* AudioTrack initialises and drains, and the receiver hears silence, because the encoding only
|
||||
* means "basic profile" there (#1988). Kodi models the same split and packs the carrier itself;
|
||||
* media3 only ever hands Android the raw encodings, at the stream rate. This sink adds the
|
||||
* missing path, with one [IecCarrierPacker] per codec.
|
||||
*
|
||||
* **Why two delegates rather than one sink with the processors held inactive.** The carrier is a
|
||||
* bit-exact byte stream that happens to be shaped like PCM. Any sample mutation — downmix, Sonic,
|
||||
* silence skipping, float conversion — turns it into full-scale noise at the receiver. Keeping the
|
||||
* carrier on a delegate built with an empty [AudioProcessorChain] makes that impossible by
|
||||
* construction. The alternative, asserting the normal chain stays inactive, puts the guarantee in a
|
||||
* different class from the thing it protects, so adding a processor later would silently break
|
||||
* playback in the loudest possible way.
|
||||
*
|
||||
* [carrierSink] must therefore be built with no audio processors and with an output provider whose
|
||||
* `AudioTrack` is forced to `ENCODING_IEC61937`; see [PlezyRenderersFactory].
|
||||
*
|
||||
* Selection happens only in [configure]. Persistent controls and the listener are mirrored to both
|
||||
* delegates so either can be activated later; per-stream calls go to the active one alone.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class IecCarrierSink(
|
||||
private val defaultSink: AudioSink,
|
||||
private val carrierSink: AudioSink,
|
||||
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
|
||||
private val carrierRouteAvailable: () -> Boolean,
|
||||
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
|
||||
private val directOutputBlocked: (Format) -> Boolean,
|
||||
private val log: ((String, String, String) -> Unit)? = null
|
||||
) : AudioSink {
|
||||
|
||||
private val matPacker = TrueHdMatPacker()
|
||||
private val dtsHdPacker = DtsHdIecPacker()
|
||||
|
||||
/** The packer for the configured stream; chosen by mime type in [configure]. */
|
||||
private var activePacker: IecCarrierPacker = matPacker
|
||||
|
||||
private var active: AudioSink = defaultSink
|
||||
private var carrierActive = false
|
||||
private var loggedCoreStrip = false
|
||||
|
||||
/** A burst the delegate refused; it must be placed before any further input is consumed. */
|
||||
private var pendingBurst: ByteBuffer? = null
|
||||
private var pendingBurstTimeUs: Long = 0
|
||||
|
||||
/**
|
||||
* Anchor for carrier timestamps, and how many carrier frames have been emitted since it.
|
||||
*
|
||||
* Every burst is a whole number of carrier frames, so timestamps are derived from the cadence
|
||||
* rather than from whichever access unit happened to close the burst. Handing the sink the
|
||||
* closing unit's own presentation time drifts against the time it derives from written frames,
|
||||
* which it reports as a discontinuity on nearly every burst. Counting frames rather than bursts
|
||||
* keeps the arithmetic exact for burst durations that are not whole microseconds (a DTS-HD
|
||||
* burst is 10666.67us).
|
||||
*/
|
||||
private var carrierAnchorUs: Long = C.TIME_UNSET
|
||||
private var carrierFramesSinceAnchor: Long = 0
|
||||
|
||||
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var sinkListener: AudioSink.Listener? = null
|
||||
|
||||
/**
|
||||
* Latched when a stream's bitstream contradicts the shape its container announced, which
|
||||
* selection was made from.
|
||||
*
|
||||
* Deliberately outlives [flush] and [reset]: media3 resets every renderer disabled by a new
|
||||
* selection before enabling the replacement (ExoPlayerImplInternal.enableRenderers), and both
|
||||
* audio renderers share this sink, so the outgoing renderer's reset lands here in the middle of
|
||||
* the very handover this latch exists to cause. Clearing it there would re-offer the carrier and
|
||||
* loop straight back into the mismatch.
|
||||
*
|
||||
* The real boundary is a new media item, which only the caller knows and which happens before
|
||||
* selection asks anything. [beginMediaItem] is that hook; without it one malformed item would
|
||||
* cost bitstreaming for every later item sharing the player.
|
||||
*
|
||||
* It is held as a generation rather than a flag because that hook is called on the app thread
|
||||
* while the mismatch is discovered on the playback thread. A late buffer from the outgoing stream
|
||||
* can land after the new item began; latching the generation the carrier was configured for makes
|
||||
* that write name the stream it belongs to instead of poisoning its successor.
|
||||
*/
|
||||
private val mediaGeneration = AtomicInteger(0)
|
||||
|
||||
/** Generation [mediaGeneration] held when the carrier was last configured; playback thread. */
|
||||
private var configuredGeneration = 0
|
||||
|
||||
@Volatile
|
||||
private var mismatchGeneration = -1
|
||||
|
||||
/**
|
||||
* True when this format should ride the carrier.
|
||||
*
|
||||
* Speed changes are excluded deliberately: a bitstream cannot be resampled, so anything other
|
||||
* than 1.0x has to decode. The same is true of the existing downmix/normalization blocks, which
|
||||
* [directOutputBlocked] already reports.
|
||||
*
|
||||
* The rate family is decided from the format rather than from the packer, which only learns it
|
||||
* from the bitstream once buffers are already flowing — far too late for a selection that
|
||||
* happens before configure.
|
||||
*/
|
||||
private fun shouldUseCarrier(format: Format): Boolean {
|
||||
if (!isTrueHd(format) && !isDtsHd(format)) return false
|
||||
if (mismatchGeneration == mediaGeneration.get()) return false
|
||||
if (!isCarrierRateFamily(format.sampleRate)) return false
|
||||
if (playbackParameters.speed != 1f) return false
|
||||
if (directOutputBlocked(format)) return false
|
||||
return carrierRouteAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* The 48kHz family rides the 192kHz carrier this path builds. The 44.1kHz family needs a 176.4kHz
|
||||
* carrier, which would be a different AudioTrack tuple throughout, so it decodes instead.
|
||||
*/
|
||||
private fun isCarrierRateFamily(sampleRate: Int): Boolean = sampleRate == 48_000 || sampleRate == 96_000 || sampleRate == 192_000
|
||||
|
||||
/** The PCM-shaped format the carrier delegate is configured with. */
|
||||
private fun carrierFormat(): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setPcmEncoding(C.ENCODING_PCM_16BIT)
|
||||
.setChannelCount(IecCarrier.CHANNEL_COUNT)
|
||||
.setSampleRate(IecCarrier.SAMPLE_RATE)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* TrueHD is deliberately binary: the carrier, or decoded PCM. Never media3's own raw TrueHD path.
|
||||
*
|
||||
* That path builds an `ENCODING_DOLBY_TRUEHD` track at the *stream* rate, which is the
|
||||
* configuration #1804 is about — one box takes a single write and never advances its
|
||||
* playback head, another freezes for ten seconds, and the third declines it and decodes anyway.
|
||||
* Even Kodi's raw fallback is a different thing: it only offers raw TrueHD after verifying it at
|
||||
* 192kHz, which media3 never requests. So when the carrier is unavailable — no IEC route, a speed
|
||||
* change, downmix, normalization, or a 44.1kHz-family stream — report unsupported and let the
|
||||
* bundled FFmpeg decoder take it, exactly as the existing decoded-PCM fallback does.
|
||||
*/
|
||||
private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD
|
||||
|
||||
/**
|
||||
* DTS-HD is binary only while the carrier route exists: the carrier, or decoded PCM. Falling
|
||||
* through to media3's raw `ENCODING_DTS_HD` path on such a route would land on exactly the
|
||||
* silent configuration #1988 is about — the route that advertises the carrier and raw DTS-HD at
|
||||
* once is the one whose raw path renders silence. Without a carrier route the raw path is the
|
||||
* pre-carrier behavior and is left alone: those routes never had the carrier to lose, and some
|
||||
* of them bitstream raw DTS-HD genuinely.
|
||||
*
|
||||
* DTS Express deliberately stays off the carrier: its mime carries a `;profile=lbr` suffix, so
|
||||
* the equality below excludes it and it keeps decoding as before.
|
||||
*/
|
||||
private fun isDtsHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_DTS_HD
|
||||
|
||||
override fun supportsFormat(format: Format): Boolean = when {
|
||||
shouldUseCarrier(format) -> true
|
||||
isTrueHd(format) -> false
|
||||
isDtsHd(format) && carrierRouteAvailable() -> false
|
||||
else -> defaultSink.supportsFormat(format)
|
||||
}
|
||||
|
||||
override fun getFormatSupport(format: Format): Int = when {
|
||||
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
|
||||
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
else -> defaultSink.getFormatSupport(format)
|
||||
}
|
||||
|
||||
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
|
||||
val inputFormat = audioSinkConfig.format
|
||||
val useCarrier = shouldUseCarrier(inputFormat)
|
||||
if (useCarrier != carrierActive) {
|
||||
log?.invoke(
|
||||
"info",
|
||||
"audio",
|
||||
if (useCarrier) {
|
||||
"${carrierCodecName(inputFormat)} via IEC 61937 carrier at ${IecCarrier.SAMPLE_RATE}Hz/" +
|
||||
"${IecCarrier.CHANNEL_COUNT}ch"
|
||||
} else {
|
||||
"Leaving the IEC 61937 carrier; audio returns to the normal sink"
|
||||
}
|
||||
)
|
||||
}
|
||||
carrierActive = useCarrier
|
||||
configuredGeneration = mediaGeneration.get()
|
||||
activePacker = if (isDtsHd(inputFormat)) dtsHdPacker else matPacker
|
||||
active = if (useCarrier) carrierSink else defaultSink
|
||||
loggedCoreStrip = false
|
||||
discardCarrierState()
|
||||
|
||||
if (useCarrier) {
|
||||
val carrierConfig = AudioSink.AudioSinkConfig.Builder(carrierFormat())
|
||||
.setPreferredBufferSizeOverride(audioSinkConfig.preferredBufferSizeOverride)
|
||||
.setTimeline(audioSinkConfig.timeline)
|
||||
.setMediaPeriodId(audioSinkConfig.mediaPeriodId)
|
||||
.build()
|
||||
carrierSink.configure(carrierConfig)
|
||||
} else {
|
||||
defaultSink.configure(audioSinkConfig)
|
||||
}
|
||||
}
|
||||
|
||||
private fun carrierCodecName(format: Format): String = if (isDtsHd(format)) "DTS-HD" else "TrueHD (MAT)"
|
||||
|
||||
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
|
||||
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
||||
|
||||
// A refused burst blocks everything: placing it must come before consuming more input, or the
|
||||
// carrier would lose a frame.
|
||||
pendingBurst?.let { burst ->
|
||||
if (!carrierSink.handleBuffer(burst, pendingBurstTimeUs, 1)) return false
|
||||
pendingBurst = null
|
||||
}
|
||||
if (!buffer.hasRemaining()) return true
|
||||
if (carrierAnchorUs == C.TIME_UNSET) carrierAnchorUs = presentationTimeUs
|
||||
|
||||
// One media3 sample holds many access units. The buffer position advances per unit, so a burst
|
||||
// the delegate refuses leaves the units behind it in the buffer for the retry rather than
|
||||
// dropping them — media3 re-delivers the same buffer whenever this returns false.
|
||||
val remaining = ByteArray(buffer.remaining())
|
||||
buffer.duplicate().get(remaining)
|
||||
|
||||
var offset = 0
|
||||
while (offset < remaining.size) {
|
||||
val length = activePacker.accessUnitLength(remaining, offset, remaining.size)
|
||||
if (activePacker.unsupportedStream) return latchMismatch()
|
||||
if (length == 0) {
|
||||
// Not a unit boundary we recognise. Consuming the tail keeps the stream moving; trying to
|
||||
// resynchronise mid-carrier would splice a frame.
|
||||
buffer.position(buffer.limit())
|
||||
return true
|
||||
}
|
||||
val burst = activePacker.packAccessUnit(remaining, offset, length)
|
||||
if (activePacker.unsupportedStream) return latchMismatch()
|
||||
offset += length
|
||||
buffer.position(buffer.position() + length)
|
||||
if (burst == null) continue
|
||||
if (activePacker === dtsHdPacker && dtsHdPacker.strippedToCore && !loggedCoreStrip) {
|
||||
loggedCoreStrip = true
|
||||
log?.invoke(
|
||||
"warn",
|
||||
"audio",
|
||||
"DTS-HD MA bitrate exceeds the IEC 61937 carrier; sending the DTS core substream for ~60s"
|
||||
)
|
||||
}
|
||||
|
||||
val burstTimeUs = carrierAnchorUs + carrierFramesSinceAnchor * 1_000_000L / IecCarrier.SAMPLE_RATE
|
||||
carrierFramesSinceAnchor += burst.remaining().toLong() / IecCarrier.BYTES_PER_FRAME
|
||||
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
|
||||
pendingBurst = burst
|
||||
pendingBurstTimeUs = burstTimeUs
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The bitstream announced a shape the carrier cannot ride, which selection could not see in the
|
||||
* container's Format. The packer emits nothing in that state; consuming here would turn the
|
||||
* stream into silence. Leave the unit in the buffer, latch the carrier off, and ask for
|
||||
* reselection so the decoder takes over and receives it.
|
||||
*/
|
||||
private fun latchMismatch(): Boolean {
|
||||
if (mismatchGeneration != configuredGeneration) {
|
||||
mismatchGeneration = configuredGeneration
|
||||
log?.invoke(
|
||||
"warn",
|
||||
"audio",
|
||||
if (activePacker === dtsHdPacker) {
|
||||
"DTS-HD bitstream cannot ride the 192kHz carrier (44.1kHz-family core or non-standard framing); " +
|
||||
"leaving the carrier so it decodes"
|
||||
} else {
|
||||
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
|
||||
"leaving the carrier so it decodes"
|
||||
}
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getCurrentPositionUs(sourceEnded: Boolean): Long = active.getCurrentPositionUs(sourceEnded)
|
||||
|
||||
override fun playToEndOfStream() {
|
||||
// A partially filled burst cannot be emitted; up to one burst is dropped at the end of a stream.
|
||||
active.playToEndOfStream()
|
||||
}
|
||||
|
||||
override fun isEnded(): Boolean = active.isEnded()
|
||||
|
||||
override fun hasPendingData(): Boolean = pendingBurst != null || active.hasPendingData()
|
||||
|
||||
override fun handleDiscontinuity() {
|
||||
discardCarrierState()
|
||||
active.handleDiscontinuity()
|
||||
}
|
||||
|
||||
override fun play() = active.play()
|
||||
|
||||
override fun pause() = active.pause()
|
||||
|
||||
override fun flush() {
|
||||
discardCarrierState()
|
||||
active.flush()
|
||||
}
|
||||
|
||||
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
|
||||
|
||||
private fun discardCarrierState() {
|
||||
matPacker.reset()
|
||||
dtsHdPacker.reset()
|
||||
pendingBurst = null
|
||||
// Re-anchor on the next burst: after a seek the carrier restarts from a new media time.
|
||||
carrierAnchorUs = C.TIME_UNSET
|
||||
carrierFramesSinceAnchor = 0
|
||||
}
|
||||
|
||||
// --- Persistent state: mirrored, so either delegate can be activated later ---
|
||||
|
||||
override fun setListener(listener: AudioSink.Listener) {
|
||||
sinkListener = listener
|
||||
defaultSink.setListener(listener)
|
||||
carrierSink.setListener(listener)
|
||||
}
|
||||
|
||||
override fun setPlayerId(playerId: PlayerId?) {
|
||||
defaultSink.setPlayerId(playerId)
|
||||
carrierSink.setPlayerId(playerId)
|
||||
}
|
||||
|
||||
override fun setClock(clock: Clock) {
|
||||
defaultSink.setClock(clock)
|
||||
carrierSink.setClock(clock)
|
||||
}
|
||||
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
|
||||
val wasUnitSpeed = this.playbackParameters.speed == 1f
|
||||
val isUnitSpeed = playbackParameters.speed == 1f
|
||||
this.playbackParameters = playbackParameters
|
||||
defaultSink.setPlaybackParameters(playbackParameters)
|
||||
// A bitstream cannot be resampled, and the carrier delegate has no processor chain to do it
|
||||
// with, so it is never handed a speed nothing can apply.
|
||||
carrierSink.setPlaybackParameters(
|
||||
if (isUnitSpeed) playbackParameters else PlaybackParameters.DEFAULT
|
||||
)
|
||||
|
||||
// Crossing 1x changes whether a bitstream may ride the carrier, but nothing re-asks on its
|
||||
// own: the renderer only consults the sink when capabilities are invalidated. Rebuilding the
|
||||
// track selector parameters is not enough either — DefaultTrackSelector skips invalidation
|
||||
// when the rebuilt parameters compare equal. This is the path media3 itself uses for a route
|
||||
// change, and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and the
|
||||
// stream moves between the carrier and the decoder.
|
||||
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
|
||||
log?.invoke(
|
||||
"info",
|
||||
"audio",
|
||||
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the bitstream carrier"
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the configured stream is currently riding the carrier. */
|
||||
val isCarrierActive: Boolean
|
||||
get() = carrierActive
|
||||
|
||||
/**
|
||||
* While the carrier is live the delegate is deliberately pinned to 1x, but the player polls this
|
||||
* through the media clock and adopts whatever it reads. Reporting the delegate's value would push
|
||||
* the pinned 1x back and silently undo the user's speed change, so the requested parameters are
|
||||
* reported instead; the reselection triggered above then moves the stream onto the decoder, which
|
||||
* really can apply them.
|
||||
*/
|
||||
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
|
||||
|
||||
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
|
||||
// Only the normal sink: the carrier delegate is fed a fixed-rate bitstream,
|
||||
// and dropping "silent" carrier bytes would break the frame cadence.
|
||||
defaultSink.setSkipSilenceEnabled(skipSilenceEnabled)
|
||||
}
|
||||
|
||||
override fun getSkipSilenceEnabled(): Boolean = defaultSink.getSkipSilenceEnabled()
|
||||
|
||||
override fun setAudioAttributes(audioAttributes: AudioAttributes) {
|
||||
defaultSink.setAudioAttributes(audioAttributes)
|
||||
carrierSink.setAudioAttributes(audioAttributes)
|
||||
}
|
||||
|
||||
override fun getAudioAttributes(): AudioAttributes? = active.audioAttributes
|
||||
|
||||
override fun setAudioSessionId(audioSessionId: Int) {
|
||||
defaultSink.setAudioSessionId(audioSessionId)
|
||||
carrierSink.setAudioSessionId(audioSessionId)
|
||||
}
|
||||
|
||||
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {
|
||||
defaultSink.setAuxEffectInfo(auxEffectInfo)
|
||||
carrierSink.setAuxEffectInfo(auxEffectInfo)
|
||||
}
|
||||
|
||||
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) {
|
||||
defaultSink.setPreferredDevice(audioDeviceInfo)
|
||||
carrierSink.setPreferredDevice(audioDeviceInfo)
|
||||
}
|
||||
|
||||
override fun setOutputStreamOffsetUs(outputStreamOffsetUs: Long) {
|
||||
defaultSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
|
||||
carrierSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
|
||||
}
|
||||
|
||||
override fun setVolume(volume: Float) {
|
||||
defaultSink.setVolume(volume)
|
||||
carrierSink.setVolume(volume)
|
||||
}
|
||||
|
||||
override fun enableTunnelingV21() {
|
||||
// Tunneling is a decoded-PCM/video-sync arrangement; the carrier never uses it.
|
||||
defaultSink.enableTunnelingV21()
|
||||
}
|
||||
|
||||
override fun disableTunneling() {
|
||||
defaultSink.disableTunneling()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
discardCarrierState()
|
||||
defaultSink.reset()
|
||||
carrierSink.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the owner immediately before a new media item is set, which is the only point where
|
||||
* "this stream lied about its shape" stops being true and the carrier can be offered again.
|
||||
*/
|
||||
fun beginMediaItem() {
|
||||
mediaGeneration.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
mediaGeneration.incrementAndGet()
|
||||
discardCarrierState()
|
||||
defaultSink.release()
|
||||
carrierSink.release()
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,100 @@ internal object LoadControlPolicy {
|
||||
*/
|
||||
const val MIN_TARGET_BYTES = 32 * MIB
|
||||
|
||||
/** `DefaultLoadControl` play-start threshold on a first buffer. */
|
||||
const val BUFFER_FOR_PLAYBACK_MS = 1_000
|
||||
|
||||
/**
|
||||
* `DefaultLoadControl` play-start threshold after a rebuffer. Below this the player is
|
||||
* *supposed* to stay in `STATE_BUFFERING`, so anything judging a buffering player has to clear
|
||||
* this bar before it can call the wait anomalous — see [BufferingStallPolicy].
|
||||
*/
|
||||
const val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5_000
|
||||
|
||||
/**
|
||||
* Fraction of a memory budget the allocator may claim. Matches the threshold the Buffer
|
||||
* Size setting already warns at (`value > heapMB / 4`).
|
||||
*/
|
||||
private const val BUDGET_DIVISOR = 4
|
||||
|
||||
/**
|
||||
* Memory tier below which the shipped Auto durations stay conservative, in
|
||||
* `ActivityManager.MemoryInfo.availMem` MiB.
|
||||
*/
|
||||
private const val LOW_MEMORY_MB = 2048
|
||||
|
||||
/** Auto durations, by memory tier. These are the values that shipped before #1816. */
|
||||
private const val AUTO_MIN_BUFFER_LOW_MEMORY_MS = 15_000
|
||||
private const val AUTO_MAX_BUFFER_LOW_MEMORY_MS = 50_000
|
||||
private const val AUTO_MIN_BUFFER_MS = 30_000
|
||||
private const val AUTO_MAX_BUFFER_MS = 60_000
|
||||
|
||||
/**
|
||||
* How deep the loader may read ahead.
|
||||
*
|
||||
* Named tiers rather than a duration, because a duration would be a promise this cannot keep:
|
||||
* `prioritizeTimeOverSizeThresholds` is disabled, so `targetBufferBytes` stops the loader even
|
||||
* below `minBufferMs`, and effective read-ahead is `min(maxBufferMs, 8 * bytes / bitrate)`. On a
|
||||
* high bitrate stream the byte term wins whatever is chosen here. The tiers therefore describe
|
||||
* an intent — how much network variance to absorb — and the Buffer Size setting bounds the
|
||||
* memory it may cost.
|
||||
*
|
||||
* Values match jellyfin-androidtv and jellyfin-android so a user moving between Jellyfin clients
|
||||
* gets the same behaviour from the same words.
|
||||
*/
|
||||
enum class BufferTier(val minBufferMs: Int, val maxBufferMs: Int) {
|
||||
/** Memory-tiered defaults, resolved per device in [bufferDurations]. */
|
||||
AUTO(0, 0),
|
||||
|
||||
/** For moderate or variable connections. */
|
||||
LARGE(50_000, 120_000),
|
||||
|
||||
/** For slow or high-latency links, where a stall costs more than the memory does. */
|
||||
EXTRA_LARGE(80_000, 240_000);
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Wire values are the Dart enum's `nativeValue`. An unknown string means a Dart/Kotlin
|
||||
* skew, and [AUTO] is the only safe reading of "I do not know what this user asked for".
|
||||
*/
|
||||
fun fromWire(value: String?): BufferTier = when (value) {
|
||||
"large" -> LARGE
|
||||
"extra_large" -> EXTRA_LARGE
|
||||
else -> AUTO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The min/max pair handed to `DefaultLoadControl.Builder.setBufferDurationsMs`. */
|
||||
data class BufferDurations(val minBufferMs: Int, val maxBufferMs: Int)
|
||||
|
||||
/**
|
||||
* Read-ahead duration bounds for this session.
|
||||
*
|
||||
* The returned pair always satisfies the ordering media3 asserts on
|
||||
* (`bufferForPlayback* <= minBufferMs <= maxBufferMs`). That is enforced here rather than
|
||||
* trusted, because media3 validates with Guava `Preconditions` — an unconditional throw that
|
||||
* R8 does not elide — so a bad pair is an `IllegalArgumentException` out of
|
||||
* `DefaultLoadControl.Builder.build()` on a user's device, not a degraded buffer.
|
||||
*
|
||||
* @param availableMB `ActivityManager.MemoryInfo.availMem`. Non-positive when unknown, which
|
||||
* is treated as the low-memory tier.
|
||||
*/
|
||||
fun bufferDurations(tier: BufferTier, availableMB: Int): BufferDurations {
|
||||
val playStartFloor = maxOf(BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
|
||||
val (rawMin, rawMax) = if (tier == BufferTier.AUTO) {
|
||||
if (availableMB <= LOW_MEMORY_MB) {
|
||||
AUTO_MIN_BUFFER_LOW_MEMORY_MS to AUTO_MAX_BUFFER_LOW_MEMORY_MS
|
||||
} else {
|
||||
AUTO_MIN_BUFFER_MS to AUTO_MAX_BUFFER_MS
|
||||
}
|
||||
} else {
|
||||
tier.minBufferMs to tier.maxBufferMs
|
||||
}
|
||||
val minBufferMs = rawMin.coerceAtLeast(playStartFloor)
|
||||
return BufferDurations(minBufferMs, rawMax.coerceAtLeast(minBufferMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param largeHeapMB `ActivityManager.largeMemoryClass` — the hard Java-heap ceiling for
|
||||
* this process, which is what bounds `DefaultAllocator` (it hands out `byte[]`).
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.LoadControl
|
||||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.TrackGroupArray
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
|
||||
import androidx.media3.exoplayer.upstream.Allocator
|
||||
|
||||
/**
|
||||
* A [LoadControl] that remembers whether it last said playback may start.
|
||||
*
|
||||
* The buffering stall watchdog ([BufferingStallPolicy]) needs to tell apart a player that is
|
||||
* buffering because the load control is holding it back — an ordinary rebuffer, not its business —
|
||||
* from one the load control has released that still refuses to move, which is the failure it exists
|
||||
* to catch.
|
||||
*
|
||||
* Re-deriving that condition cannot be done faithfully: `DefaultLoadControl.shouldStartPlayback`
|
||||
* releases playback on buffered *playout* duration (media duration divided by playback speed) or,
|
||||
* with `prioritizeTimeOverSizeThresholds` disabled, as soon as its byte target is full — which on a
|
||||
* high bitrate stream happens well below any duration bar, and depends on allocator accounting that
|
||||
* no public API exposes. So ask the object ExoPlayer asks, and record its answer.
|
||||
*
|
||||
* Note what this does *not* reach: `ExoPlayerImplInternal.shouldTransitionToReadyState` returns
|
||||
* early when the renderers are not ready, so it never asks at all in the case the watchdog was
|
||||
* written for — a renderer stuck forever. [BufferingStallPolicy] therefore also takes
|
||||
* `Player.isLoading`, which stays observable throughout.
|
||||
*
|
||||
* Every member is forwarded explicitly, and that is not a style choice: Kotlin's `by` delegation
|
||||
* only generates forwarders for *abstract* interface members, so a Java interface like this one —
|
||||
* where all but `getAllocator` carry default implementations that throw
|
||||
* `IllegalStateException("... not implemented")` — would inherit those throwing defaults and blow up
|
||||
* inside `ExoPlayer.Builder.build()`. [ObservingLoadControlTest] builds a real player through this
|
||||
* wrapper so a media3 upgrade that adds another such method fails a test rather than a device.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class ObservingLoadControl(private val delegate: LoadControl) : LoadControl {
|
||||
|
||||
/**
|
||||
* The delegate's most recent verdict, or null if it has not been asked since [reset].
|
||||
*
|
||||
* ExoPlayer asks on every playback-loop iteration while buffering, so a reader running on the
|
||||
* watchdog's schedule sees a current answer; null only means "not yet asked".
|
||||
*/
|
||||
@Volatile
|
||||
var startPlaybackVerdict: Boolean? = null
|
||||
private set
|
||||
|
||||
/** Forgets the recorded verdict, for a watchdog arming a fresh buffering window. */
|
||||
fun reset() {
|
||||
startPlaybackVerdict = null
|
||||
}
|
||||
|
||||
// The Parameters overloads are the ones ExoPlayer 1.10 calls; the older signatures are deprecated
|
||||
// shims that route through them.
|
||||
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean {
|
||||
val verdict = delegate.shouldStartPlayback(parameters)
|
||||
startPlaybackVerdict = verdict
|
||||
return verdict
|
||||
}
|
||||
|
||||
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = delegate.shouldContinueLoading(parameters)
|
||||
|
||||
override fun shouldContinuePreloading(
|
||||
playerId: PlayerId,
|
||||
timeline: Timeline,
|
||||
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||
bufferedDurationUs: Long
|
||||
): Boolean = delegate.shouldContinuePreloading(playerId, timeline, mediaPeriodId, bufferedDurationUs)
|
||||
|
||||
override fun onPrepared(playerId: PlayerId) = delegate.onPrepared(playerId)
|
||||
|
||||
override fun onTracksSelected(
|
||||
parameters: LoadControl.Parameters,
|
||||
trackGroups: TrackGroupArray,
|
||||
trackSelections: Array<out ExoTrackSelection>
|
||||
) = delegate.onTracksSelected(parameters, trackGroups, trackSelections)
|
||||
|
||||
override fun onStopped(playerId: PlayerId) = delegate.onStopped(playerId)
|
||||
|
||||
override fun onReleased(playerId: PlayerId) = delegate.onReleased(playerId)
|
||||
|
||||
override fun getAllocator(playerId: PlayerId): Allocator = delegate.getAllocator(playerId)
|
||||
|
||||
override fun getBackBufferDurationUs(playerId: PlayerId): Long = delegate.getBackBufferDurationUs(playerId)
|
||||
|
||||
override fun retainBackBufferFromKeyframe(playerId: PlayerId): Boolean = delegate.retainBackBufferFromKeyframe(playerId)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.util.Consumer
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.extractor.text.CuesWithTiming
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import java.util.zip.Inflater
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Delegates to [delegate] for every subtitle format except PGS, which is handled by
|
||||
* [PgsCompositionParser] because media3's PgsParser drops all but one composition object (#1953).
|
||||
*/
|
||||
@UnstableApi
|
||||
class PgsSubtitleParserFactory(private val delegate: SubtitleParser.Factory) : SubtitleParser.Factory {
|
||||
override fun supportsFormat(format: Format): Boolean = delegate.supportsFormat(format)
|
||||
|
||||
override fun getCueReplacementBehavior(format: Format): Int = delegate.getCueReplacementBehavior(format)
|
||||
|
||||
override fun create(format: Format): SubtitleParser = if (format.sampleMimeType == MimeTypes.APPLICATION_PGS) PgsCompositionParser() else delegate.create(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* PGS subtitle parser that renders every composition object in a display set.
|
||||
*
|
||||
* media3's PgsParser keeps one bitmap buffer and only the first composition object's
|
||||
* coordinates, so a display set with two simultaneous images (dialogue plus a sign or
|
||||
* song caption) draws the last object at the first object's position and loses the rest.
|
||||
* It is also stateless across display sets, so Normal-state sets that only update the
|
||||
* palette (fades) or reuse cached objects blank the screen.
|
||||
*
|
||||
* This parser follows FFmpeg's pgssubdec model, which Kodi/mpv/VLC share: objects and
|
||||
* palettes are cached per epoch keyed by id, any non-Normal composition state releases
|
||||
* the caches, palette sections update entries in place, and the display-end section
|
||||
* composes one cue per object reference in the presentation. Colors use limited-range
|
||||
* BT.709 for HD planes and BT.601 otherwise, matching FFmpeg output.
|
||||
*/
|
||||
@UnstableApi
|
||||
class PgsCompositionParser : SubtitleParser {
|
||||
|
||||
companion object {
|
||||
private const val SECTION_TYPE_PALETTE = 0x14
|
||||
private const val SECTION_TYPE_OBJECT = 0x15
|
||||
private const val SECTION_TYPE_PRESENTATION = 0x16
|
||||
private const val SECTION_TYPE_END = 0x80
|
||||
|
||||
// Blu-ray spec limits, as enforced by FFmpeg's pgssubdec.
|
||||
private const val MAX_OBJECT_REFS = 2
|
||||
private const val MAX_EPOCH_OBJECTS = 64
|
||||
private const val MAX_EPOCH_PALETTES = 8
|
||||
|
||||
// Bounds object dimensions when a damaged stream declares them before any
|
||||
// presentation section has established the composition plane.
|
||||
private const val MAX_PLANE_DIMENSION = 4096
|
||||
}
|
||||
|
||||
private class PgsObject {
|
||||
var width = 0
|
||||
var height = 0
|
||||
var rle = ByteArray(0)
|
||||
var received = 0
|
||||
|
||||
val complete: Boolean
|
||||
get() = rle.isNotEmpty() && received == rle.size
|
||||
}
|
||||
|
||||
private class ObjectRef(val objectId: Int, val x: Int, val y: Int)
|
||||
|
||||
private val buffer = ParsableByteArray()
|
||||
private val inflatedBuffer = ParsableByteArray()
|
||||
private var inflater: Inflater? = null
|
||||
|
||||
// Epoch state: objects and palettes persist across display sets until a
|
||||
// non-Normal composition state starts a new epoch or [reset] follows a seek.
|
||||
private val objects = HashMap<Int, PgsObject>()
|
||||
private val palettes = HashMap<Int, IntArray>()
|
||||
|
||||
// Latest presentation composition.
|
||||
private var planeWidth = 0
|
||||
private var planeHeight = 0
|
||||
private var paletteId = 0
|
||||
private var objectRefs = emptyList<ObjectRef>()
|
||||
|
||||
override fun getCueReplacementBehavior(): Int = Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
|
||||
|
||||
override fun reset() {
|
||||
objects.clear()
|
||||
palettes.clear()
|
||||
objectRefs = emptyList()
|
||||
planeWidth = 0
|
||||
planeHeight = 0
|
||||
paletteId = 0
|
||||
}
|
||||
|
||||
override fun parse(
|
||||
data: ByteArray,
|
||||
offset: Int,
|
||||
length: Int,
|
||||
outputOptions: SubtitleParser.OutputOptions,
|
||||
output: Consumer<CuesWithTiming>
|
||||
) {
|
||||
buffer.reset(data, offset + length)
|
||||
buffer.setPosition(offset)
|
||||
val inflater = this.inflater ?: Inflater().also { this.inflater = it }
|
||||
if (Util.maybeInflate(buffer, inflatedBuffer, inflater)) {
|
||||
buffer.reset(inflatedBuffer.data, inflatedBuffer.limit())
|
||||
}
|
||||
val cues = ArrayList<Cue>()
|
||||
while (buffer.bytesLeft() >= 3) {
|
||||
readNextSection(cues)
|
||||
}
|
||||
output.accept(
|
||||
CuesWithTiming(
|
||||
cues,
|
||||
C.TIME_UNSET, // startTimeUs
|
||||
C.TIME_UNSET // durationUs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun readNextSection(cues: MutableList<Cue>) {
|
||||
val sectionType = buffer.readUnsignedByte()
|
||||
val sectionLength = buffer.readUnsignedShort()
|
||||
val nextSectionPosition = buffer.position + sectionLength
|
||||
if (nextSectionPosition > buffer.limit()) {
|
||||
buffer.setPosition(buffer.limit())
|
||||
return
|
||||
}
|
||||
when (sectionType) {
|
||||
SECTION_TYPE_PALETTE -> parsePalette(sectionLength)
|
||||
SECTION_TYPE_OBJECT -> parseObject(sectionLength)
|
||||
SECTION_TYPE_PRESENTATION -> parsePresentation(sectionLength)
|
||||
SECTION_TYPE_END -> composeCues(cues)
|
||||
// Window sections (0x17) carry no information the composition needs.
|
||||
}
|
||||
buffer.setPosition(nextSectionPosition)
|
||||
}
|
||||
|
||||
private fun parsePresentation(sectionLength: Int) {
|
||||
if (sectionLength < 11) return
|
||||
planeWidth = buffer.readUnsignedShort()
|
||||
planeHeight = buffer.readUnsignedShort()
|
||||
buffer.skipBytes(3) // Frame rate (1), composition number (2).
|
||||
val compositionState = buffer.readUnsignedByte()
|
||||
if (compositionState and 0xC0 != 0) {
|
||||
// Epoch start/acquisition point display sets carry complete data; previous
|
||||
// epoch objects and palettes are released.
|
||||
objects.clear()
|
||||
palettes.clear()
|
||||
}
|
||||
buffer.skipBytes(1) // Palette update flag.
|
||||
paletteId = buffer.readUnsignedByte()
|
||||
val objectCount = buffer.readUnsignedByte()
|
||||
var remaining = sectionLength - 11
|
||||
val refs = ArrayList<ObjectRef>(min(objectCount, MAX_OBJECT_REFS))
|
||||
for (i in 0 until min(objectCount, MAX_OBJECT_REFS)) {
|
||||
if (remaining < 8) break
|
||||
val objectId = buffer.readUnsignedShort()
|
||||
buffer.skipBytes(1) // Window id.
|
||||
val flags = buffer.readUnsignedByte()
|
||||
val x = buffer.readUnsignedShort()
|
||||
val y = buffer.readUnsignedShort()
|
||||
remaining -= 8
|
||||
if (flags and 0x80 != 0) {
|
||||
// Cropped object: skip the crop rectangle. Like FFmpeg, the crop itself
|
||||
// is not applied, but the following reference must parse correctly.
|
||||
if (remaining < 8) break
|
||||
buffer.skipBytes(8)
|
||||
remaining -= 8
|
||||
}
|
||||
refs.add(ObjectRef(objectId, x, y))
|
||||
}
|
||||
objectRefs = refs
|
||||
}
|
||||
|
||||
private fun parsePalette(sectionLength: Int) {
|
||||
if (sectionLength % 5 != 2) return
|
||||
val id = buffer.readUnsignedByte()
|
||||
buffer.skipBytes(1) // Palette version.
|
||||
if (palettes.size >= MAX_EPOCH_PALETTES && id !in palettes) return
|
||||
// Entries update in place: fade ramps resend only the entries that changed.
|
||||
val palette = palettes.getOrPut(id) { IntArray(256) }
|
||||
val bt709 = planeHeight <= 0 || planeHeight > 576
|
||||
repeat(sectionLength / 5) {
|
||||
val index = buffer.readUnsignedByte()
|
||||
val y = buffer.readUnsignedByte()
|
||||
val cr = buffer.readUnsignedByte()
|
||||
val cb = buffer.readUnsignedByte()
|
||||
val alpha = buffer.readUnsignedByte()
|
||||
palette[index] = argbColor(y, cb, cr, alpha, bt709)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseObject(sectionLength: Int) {
|
||||
if (sectionLength < 4) return
|
||||
val id = buffer.readUnsignedShort()
|
||||
if (objects.size >= MAX_EPOCH_OBJECTS && id !in objects) return
|
||||
buffer.skipBytes(1) // Object version.
|
||||
val isFirstFragment = buffer.readUnsignedByte() and 0x80 != 0
|
||||
var remaining = sectionLength - 4
|
||||
val obj = objects.getOrPut(id) { PgsObject() }
|
||||
if (isFirstFragment) {
|
||||
if (remaining < 7) return
|
||||
val rleLength = buffer.readUnsignedInt24() - 4 // Length includes the width/height fields.
|
||||
val width = buffer.readUnsignedShort()
|
||||
val height = buffer.readUnsignedShort()
|
||||
remaining -= 7
|
||||
val maxWidth = if (planeWidth > 0) planeWidth else MAX_PLANE_DIMENSION
|
||||
val maxHeight = if (planeHeight > 0) planeHeight else MAX_PLANE_DIMENSION
|
||||
if (rleLength <= 0 || width !in 1..maxWidth || height !in 1..maxHeight) {
|
||||
obj.rle = ByteArray(0)
|
||||
obj.received = 0
|
||||
return
|
||||
}
|
||||
obj.width = width
|
||||
obj.height = height
|
||||
obj.rle = ByteArray(rleLength)
|
||||
obj.received = 0
|
||||
}
|
||||
val bytesToRead = min(remaining, obj.rle.size - obj.received)
|
||||
if (bytesToRead > 0) {
|
||||
buffer.readBytes(obj.rle, obj.received, bytesToRead)
|
||||
obj.received += bytesToRead
|
||||
}
|
||||
}
|
||||
|
||||
private fun composeCues(cues: MutableList<Cue>) {
|
||||
if (planeWidth <= 0 || planeHeight <= 0) return
|
||||
val palette = palettes[paletteId] ?: return
|
||||
for (ref in objectRefs) {
|
||||
// Skip references to missing or incomplete objects (damaged stream, or a
|
||||
// post-seek Normal set whose epoch data was never observed).
|
||||
val obj = objects[ref.objectId] ?: continue
|
||||
if (!obj.complete) continue
|
||||
cues.add(
|
||||
Cue.Builder()
|
||||
.setBitmap(decodeBitmap(obj, palette))
|
||||
.setPosition(ref.x.toFloat() / planeWidth)
|
||||
.setPositionAnchor(Cue.ANCHOR_TYPE_START)
|
||||
.setLine(ref.y.toFloat() / planeHeight, Cue.LINE_TYPE_FRACTION)
|
||||
.setLineAnchor(Cue.ANCHOR_TYPE_START)
|
||||
.setSize(obj.width.toFloat() / planeWidth)
|
||||
.setBitmapHeight(obj.height.toFloat() / planeHeight)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBitmap(obj: PgsObject, palette: IntArray): Bitmap {
|
||||
val pixels = IntArray(obj.width * obj.height)
|
||||
val rle = obj.rle
|
||||
var input = 0
|
||||
var output = 0
|
||||
while (input < rle.size && output < pixels.size) {
|
||||
val first = rle[input++].toInt() and 0xFF
|
||||
if (first != 0) {
|
||||
pixels[output++] = palette[first]
|
||||
continue
|
||||
}
|
||||
if (input >= rle.size) break
|
||||
val flags = rle[input++].toInt() and 0xFF
|
||||
if (flags == 0) continue // End-of-line marker.
|
||||
var run = flags and 0x3F
|
||||
if (flags and 0x40 != 0) {
|
||||
if (input >= rle.size) break
|
||||
run = run shl 8 or (rle[input++].toInt() and 0xFF)
|
||||
}
|
||||
val color = if (flags and 0x80 != 0) {
|
||||
if (input >= rle.size) break
|
||||
palette[rle[input++].toInt() and 0xFF]
|
||||
} else {
|
||||
palette[0]
|
||||
}
|
||||
val end = min(output + run, pixels.size)
|
||||
pixels.fill(color, output, end)
|
||||
output = end
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, obj.width, obj.height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun argbColor(y: Int, cb: Int, cr: Int, alpha: Int, bt709: Boolean): Int {
|
||||
val yc = (y - 16) * (255f / 219f)
|
||||
val cbc = (cb - 128) * (255f / 224f)
|
||||
val crc = (cr - 128) * (255f / 224f)
|
||||
val r: Float
|
||||
val g: Float
|
||||
val b: Float
|
||||
if (bt709) {
|
||||
r = yc + 1.5747f * crc
|
||||
g = yc - 0.1873f * cbc - 0.4682f * crc
|
||||
b = yc + 1.8556f * cbc
|
||||
} else {
|
||||
r = yc + 1.402f * crc
|
||||
g = yc - 0.34414f * cbc - 0.71414f * crc
|
||||
b = yc + 1.772f * cbc
|
||||
}
|
||||
return (alpha shl 24) or
|
||||
(r.roundToInt().coerceIn(0, 255) shl 16) or
|
||||
(g.roundToInt().coerceIn(0, 255) shl 8) or
|
||||
b.roundToInt().coerceIn(0, 255)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.audio.AudioProcessor
|
||||
import androidx.media3.common.audio.ChannelMixingAudioProcessor
|
||||
import androidx.media3.common.audio.ChannelMixingMatrix
|
||||
import androidx.media3.common.util.Clock
|
||||
@@ -150,17 +151,31 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
)
|
||||
}
|
||||
|
||||
private var iecCarrierSink: IecCarrierSink? = null
|
||||
|
||||
/**
|
||||
* Clears per-stream carrier state that must survive renderer resets but not a new media item.
|
||||
* Call before setting a new source; see [IecCarrierSink.beginMediaItem].
|
||||
*/
|
||||
fun beginMediaItem() {
|
||||
iecCarrierSink?.beginMediaItem()
|
||||
}
|
||||
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean
|
||||
): AudioSink {
|
||||
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
|
||||
|
||||
// Media3 1.11's replacement is fixed at 500ms; preserve the dynamic 500–1000ms PCM policy.
|
||||
@Suppress("DEPRECATION")
|
||||
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
|
||||
.setMinPcmBufferDurationUs(500_000)
|
||||
.setMaxPcmBufferDurationUs(1_000_000)
|
||||
.setPcmBufferMultiplicationFactor(4)
|
||||
// Media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms. Some
|
||||
// HDMI routes reject a buffer that short (#1790). Ask for a second up front; Media3 1.11
|
||||
// now uses the same one-second floor for its last-resort retry (#3207).
|
||||
.setPassthroughBufferDurationUs(500_000)
|
||||
.build()
|
||||
|
||||
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
|
||||
@@ -180,7 +195,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
|
||||
.build()
|
||||
|
||||
return PositionFixAudioSink(
|
||||
val processedSink = PositionFixAudioSink(
|
||||
defaultSink,
|
||||
rawPositionUs,
|
||||
audioDelayUs,
|
||||
@@ -188,7 +203,89 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
onAudioCapabilitiesChanged,
|
||||
audioDiagnosticsLogger
|
||||
)
|
||||
|
||||
return IecCarrierSink(
|
||||
defaultSink = processedSink,
|
||||
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
||||
carrierRouteAvailable = { supportsIecCarrier(context) },
|
||||
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
||||
log = audioDiagnosticsLogger
|
||||
).also { iecCarrierSink = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
|
||||
*
|
||||
* Deliberately separate from the processed sink, and deliberately barren: an empty
|
||||
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
|
||||
* float conversion can ever touch the bytes. The carrier only looks like PCM; a single mutated
|
||||
* sample reaches the receiver as full-scale noise rather than as a glitch.
|
||||
*
|
||||
* The output provider keeps `OutputConfig.encoding` at PCM 16-bit so every position, pending-data
|
||||
* and release accounting in media3 stays in its mature PCM path — which is correct here, because
|
||||
* after packing the stream genuinely is a fixed-rate 192kHz 8-channel carrier. Only the
|
||||
* `AudioTrack` itself is switched to `ENCODING_IEC61937`, via the builder modifier that upstream
|
||||
* applies immediately before `AudioTrack.Builder.build()`.
|
||||
*/
|
||||
private fun buildCarrierSink(
|
||||
context: Context,
|
||||
bufferSizeProvider: DefaultAudioTrackBufferSizeProvider
|
||||
): AudioSink {
|
||||
val provider = AudioTrackAudioOutputProvider.Builder(context)
|
||||
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
setAudioTrackBuilderModifier { builder, config ->
|
||||
builder.setAudioFormat(
|
||||
android.media.AudioFormat.Builder()
|
||||
.setEncoding(android.media.AudioFormat.ENCODING_IEC61937)
|
||||
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
|
||||
.setSampleRate(config.sampleRate)
|
||||
.build()
|
||||
)
|
||||
// The AudioTrackConfig media3 reports upstream is built from OutputConfig, which stays
|
||||
// PCM so the accounting stays in the PCM domain — so it cannot show the real encoding.
|
||||
// This is the only place that can confirm what the AudioTrack was actually built as.
|
||||
audioDiagnosticsLogger?.invoke(
|
||||
"info",
|
||||
"audio",
|
||||
"Carrier AudioTrack: encoding=IEC61937 rate=${config.sampleRate} " +
|
||||
"mask=0x${android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND.toString(16)} " +
|
||||
"buffer=${config.bufferSize}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(false)
|
||||
// Not DefaultAudioProcessorChain: that one appends silence-skipping and Sonic whatever it is
|
||||
// constructed with, and either would rewrite carrier bytes. This chain has nothing in it, so
|
||||
// AudioProcessingPipeline is a straight passthrough. Speed is reported as applied without
|
||||
// resampling because the carrier delegate routes playback parameters to the AudioTrack, and
|
||||
// the sink refuses the carrier outright at any speed other than 1.0x.
|
||||
.setAudioProcessorChain(EmptyAudioProcessorChain())
|
||||
.setAudioOutputProvider(provider)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An [AudioProcessorChain] that owns no processors, for the MAT carrier delegate (#1804).
|
||||
*
|
||||
* `DefaultAudioProcessorChain` always contributes silence-skipping and Sonic; both rewrite samples,
|
||||
* which is fatal to a bit-exact IEC 61937 carrier. Speed is reported back unchanged because the
|
||||
* carrier delegate applies playback parameters at the AudioTrack, and the routing sink declines the
|
||||
* carrier entirely at any speed other than 1.0x.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private class EmptyAudioProcessorChain : DefaultAudioSink.AudioProcessorChain {
|
||||
override fun getAudioProcessors(): Array<AudioProcessor> = emptyArray()
|
||||
override fun applyPlaybackParameters(playbackParameters: PlaybackParameters): PlaybackParameters = playbackParameters
|
||||
override fun applySkipSilenceEnabled(skipSilenceEnabled: Boolean): Boolean = false
|
||||
override fun getMediaDuration(playoutDuration: Long): Long = playoutDuration
|
||||
override fun getSkippedOutputFrameCount(): Long = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,17 +611,46 @@ internal class DvSanitizingVideoRenderer(
|
||||
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
|
||||
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
|
||||
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
|
||||
//
|
||||
// Two invariants keep that safe (#1790):
|
||||
//
|
||||
// 1. Only outputs [AudioOutputCachePolicy] admits are parked, so the cache never holds a
|
||||
// scarce direct/passthrough route hostage while the next one is being created.
|
||||
// 2. Every flush is answered by exactly one onReleased, promptly.
|
||||
//
|
||||
// DefaultAudioSink increments a *static, process-wide* counter on every flush and decrements it
|
||||
// only from `Listener::onReleased`, which media3 delivers by posting to the playback looper — a
|
||||
// looper that is already dead by the time a player-release actually completes. A dropped callback
|
||||
// pins the counter above zero, and any nonzero value disables media3's escalation of *both* init
|
||||
// and write failures: `PendingExceptionHolder` refuses to arm its throw deadline and every retry
|
||||
// short-circuits, so a sink error never becomes a PlaybackException and playback hangs in
|
||||
// STATE_BUFFERING with no recovery. The wrapper therefore owns the listener set and answers the
|
||||
// flush itself when it parks the track, because a parked track is never going to release.
|
||||
//
|
||||
// Deferring that answer until the parked track is really evicted is tempting — it would let
|
||||
// media3 stay patient through the eviction, whose replacement is built while the old AudioTrack
|
||||
// is still going away — but it holds the counter above zero for the whole live track after the
|
||||
// first seek, which is the very hang above. So the eviction overlap is tolerated instead, as
|
||||
// upstream tolerates it: if the replacement does fail to allocate, media3 escalates on its own
|
||||
// 200ms deadline into the audio recovery ladder, and the buffering stall watchdog backs that up.
|
||||
//
|
||||
// Everything below is confined to the ExoPlayer playback thread: sink flush/release, provider
|
||||
// lookups, and media3's own onReleased delivery all run there.
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class RawPositionOutputProvider(
|
||||
internal class RawPositionOutputProvider(
|
||||
private val delegate: AudioOutputProvider,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
private val log: ((String, String, String) -> Unit)?,
|
||||
private val sdkInt: Int = Build.VERSION.SDK_INT
|
||||
) : AudioOutputProvider {
|
||||
|
||||
private var cachedOutput: RawPositionAudioOutput? = null
|
||||
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
|
||||
|
||||
/** Outputs whose real release was started but whose completion has not been observed yet. */
|
||||
private val unsettledReleases = LinkedHashSet<RawPositionAudioOutput>()
|
||||
|
||||
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
|
||||
|
||||
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config)
|
||||
@@ -533,14 +659,24 @@ private class RawPositionOutputProvider(
|
||||
val cached = cachedOutput
|
||||
if (cached != null && cachedConfig == config) {
|
||||
cachedOutput = null
|
||||
cached.markReacquired()
|
||||
return cached
|
||||
}
|
||||
cached?.forceRelease()
|
||||
cachedOutput = null
|
||||
cachedConfig = null
|
||||
|
||||
// The replacement is built while the evicted track is still going away. Upstream does the
|
||||
// same; the alternatives are worse (see the note above this class).
|
||||
val realOutput = delegate.getAudioOutput(config)
|
||||
cachedConfig = config
|
||||
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
|
||||
return RawPositionAudioOutput(
|
||||
delegate = realOutput,
|
||||
rawPositionUs = rawPositionUs,
|
||||
provider = this,
|
||||
mayCache = AudioOutputCachePolicy.mayCache(config.encoding, config.isOffload, sdkInt),
|
||||
log = log
|
||||
)
|
||||
}
|
||||
|
||||
fun returnToCache(output: RawPositionAudioOutput) {
|
||||
@@ -551,6 +687,14 @@ private class RawPositionOutputProvider(
|
||||
cachedOutput = output
|
||||
}
|
||||
|
||||
fun onRealReleaseStarted(output: RawPositionAudioOutput) {
|
||||
unsettledReleases.add(output)
|
||||
}
|
||||
|
||||
fun onReleaseSettled(output: RawPositionAudioOutput) {
|
||||
unsettledReleases.remove(output)
|
||||
}
|
||||
|
||||
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
|
||||
|
||||
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
|
||||
@@ -561,15 +705,21 @@ private class RawPositionOutputProvider(
|
||||
cachedOutput?.forceRelease()
|
||||
cachedOutput = null
|
||||
cachedConfig = null
|
||||
// Player teardown: media3 schedules the real AudioTrack release with a delay and posts the
|
||||
// completion back to the playback looper this call is in the middle of quitting, so nothing
|
||||
// will ever deliver it. Settle here rather than leak the accounting for the whole process.
|
||||
for (output in unsettledReleases.toList()) output.settleReleaseNow()
|
||||
unsettledReleases.clear()
|
||||
delegate.release()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class RawPositionAudioOutput(
|
||||
internal class RawPositionAudioOutput(
|
||||
private val delegate: AudioOutput,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val provider: RawPositionOutputProvider,
|
||||
private val mayCache: Boolean,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : AudioOutput {
|
||||
|
||||
@@ -578,6 +728,46 @@ private class RawPositionAudioOutput(
|
||||
private var writtenBytes = 0L
|
||||
private var failed = false
|
||||
|
||||
/**
|
||||
* DefaultAudioSink registers here instead of on the real output, so the wrapper can report the
|
||||
* releases media3 will not: a parked output never really releases, and a released output's
|
||||
* completion callback is posted to a playback looper that may already be gone. The set is
|
||||
* cleared on every release report and repopulated by the sink on the next acquisition, which
|
||||
* also stops one stale listener per reuse from piling up on the real output.
|
||||
*/
|
||||
private val listeners = mutableListOf<AudioOutput.Listener>()
|
||||
private var releaseSignalled = false
|
||||
|
||||
private val currentListener: AudioOutput.Listener?
|
||||
get() = listeners.lastOrNull()
|
||||
|
||||
private val forwarder = object : AudioOutput.Listener {
|
||||
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) {
|
||||
currentListener?.onPositionAdvancing(playoutStartSystemTimeMs)
|
||||
}
|
||||
|
||||
override fun onOffloadDataRequest() {
|
||||
currentListener?.onOffloadDataRequest()
|
||||
}
|
||||
|
||||
override fun onOffloadPresentationEnded() {
|
||||
currentListener?.onOffloadPresentationEnded()
|
||||
}
|
||||
|
||||
override fun onUnderrun() {
|
||||
currentListener?.onUnderrun()
|
||||
}
|
||||
|
||||
override fun onReleased() {
|
||||
provider.onReleaseSettled(this@RawPositionAudioOutput)
|
||||
signalReleased()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
delegate.addListener(forwarder)
|
||||
}
|
||||
|
||||
override fun getPositionUs(): Long {
|
||||
val pos = delegate.getPositionUs()
|
||||
rawPositionUs.set(pos)
|
||||
@@ -626,24 +816,50 @@ private class RawPositionAudioOutput(
|
||||
|
||||
override fun release() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
if (failed) {
|
||||
delegate.release()
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 25) {
|
||||
if (!failed && mayCache) {
|
||||
delegate.stop()
|
||||
delegate.flush()
|
||||
provider.returnToCache(this)
|
||||
} else {
|
||||
delegate.release()
|
||||
// A parked track is never going to release, so answer the flush now. Holding the sink's
|
||||
// pending-release count open for it would disable media3's escalation of every later sink
|
||||
// error, for as long as the track stays parked or live — the #1790 hang, re-armed by an
|
||||
// ordinary seek.
|
||||
signalReleased()
|
||||
return
|
||||
}
|
||||
startRealRelease()
|
||||
}
|
||||
|
||||
/** Releases for real even when the output would otherwise be cacheable. */
|
||||
fun forceRelease() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
startRealRelease()
|
||||
}
|
||||
|
||||
/** Reports a release whose completion callback can no longer be delivered. */
|
||||
fun settleReleaseNow() {
|
||||
provider.onReleaseSettled(this)
|
||||
signalReleased()
|
||||
}
|
||||
|
||||
/** Re-arms the wrapper for a fresh acquisition out of the provider's cache. */
|
||||
fun markReacquired() {
|
||||
releaseSignalled = false
|
||||
}
|
||||
|
||||
private fun startRealRelease() {
|
||||
provider.onRealReleaseStarted(this)
|
||||
delegate.release()
|
||||
}
|
||||
|
||||
private fun signalReleased() {
|
||||
if (releaseSignalled) return
|
||||
releaseSignalled = true
|
||||
val notified = listeners.toList()
|
||||
listeners.clear()
|
||||
for (listener in notified) listener.onReleased()
|
||||
}
|
||||
|
||||
override fun setVolume(volume: Float) = delegate.setVolume(volume)
|
||||
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
|
||||
override fun getAudioSessionId() = delegate.getAudioSessionId()
|
||||
@@ -651,8 +867,12 @@ private class RawPositionAudioOutput(
|
||||
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
|
||||
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
|
||||
override fun isStalled() = delegate.isStalled()
|
||||
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
|
||||
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
|
||||
override fun addListener(listener: AudioOutput.Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
override fun removeListener(listener: AudioOutput.Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
|
||||
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
|
||||
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
|
||||
|
||||
@@ -13,12 +13,18 @@ internal object SubtitleViewLayout {
|
||||
videoHeight: Int,
|
||||
pixelRatio: Float,
|
||||
resizeMode: Int,
|
||||
zoomScale: Float
|
||||
zoomScale: Float,
|
||||
anchorToScreen: Boolean = false
|
||||
): SubtitleViewDimensions? {
|
||||
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
|
||||
if (containerWidth <= 0 || containerHeight <= 0) return null
|
||||
|
||||
if (resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
|
||||
// Anchor-to-screen (#1730): size the text view to the full container so
|
||||
// media3's fractional text size and bottom-anchored cue placement compute
|
||||
// against the physical screen, letting subtitles render in the letterbox
|
||||
// bars instead of inside the video rect. Same geometry the non-FIT modes
|
||||
// below already use.
|
||||
if (anchorToScreen || resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
|
||||
return SubtitleViewDimensions(containerWidth, containerHeight)
|
||||
}
|
||||
|
||||
@@ -36,16 +42,35 @@ internal object SubtitleViewLayout {
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
pixelRatio: Float,
|
||||
resizeMode: Int,
|
||||
zoomScale: Float
|
||||
planeAspect: Float?
|
||||
): SubtitleViewDimensions? {
|
||||
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
|
||||
if (containerWidth <= 0 || containerHeight <= 0) return null
|
||||
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
|
||||
val visibleVideoBounds = fit(containerWidth, containerHeight, videoAspect)
|
||||
val layoutAspect = planeAspect?.takeIf { it.isFinite() && it > 0f }
|
||||
?: return visibleVideoBounds
|
||||
|
||||
// PGS/VOB cues are authored in video coordinates, but users expect them to
|
||||
// remain readable and fully visible when the video is cropped or zoomed.
|
||||
// Fit to the visible container instead of following the cropped video rect.
|
||||
return fit(containerWidth, containerHeight, videoAspect)
|
||||
// Media3 expresses bitmap cue positions and dimensions relative to the
|
||||
// subtitle composition plane, which can differ from a cropped video's
|
||||
// dimensions. Fit that plane inside the visible video bounds so cues keep
|
||||
// their authored geometry without entering the letterbox bars.
|
||||
return fit(visibleVideoBounds.width, visibleVideoBounds.height, layoutAspect)
|
||||
}
|
||||
|
||||
fun bitmapPlaneAspect(
|
||||
bitmapWidth: Int,
|
||||
bitmapHeight: Int,
|
||||
cueWidthFraction: Float,
|
||||
cueHeightFraction: Float
|
||||
): Float? {
|
||||
if (bitmapWidth <= 0 || bitmapHeight <= 0) return null
|
||||
if (!cueWidthFraction.isFinite() || cueWidthFraction <= 0f) return null
|
||||
if (!cueHeightFraction.isFinite() || cueHeightFraction <= 0f) return null
|
||||
|
||||
// cueWidth = bitmapWidth / planeWidth and cueHeight = bitmapHeight / planeHeight.
|
||||
val aspect = bitmapWidth.toDouble() * cueHeightFraction.toDouble() /
|
||||
(bitmapHeight.toDouble() * cueWidthFraction.toDouble())
|
||||
return aspect.toFloat().takeIf { it.isFinite() && it > 0f }
|
||||
}
|
||||
|
||||
private fun videoAspect(videoWidth: Int, videoHeight: Int, pixelRatio: Float): Float? {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
/**
|
||||
* Packs Dolby TrueHD access units into MAT frames carried in IEC 61937 bursts (#1804).
|
||||
*
|
||||
* Android will not bitstream raw TrueHD on the TV routes we have measured — the platform reports
|
||||
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
|
||||
* bitstream-capable. That is also the split Kodi models: it offers an "AudioTrack (IEC)" sink where
|
||||
* it packs the carrier itself, and treats handing raw TrueHD to Android as the fallback. Media3 only
|
||||
* ever does the latter, and at the stream rate rather than the carrier rate, which is why playback
|
||||
* wedges where other players work.
|
||||
*
|
||||
* The algorithm is a port of FFmpeg's `spdif_header_truehd` (libavformat/spdifenc.c, n8.0). Kodi's
|
||||
* `CAEBitstreamPacker` is *not* an equivalent reference: it wraps an already-assembled buffer,
|
||||
* whereas the timing-driven padding and MAT code placement live in FFmpeg's stateful packer.
|
||||
*
|
||||
* Output is a complete IEC 61937 burst per MAT frame:
|
||||
*
|
||||
* - 8 byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x0016 Pd=61424`
|
||||
* - 61424 bytes of MAT frame, 16-bit byte-swapped
|
||||
* - 8 bytes of zero padding
|
||||
* - 61440 bytes total, which is 20ms at 192kHz/8ch/16-bit
|
||||
*
|
||||
* Not thread safe; the sink drives it from the playback thread only.
|
||||
*/
|
||||
internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
|
||||
internal companion object {
|
||||
/** Payload bytes in one MAT frame. */
|
||||
const val MAT_FRAME_SIZE = 61424
|
||||
|
||||
/** Bytes from the start of one burst to the next, including preamble and trailing gap. */
|
||||
const val MAT_PKT_OFFSET = 61440
|
||||
|
||||
private const val BURST_HEADER_SIZE = 8
|
||||
private const val SYNCWORD1 = 0xF872
|
||||
private const val SYNCWORD2 = 0x4E1F
|
||||
private const val IEC61937_TRUEHD = 0x16
|
||||
|
||||
/** Minimum bytes needed to read an access unit header plus its major-sync probe. */
|
||||
private const val MIN_ACCESS_UNIT_LENGTH = 10
|
||||
|
||||
private val MAT_START_CODE = byteArrayOf(
|
||||
0x07, 0x9E.toByte(), 0x00, 0x03, 0x84.toByte(), 0x01, 0x01, 0x01, 0x80.toByte(), 0x00,
|
||||
0x56, 0xA5.toByte(), 0x3B, 0xF4.toByte(), 0x81.toByte(), 0x83.toByte(), 0x49, 0x80.toByte(),
|
||||
0x77, 0xE0.toByte()
|
||||
)
|
||||
private val MAT_MIDDLE_CODE = byteArrayOf(
|
||||
0xC3.toByte(), 0xC1.toByte(), 0x42, 0x49, 0x3B, 0xFA.toByte(), 0x82.toByte(), 0x83.toByte(),
|
||||
0x49, 0x80.toByte(), 0x77, 0xE0.toByte()
|
||||
)
|
||||
private val MAT_END_CODE = byteArrayOf(
|
||||
0xC3.toByte(), 0xC2.toByte(), 0xC0.toByte(), 0xC4.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x97.toByte(), 0x11
|
||||
)
|
||||
|
||||
private val MAT_CODES = arrayOf(MAT_START_CODE, MAT_MIDDLE_CODE, MAT_END_CODE)
|
||||
private val MAT_CODE_POSITIONS = intArrayOf(0, 30708, MAT_FRAME_SIZE - MAT_END_CODE.size)
|
||||
|
||||
/**
|
||||
* Length of the access unit starting at [offset], in bytes.
|
||||
*
|
||||
* The first 16-bit big-endian word carries the length in units of 16-bit words in its low 12
|
||||
* bits. Returns 0 when the buffer is too short or the length is nonsensical, which the caller
|
||||
* treats as "stop walking".
|
||||
*/
|
||||
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
|
||||
if (offset + 2 > limit) return 0
|
||||
val words = ((data[offset].toInt() and 0x0F) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
val length = words * 2
|
||||
return if (length < 4 || offset + length > limit) 0 else length
|
||||
}
|
||||
|
||||
/** True when the access unit at [offset] carries a major sync, which announces the rate. */
|
||||
private fun hasMajorSync(data: ByteArray, offset: Int, limit: Int): Boolean {
|
||||
if (offset + 8 > limit) return false
|
||||
return (data[offset + 4].toInt() and 0xFF) == 0xF8 &&
|
||||
(data[offset + 5].toInt() and 0xFF) == 0x72 &&
|
||||
(data[offset + 6].toInt() and 0xFF) == 0x6F
|
||||
}
|
||||
}
|
||||
|
||||
private val matBuffers = Array(2) { ByteArray(MAT_FRAME_SIZE) }
|
||||
private val burstBacking = Array(2) { ByteArray(MAT_PKT_OFFSET) }
|
||||
private val burstBuffers = Array(2) {
|
||||
ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
}
|
||||
private var burstIndex = 0
|
||||
private var matBufferIndex = 0
|
||||
private var matBufferFilled = 0
|
||||
private var previousTiming = 0
|
||||
private var previousSize = 0
|
||||
private var samplesPerFrame = 0
|
||||
|
||||
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
|
||||
override fun reset() {
|
||||
matBufferIndex = 0
|
||||
matBufferFilled = 0
|
||||
previousTiming = 0
|
||||
previousSize = 0
|
||||
samplesPerFrame = 0
|
||||
// The family is re-learned from the next major sync. Leaving it latched here would make every
|
||||
// later stream on this packer emit nothing.
|
||||
unsupportedStream = false
|
||||
java.util.Arrays.fill(matBuffers[0], 0)
|
||||
java.util.Arrays.fill(matBuffers[1], 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the stream announced a 44.1kHz-family rate.
|
||||
*
|
||||
* That family rides a 176.4kHz carrier rather than 192kHz, which changes the AudioTrack tuple the
|
||||
* whole path is built around. Rather than carry a second carrier configuration for a combination
|
||||
* that is essentially absent from real media, the sink reads this and falls back to decoding.
|
||||
*/
|
||||
override var unsupportedStream: Boolean = false
|
||||
private set
|
||||
|
||||
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int = Companion.accessUnitLength(data, offset, limit)
|
||||
|
||||
/**
|
||||
* Packs one access unit, returning a completed burst when this unit finished a MAT frame.
|
||||
*
|
||||
* The returned buffer is owned by the packer and reused, alternating between two so a burst
|
||||
* handed downstream stays valid while the next frame fills. Callers must submit or copy it before
|
||||
* the second following call. This is the audio path on low-power TV hardware — a fresh 61,440
|
||||
* byte array every 20ms would be roughly 3MB/s of garbage.
|
||||
*
|
||||
* Faithful to `spdif_header_truehd`: padding between units is derived from the delta of their
|
||||
* `input_timing` fields against the previous unit's on-carrier size, so the carrier keeps a fixed
|
||||
* rate even though the source frames vary in length.
|
||||
*
|
||||
* At most one burst can complete per access unit: padding is bounded below half a MAT frame and
|
||||
* an access unit is far smaller, so a single unit cannot span two frame boundaries.
|
||||
*/
|
||||
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
|
||||
if (length < MIN_ACCESS_UNIT_LENGTH) return null
|
||||
|
||||
if (hasMajorSync(data, offset, offset + length)) {
|
||||
// Rate is announced in the major sync; 40 samples per frame at 48kHz, doubling with the rate.
|
||||
val rateBits = when (data[offset + 7].toInt() and 0xFF) {
|
||||
0xBA -> (data[offset + 8].toInt() and 0xFF) shr 4
|
||||
0xBB -> (data[offset + 9].toInt() and 0xFF) shr 4
|
||||
else -> return null
|
||||
}
|
||||
// Bit 3 selects the 44.1kHz family, which rides a 176.4kHz carrier we do not build.
|
||||
unsupportedStream = (rateBits and 8) != 0
|
||||
if (unsupportedStream) return null
|
||||
samplesPerFrame = 40 shl (rateBits and 3)
|
||||
}
|
||||
if (unsupportedStream || samplesPerFrame == 0) return null
|
||||
|
||||
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
|
||||
var paddingRemaining = 0
|
||||
if (previousSize != 0) {
|
||||
val deltaSamples = (inputTiming - previousTiming) and 0xFFFF
|
||||
// One 48kHz-family frame is 1/1200s; the carrier runs at 768000*4 bytes/s, so the nominal
|
||||
// space per frame is 2560 bytes, which divides evenly by samplesPerFrame.
|
||||
val deltaBytes = deltaSamples * 2560 / samplesPerFrame
|
||||
paddingRemaining = deltaBytes - previousSize
|
||||
if (paddingRemaining < 0 || paddingRemaining >= MAT_FRAME_SIZE / 2) {
|
||||
// Timing we do not model; better a momentary rate wobble than a corrupt frame.
|
||||
paddingRemaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
var totalFrameSize = length
|
||||
var dataCursor = offset
|
||||
var dataRemaining = length
|
||||
var completed: ByteBuffer? = null
|
||||
|
||||
var nextCodeIndex = MAT_CODE_POSITIONS.indexOfFirst { matBufferFilled <= it }
|
||||
if (nextCodeIndex < 0) return null
|
||||
|
||||
while (paddingRemaining > 0 || dataRemaining > 0 || MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) {
|
||||
if (MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) {
|
||||
val code = MAT_CODES[nextCodeIndex]
|
||||
var codeLengthRemaining = code.size
|
||||
code.copyInto(matBuffers[matBufferIndex], matBufferFilled)
|
||||
matBufferFilled += code.size
|
||||
nextCodeIndex++
|
||||
|
||||
if (nextCodeIndex == MAT_CODES.size) {
|
||||
nextCodeIndex = 0
|
||||
// Last code of the frame: this buffer is complete, continue filling the other one.
|
||||
completed = toIec61937Burst(matBuffers[matBufferIndex])
|
||||
matBufferIndex = matBufferIndex xor 1
|
||||
matBufferFilled = 0
|
||||
// The inter-frame gap occupies carrier space too.
|
||||
codeLengthRemaining += MAT_PKT_OFFSET - MAT_FRAME_SIZE
|
||||
}
|
||||
|
||||
if (paddingRemaining > 0) {
|
||||
val countedAsPadding = minOf(paddingRemaining, codeLengthRemaining)
|
||||
paddingRemaining -= countedAsPadding
|
||||
codeLengthRemaining -= countedAsPadding
|
||||
}
|
||||
if (codeLengthRemaining > 0) totalFrameSize += codeLengthRemaining
|
||||
}
|
||||
|
||||
if (paddingRemaining > 0) {
|
||||
val padding = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, paddingRemaining)
|
||||
java.util.Arrays.fill(matBuffers[matBufferIndex], matBufferFilled, matBufferFilled + padding, 0)
|
||||
matBufferFilled += padding
|
||||
paddingRemaining -= padding
|
||||
if (paddingRemaining > 0) continue
|
||||
}
|
||||
|
||||
if (dataRemaining > 0) {
|
||||
val toCopy = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, dataRemaining)
|
||||
data.copyInto(matBuffers[matBufferIndex], matBufferFilled, dataCursor, dataCursor + toCopy)
|
||||
matBufferFilled += toCopy
|
||||
dataCursor += toCopy
|
||||
dataRemaining -= toCopy
|
||||
}
|
||||
}
|
||||
|
||||
previousSize = totalFrameSize
|
||||
previousTiming = inputTiming
|
||||
return completed
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a finished MAT frame in its IEC 61937 burst, into the next reusable buffer.
|
||||
*
|
||||
* Alternating between two keeps a burst already handed downstream intact while the following
|
||||
* frame is assembled.
|
||||
*/
|
||||
private fun toIec61937Burst(matFrame: ByteArray): ByteBuffer {
|
||||
burstIndex = burstIndex xor 1
|
||||
val burst = burstBuffers[burstIndex]
|
||||
val backing = burstBacking[burstIndex]
|
||||
putLittleEndianShort(backing, 0, SYNCWORD1)
|
||||
putLittleEndianShort(backing, 2, SYNCWORD2)
|
||||
putLittleEndianShort(backing, 4, IEC61937_TRUEHD)
|
||||
putLittleEndianShort(backing, 6, MAT_FRAME_SIZE)
|
||||
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
|
||||
var source = 0
|
||||
var destination = BURST_HEADER_SIZE
|
||||
while (source < MAT_FRAME_SIZE) {
|
||||
backing[destination] = matFrame[source + 1]
|
||||
backing[destination + 1] = matFrame[source]
|
||||
source += 2
|
||||
destination += 2
|
||||
}
|
||||
// Trailing bytes stay zero for the inter-frame gap; the backing array is never re-dirtied
|
||||
// beyond MAT_FRAME_SIZE, so they remain zero for the life of the packer.
|
||||
burst.limit(MAT_PKT_OFFSET)
|
||||
burst.position(0)
|
||||
return burst
|
||||
}
|
||||
|
||||
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
@@ -8,6 +11,9 @@ 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:
|
||||
@@ -16,8 +22,19 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
* stripping). This subclass intercepts the compression algorithm during track
|
||||
* header parsing:
|
||||
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
|
||||
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
|
||||
* - 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,
|
||||
@@ -36,15 +53,43 @@ class ZlibMatroskaExtractor(
|
||||
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) {
|
||||
@@ -59,12 +104,17 @@ class ZlibMatroskaExtractor(
|
||||
}
|
||||
|
||||
override fun integerElement(id: Int, value: Long) {
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
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)
|
||||
}
|
||||
@@ -76,27 +126,47 @@ class ZlibMatroskaExtractor(
|
||||
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 mark before super — the track output is created inside super's
|
||||
// endMasterElement, and the x-unknown format must never reach the queue.
|
||||
// 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) {
|
||||
zlibOutput?.activateLast()
|
||||
currentTrackUsesZlib = false
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +176,64 @@ class ZlibMatroskaExtractor(
|
||||
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
|
||||
@@ -138,3 +266,50 @@ class ZlibMatroskaExtractor(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,20 +39,48 @@ import kotlinx.coroutines.sync.withLock
|
||||
class MpvPlayerCore private constructor(
|
||||
private val context: Context,
|
||||
private val audioOnly: Boolean,
|
||||
private val hardwareDecoding: Boolean,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback,
|
||||
SurfacePlayerCore {
|
||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||
constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean = false,
|
||||
hardwareDecoding: Boolean = true
|
||||
) : this(context, audioOnly, hardwareDecoding, null, false)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?
|
||||
) : this(context, audioOnly, propertyWriter, true)
|
||||
) : this(context, audioOnly, true, propertyWriter, true)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerCore"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "gpu" else "gpu-next,gpu"
|
||||
}
|
||||
|
||||
/** Video-only paths. The plugin always constructs video cores with the
|
||||
@@ -96,7 +124,6 @@ class MpvPlayerCore private constructor(
|
||||
// output (#1482).
|
||||
private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
|
||||
// Frame rate matching
|
||||
private var frameRateManager: FrameRateManager? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
@@ -110,7 +137,6 @@ class MpvPlayerCore private constructor(
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||
}
|
||||
|
||||
// Audio focus
|
||||
private var audioFocusManager: AudioFocusManager? = null
|
||||
|
||||
@Volatile private var cachedPaused: Boolean = true
|
||||
@@ -275,7 +301,6 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "SurfaceView added to content view")
|
||||
}
|
||||
|
||||
// Create MpvPlayer on background thread via coroutine
|
||||
scope.launch {
|
||||
try {
|
||||
if (disposing) {
|
||||
@@ -296,10 +321,12 @@ class MpvPlayerCore private constructor(
|
||||
setOption("audio-display", "no")
|
||||
setOption("gapless-audio", "weak")
|
||||
} else {
|
||||
setOption("vo", "gpu")
|
||||
// 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.
|
||||
setOption("vo", initialVideoOutput(hardwareDecoding))
|
||||
setOption("gpu-context", "android")
|
||||
setOption("opengl-es", "yes")
|
||||
setOption("vd-lavc-film-grain", "cpu")
|
||||
if (displayFpsOverride != null) {
|
||||
setOption("display-fps-override", displayFpsOverride)
|
||||
}
|
||||
@@ -1041,9 +1068,13 @@ class MpvPlayerCore private constructor(
|
||||
"estimated-vf-fps" to getProperty("estimated-vf-fps"),
|
||||
"video-bitrate" to getProperty("video-bitrate"),
|
||||
"hwdec-current" to getProperty("hwdec-current"),
|
||||
"current-vo" to getProperty("current-vo"),
|
||||
"audio-codec-name" to getProperty("audio-codec-name"),
|
||||
"audio-params/samplerate" to getProperty("audio-params/samplerate"),
|
||||
"audio-params/hr-channels" to getProperty("audio-params/hr-channels"),
|
||||
"audio-params/format" to getProperty("audio-params/format"),
|
||||
"current-tracks/audio/demux-samplerate" to getProperty("current-tracks/audio/demux-samplerate"),
|
||||
"current-tracks/audio/demux-channel-count" to getProperty("current-tracks/audio/demux-channel-count"),
|
||||
"audio-bitrate" to getProperty("audio-bitrate"),
|
||||
"total-avsync-change" to getProperty("total-avsync-change"),
|
||||
"cache-used" to getProperty("cache-used"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.edde746.plezy.exoplayer.supportedMpvSpdifCodecs
|
||||
import com.edde746.plezy.shared.PlayerChannelBinding
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
@@ -74,8 +75,6 @@ open class MpvPlayerPlugin(
|
||||
private var initAttemptCounter = 0
|
||||
private var activeInitAttempt: Int? = null
|
||||
|
||||
// FlutterPlugin
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
channels.attach(binding)
|
||||
@@ -104,8 +103,6 @@ open class MpvPlayerPlugin(
|
||||
takeCoreForTeardown()?.dispose()
|
||||
}
|
||||
|
||||
// ActivityAware
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
@@ -141,8 +138,6 @@ open class MpvPlayerPlugin(
|
||||
Log.d(tag, "Detached from activity for config changes")
|
||||
}
|
||||
|
||||
// EventChannel.StreamHandler
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
channels.listen(events)
|
||||
}
|
||||
@@ -151,11 +146,9 @@ open class MpvPlayerPlugin(
|
||||
channels.cancel()
|
||||
}
|
||||
|
||||
// MethodChannel.MethodCallHandler
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(result)
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
@@ -167,6 +160,7 @@ open class MpvPlayerPlugin(
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
"getAudioSpdifCodecs" -> handleGetAudioSpdifCodecs(result)
|
||||
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
|
||||
"openContentFd" -> handleOpenContentFd(call, result)
|
||||
"closeContentFd" -> handleCloseContentFd(call, result)
|
||||
@@ -176,7 +170,21 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInitialize(result: MethodChannel.Result) {
|
||||
/**
|
||||
* Derives the `audio-spdif` value for the current audio route. mpv force-passthroughs
|
||||
* every codec named there with no decode fallback, so with no context to inspect the
|
||||
* route the conservative answer is the empty list — decode everything (#1703, #1991).
|
||||
*/
|
||||
private fun handleGetAudioSpdifCodecs(result: MethodChannel.Result) {
|
||||
val context: Context? = activity ?: applicationContext
|
||||
result.success(context?.let(::supportedMpvSpdifCodecs) ?: "")
|
||||
}
|
||||
|
||||
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
|
||||
// Whether the session will hardware-decode; decides the initial vo chain
|
||||
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
|
||||
// from older callers; hardware decode is the setting's default.
|
||||
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
|
||||
// Video cores need the Activity (surface/view hierarchy); the audio-only
|
||||
// core is built on the application context so it can outlive it.
|
||||
val coreContext: Context? = if (audioOnly) applicationContext else activity
|
||||
@@ -235,7 +243,7 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(coreContext, audioOnly).apply {
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
|
||||
@@ -6,6 +6,43 @@ object DeviceQuirks {
|
||||
val isEWaste: Boolean
|
||||
get() = isGooglePixelDevice || isGoogleTensorDevice
|
||||
|
||||
/**
|
||||
* Highest content frame rate for which [hasUnreliableTunneledPlayback] applies.
|
||||
*
|
||||
* Tunneling exists to meet the scheduling deadlines of high-frame-rate 4K, which Amazon
|
||||
* documents and recommends for exactly that workload, so the cut-off keeps 4K50/60
|
||||
* tunneled. Withdrawing tunneling is not free even below it — the device pipeline saves
|
||||
* CPU and power at any rate — but ~33ms per frame is well inside what media3's own
|
||||
* release path handles, and a juddering picture is the worse trade.
|
||||
*/
|
||||
const val MAX_UNRELIABLE_TUNNELED_FPS = 30f
|
||||
|
||||
/**
|
||||
* Fire TV Stick 4K (AFTMM) judders continuously on 23.976p direct play while tunneled.
|
||||
* The #1802 reporter A/B-tested it: tunneling off, every other setting unchanged, and
|
||||
* the judder goes away. Untunneled, media3's VideoFrameReleaseHelper owns frame release
|
||||
* and vsync-aligns it; tunneled, the device owns it and media3 notes it "may do frame
|
||||
* rate conversion".
|
||||
*
|
||||
* The mechanism is unconfirmed. Tunneling fires no VideoFrameMetadataListener and stops
|
||||
* media3 counting frames in the codec, so nothing app-side can measure the cadence or
|
||||
* drops. Only the trigger is established.
|
||||
*
|
||||
* Deliberately scoped to the one model and the one rate class that were tested: AFTMM
|
||||
* keeps tunneling for high-frame-rate content, and an unknown rate changes nothing.
|
||||
* Widen only on further reports.
|
||||
*/
|
||||
fun hasUnreliableTunneledPlayback(contentFrameRate: Float): Boolean = hasUnreliableTunneledPlayback(Build.MANUFACTURER, Build.MODEL, contentFrameRate)
|
||||
|
||||
internal fun hasUnreliableTunneledPlayback(
|
||||
manufacturer: String,
|
||||
model: String,
|
||||
contentFrameRate: Float
|
||||
): Boolean = manufacturer.equals("Amazon", ignoreCase = true) &&
|
||||
model.equals("AFTMM", ignoreCase = true) &&
|
||||
contentFrameRate > 0f &&
|
||||
contentFrameRate <= MAX_UNRELIABLE_TUNNELED_FPS
|
||||
|
||||
private val isGooglePixelDevice: Boolean
|
||||
get() = (
|
||||
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.edde746.plezy.watchnext
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
@@ -15,6 +17,19 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
||||
internal constructor(executor: Executor) : this(executor, false)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SystemShelfUpdateReceiver"
|
||||
|
||||
/**
|
||||
* Unique name of the periodic ShelfRefreshWorker job 2.13.0 shipped and
|
||||
* enqueued (KEEP, persisted by WorkManager). The worker is gone, so on an
|
||||
* updated device the persisted job would wake the process once more, fail
|
||||
* to instantiate the deleted class, and linger as a permanently failed
|
||||
* record; cancel it on the first update instead.
|
||||
*/
|
||||
internal const val LEGACY_SHELF_REFRESH_WORK = "plezy_shelf_refresh"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
@@ -23,6 +38,7 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
try {
|
||||
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
|
||||
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
cancelLegacyShelfRefreshWork(context.applicationContext)
|
||||
provider.migrateShelfSchema()
|
||||
} else {
|
||||
provider.restoreReadGrants()
|
||||
@@ -33,4 +49,13 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort by design: shelf maintenance must not die on a WorkManager failure. */
|
||||
private fun cancelLegacyShelfRefreshWork(context: Context) {
|
||||
try {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(LEGACY_SHELF_REFRESH_WORK)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel legacy shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class WatchNextPlugin() :
|
||||
companion object {
|
||||
private const val TAG = "WatchNextPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
||||
private const val SCHEMA_VERSION = 2
|
||||
internal const val SCHEMA_VERSION = 3
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.media.AudioFormat
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AudioOutputCachePolicyTest {
|
||||
|
||||
@Test
|
||||
fun decodedPcmIsCacheable() {
|
||||
assertTrue(
|
||||
AudioOutputCachePolicy.mayCache(
|
||||
encoding = AudioFormat.ENCODING_PCM_16BIT,
|
||||
isOffload = false,
|
||||
sdkInt = 34
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyPcmWidthIsCacheable() {
|
||||
val pcmEncodings = listOf(
|
||||
AudioFormat.ENCODING_PCM_8BIT,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
AudioFormat.ENCODING_PCM_24BIT_PACKED,
|
||||
AudioFormat.ENCODING_PCM_32BIT,
|
||||
AudioFormat.ENCODING_PCM_FLOAT
|
||||
)
|
||||
for (encoding in pcmEncodings) {
|
||||
assertTrue(
|
||||
"expected $encoding to be cacheable",
|
||||
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parking a bitstream track holds the platform's direct output, which is often single-instance. */
|
||||
@Test
|
||||
fun bitstreamOutputIsNeverCacheable() {
|
||||
val passthroughEncodings = listOf(
|
||||
AudioFormat.ENCODING_AC3,
|
||||
AudioFormat.ENCODING_E_AC3,
|
||||
AudioFormat.ENCODING_E_AC3_JOC,
|
||||
AudioFormat.ENCODING_DTS,
|
||||
AudioFormat.ENCODING_DTS_HD,
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD
|
||||
)
|
||||
for (encoding in passthroughEncodings) {
|
||||
assertFalse(
|
||||
"expected $encoding to be excluded from the cache",
|
||||
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offloadOutputIsNeverCacheable() {
|
||||
assertFalse(
|
||||
AudioOutputCachePolicy.mayCache(
|
||||
encoding = AudioFormat.ENCODING_PCM_16BIT,
|
||||
isOffload = true,
|
||||
sdkInt = 34
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachingIsOffBelowTheFlushableApiLevel() {
|
||||
assertFalse(
|
||||
AudioOutputCachePolicy.mayCache(
|
||||
encoding = AudioFormat.ENCODING_PCM_16BIT,
|
||||
isOffload = false,
|
||||
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT - 1
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
AudioOutputCachePolicy.mayCache(
|
||||
encoding = AudioFormat.ENCODING_PCM_16BIT,
|
||||
isOffload = false,
|
||||
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,38 +48,160 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() {
|
||||
assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true })
|
||||
fun dtsDecodeIsForcedToFfmpegWhenDirectOutputIsBlocked() {
|
||||
// Passthrough disabled (the #1995 report), downmix, normalization, or a failure block:
|
||||
// the stream decodes, and platform DTS decoders render silence on license-gated devices.
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { true }, { true }))
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { true }, { true }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsDecodeIsForcedToFfmpegWhenTheRouteCannotBitstream() {
|
||||
// Onn 4K Plus over HDMI to a Dolby-only TV: passthrough enabled (the Android TV default)
|
||||
// still decodes because the route carries no DTS shape (#1995).
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { false }))
|
||||
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bitstreamCapableDtsRoutesKeepThePlatformDecoderVisible() {
|
||||
// A bitstreaming session never engages a decoder; leaving the platform decoder visible
|
||||
// keeps the hardware-decoder tunneling gate exactly as it was.
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { true }))
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { true }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsVariantsFfmpegCannotClaimAreLeftAlone() {
|
||||
// DTS Express and DTS:X are not in FfmpegLibrary's mime map; hiding their platform
|
||||
// decoders would leave those streams with no decoder at all.
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd;profile=lbr", { true }, { false }))
|
||||
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.uhd", { true }, { false }))
|
||||
assertFalse(shouldForceFfmpegDtsDecode(MimeTypes.AUDIO_TRUEHD, { true }, { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonDtsMimesNeverConsultTheDtsProbes() {
|
||||
// Decoder selection runs this predicate for every mime, video included; the probes make
|
||||
// binder calls and must stay behind the mime gate.
|
||||
assertFalse(
|
||||
shouldForceFfmpegDtsDecode(
|
||||
MimeTypes.VIDEO_H265,
|
||||
{ throw AssertionError("directOutputBlocked consulted for a non-DTS mime") },
|
||||
{ throw AssertionError("routeCanBitstreamDts consulted for a non-DTS mime") }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@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 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListDropsCodecsTheRouteCannotBitstream() {
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream,
|
||||
// TrueHD and DTS do not (#1703).
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3 bitstreams, DTS does not (#1703).
|
||||
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
|
||||
|
||||
assertEquals("ac3,eac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListOmitsDtsHdOnDtsCoreOnlyRoutes() {
|
||||
// media3's passthrough probe would answer yes for DTS-HD here by downgrading to the
|
||||
// DTS core. mpv reads `dts,dts-hd` as `dts-hd` alone and force-passes DTS-HD MA, so
|
||||
// accepting that downgrade would break DTS too.
|
||||
val dtsCoreOnlyRoute = setOf(C.ENCODING_DTS)
|
||||
|
||||
assertEquals("dts", mpvSpdifCodecs { encoding -> encoding in dtsCoreOnlyRoute })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListKeepsDtsHdWhenTheRouteAdvertisesIt() {
|
||||
val dtsHdRoute = setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD)
|
||||
|
||||
assertEquals("dts,dts-hd", mpvSpdifCodecs { encoding -> encoding in dtsHdRoute })
|
||||
assertEquals("ac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteIsNeverOfferedBelowApi24() {
|
||||
// ENCODING_IEC61937 does not exist there.
|
||||
assertFalse(
|
||||
iecRouteSupported(
|
||||
sdkInt = 23,
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true },
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteRequiresASizableBufferOnEveryTier() {
|
||||
for (sdkInt in intArrayOf(25, 28, 29, 30, 32, 33, 34)) {
|
||||
assertFalse(
|
||||
"api $sdkInt",
|
||||
iecRouteSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeBuffer = { false },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true },
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routeOnApi24To28FollowsTheHdmiAdvertisement() {
|
||||
// No runtime oracle exists below API 29; only an HDMI AudioDeviceInfo that explicitly
|
||||
// advertises the IEC tuple can vouch for it. Shield Experience 8.x is API 28, and a flat
|
||||
// `false` on this tier force-decoded TrueHD on routes that genuinely carry it (#1991).
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
for (sdkInt in intArrayOf(25, 28)) {
|
||||
assertEquals(
|
||||
"api $sdkInt supported=$supported",
|
||||
supported,
|
||||
iecRouteSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
|
||||
directPlaybackSupported = { throw AssertionError("isDirectPlaybackSupported does not exist below API 29") },
|
||||
hdmiRouteAdvertised = { supported }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routeOnApi29To32FollowsTheDirectPlaybackProbe() {
|
||||
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
for (sdkInt in intArrayOf(29, 30, 32)) {
|
||||
assertEquals(
|
||||
"api $sdkInt supported=$supported",
|
||||
supported,
|
||||
iecRouteSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
|
||||
directPlaybackSupported = { supported },
|
||||
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routeOnApi33UsesTheBitstreamProbe() {
|
||||
// getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29
|
||||
// probe must not shadow it where the platform can answer precisely.
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
assertEquals(
|
||||
"supported=$supported",
|
||||
supported,
|
||||
iecRouteSupported(
|
||||
sdkInt = 33,
|
||||
canSizeBuffer = { true },
|
||||
bitstreamSupported = { supported },
|
||||
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") },
|
||||
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -8,12 +8,23 @@ import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.EOFException
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class BufferedTransformingTrackOutputTest {
|
||||
@Before
|
||||
fun enableMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun resetMedia3TestBoundsChecks() {
|
||||
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeTransformBuffersChunksAndEmitsOneNormalizedSample() {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import com.edde746.plezy.exoplayer.BufferingStallPolicy.Verdict
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BufferingStallPolicyTest {
|
||||
|
||||
private val position = 85_206L
|
||||
|
||||
private fun evaluate(
|
||||
elapsedMs: Long = BufferingStallPolicy.STALL_TIMEOUT_MS,
|
||||
baselinePositionMs: Long = position,
|
||||
currentPositionMs: Long = position,
|
||||
bufferedAheadMs: Long = 30_000L,
|
||||
playbackSpeed: Float = 1f,
|
||||
loadControlReady: Boolean? = null,
|
||||
loading: Boolean = true
|
||||
) = BufferingStallPolicy.evaluate(
|
||||
elapsedMs = elapsedMs,
|
||||
baselinePositionMs = baselinePositionMs,
|
||||
currentPositionMs = currentPositionMs,
|
||||
bufferedPositionMs = currentPositionMs + bufferedAheadMs,
|
||||
playbackSpeed = playbackSpeed,
|
||||
loadControlReady = loadControlReady,
|
||||
loading = loading
|
||||
)
|
||||
|
||||
@Test
|
||||
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
|
||||
// The #1790 shape on a high bitrate stream: the byte cap is full at a couple of seconds, the
|
||||
// renderers never become ready so media3 never asks its load control, and nothing moves.
|
||||
val belowTheDurationBar = 2_000L
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = true))
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLoaderStillFetchingWithATinyBufferIsAnOrdinaryRebuffer() {
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 500L, loading = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleNotYetFromTheLoadControlCannotVetoAStoppedLoader() {
|
||||
// media3 stops asking the moment a renderer goes unready — the failure this watchdog exists for
|
||||
// — so a `false` recorded before that must not outrank a loader that has since stopped.
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
|
||||
// The real failure this watchdog exists for, on a high bitrate stream: media3 released playback
|
||||
// off its byte target with only a couple of seconds buffered, and the player still will not move.
|
||||
val belowTheDurationBar = 2_000L
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar))
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loadControlReady = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLoadControlHoldingPlaybackBackIsNotIndictedWhileTheLoaderStillWants() {
|
||||
// Plenty buffered by duration, so the duration signal alone would indict; media3 saying "not
|
||||
// yet" while its loader keeps fetching is an ordinary rebuffer.
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L))
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L, loadControlReady = false))
|
||||
assertEquals(
|
||||
"nothing says this player could start",
|
||||
Verdict.STARVED,
|
||||
evaluate(bufferedAheadMs = 1_000L, loading = true, loadControlReady = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressOutranksTheLoadControlVerdict() {
|
||||
assertEquals(
|
||||
Verdict.HEALTHY,
|
||||
evaluate(currentPositionMs = position + 5_000, loadControlReady = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
|
||||
// Enough media to start at 1x, but the load control measures its bar in playout time, so at 2x
|
||||
// this player is still legitimately rebuffering rather than stalled.
|
||||
val justEnoughAtNormalSpeed = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1_000L
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed))
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed, playbackSpeed = 2f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSlowMotionPlayerNeedsLessMediaBeforeItIsIndicted() {
|
||||
val shortOfTheBar = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1_000L
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = shortOfTheBar))
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = shortOfTheBar, playbackSpeed = 0.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anImpossibleSpeedIsTreatedAsNormal() {
|
||||
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = 0f))
|
||||
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advancingPositionIsHealthy() {
|
||||
assertEquals(
|
||||
Verdict.HEALTHY,
|
||||
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advancingPositionOutranksAnExpiredTimeout() {
|
||||
assertEquals(
|
||||
Verdict.HEALTHY,
|
||||
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, currentPositionMs = position + 5_000)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clockJitterBelowTheEpsilonIsNotProgress() {
|
||||
assertEquals(
|
||||
Verdict.STALLED,
|
||||
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS - 1)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positionMovingBackwardsIsNotProgress() {
|
||||
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun frozenPositionWaitsOutTheTimeout() {
|
||||
assertEquals(Verdict.WAITING, evaluate(elapsedMs = BufferingStallPolicy.STALL_TIMEOUT_MS - 1))
|
||||
}
|
||||
|
||||
/** The #1790 shape: data available, nothing playing, no error raised. */
|
||||
@Test
|
||||
fun bufferedButFrozenIsStalled() {
|
||||
assertEquals(Verdict.STALLED, evaluate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyBufferIsStarved() {
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starvationOutranksAnExpiredTimeout() {
|
||||
assertEquals(
|
||||
Verdict.STARVED,
|
||||
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, bufferedAheadMs = 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `DefaultLoadControl` deliberately holds playback in `STATE_BUFFERING` until it has
|
||||
* [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS] buffered. A watchdog that indicts
|
||||
* the player inside that window is indicting it for obeying its own load control.
|
||||
*/
|
||||
@Test
|
||||
fun aBufferBelowTheLoadControlPlayStartThresholdIsStarved() {
|
||||
assertEquals(
|
||||
Verdict.STARVED,
|
||||
evaluate(
|
||||
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
|
||||
bufferedAheadMs = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theStallThresholdClearsTheLoadControlPlayStartThreshold() {
|
||||
assertTrue(
|
||||
"MIN_BUFFER_AHEAD_MS (${BufferingStallPolicy.MIN_BUFFER_AHEAD_MS}) must exceed the load " +
|
||||
"control's post-rebuffer play-start threshold " +
|
||||
"(${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS})",
|
||||
BufferingStallPolicy.MIN_BUFFER_AHEAD_MS > LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starvedIsStickyWhileTheBufferStaysThin() {
|
||||
assertEquals(
|
||||
Verdict.STARVED,
|
||||
evaluate(
|
||||
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
|
||||
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starvationAndProgressBothRestartTheStallClock() {
|
||||
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.STARVED))
|
||||
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.HEALTHY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waitingKeepsTheStallClockRunning() {
|
||||
assertFalse(BufferingStallPolicy.resetsStallClock(Verdict.WAITING))
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: a long network stall used to accrue the whole timeout while starved, so the very
|
||||
* first poll after the buffer refilled reported a stall that never happened and force-reloaded
|
||||
* a perfectly healthy rebuffer. Starvation restarts the clock, so the refilled buffer gets the
|
||||
* full timeout to start playing.
|
||||
*/
|
||||
@Test
|
||||
fun aRecoveringBufferIsNotStalledByTimeSpentStarved() {
|
||||
val starvedFor = 60_000L
|
||||
val starved = evaluate(elapsedMs = starvedFor, bufferedAheadMs = 0)
|
||||
assertEquals(Verdict.STARVED, starved)
|
||||
assertTrue(BufferingStallPolicy.resetsStallClock(starved))
|
||||
|
||||
// The watchdog re-baselines on that verdict, so the next poll starts from zero elapsed.
|
||||
assertEquals(
|
||||
Verdict.WAITING,
|
||||
evaluate(
|
||||
elapsedMs = BufferingStallPolicy.CHECK_INTERVAL_MS,
|
||||
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the DTS-HD -> IEC 61937 "DTS type IV" carrier against FFmpeg (#1988).
|
||||
*
|
||||
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
|
||||
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
|
||||
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
|
||||
* FFmpeg's own input and output at the 768kHz HD rate that fills the 192kHz/7.1 carrier.
|
||||
*
|
||||
* FFmpeg cannot encode Master Audio, so the access units are synthesized — real `dca` core frames,
|
||||
* each glued to a hand-built extension substream — by `dtshd_fixture_generator.py` beside the
|
||||
* fixtures; the golden is then FFmpeg's own packing:
|
||||
*
|
||||
* ```
|
||||
* ffmpeg -f dts -i dtshd_ma_access_units.bin -c:a copy -f spdif -dtshd_rate 768000 \
|
||||
* dtshd_ma_iec61937_golden.bin
|
||||
* ```
|
||||
*
|
||||
* One mid-stream unit is oversized on purpose, so the fixtures also pin FFmpeg's overflow answer:
|
||||
* strip to the core substream and hold that for ~60s.
|
||||
*/
|
||||
class DtsHdIecPackerTest {
|
||||
|
||||
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
|
||||
.use { it.readBytes() }
|
||||
|
||||
private val accessUnits by lazy { resource("dtshd_ma_access_units.bin") }
|
||||
private val golden by lazy { resource("dtshd_ma_iec61937_golden.bin") }
|
||||
|
||||
/** 512 samples at the fixture's 48kHz core rate on the 768kHz carrier model, in bytes. */
|
||||
private val burstSize = 32768
|
||||
|
||||
/** The whole point: our carrier and FFmpeg's are the same bytes. */
|
||||
@Test
|
||||
fun packedCarrierMatchesFfmpegByteForByte() {
|
||||
val packed = packAll(accessUnits)
|
||||
|
||||
assertEquals(
|
||||
"burst count differs from FFmpeg",
|
||||
golden.size / burstSize,
|
||||
packed.size / burstSize
|
||||
)
|
||||
// Compare per burst so a failure names the burst rather than dumping 480KB.
|
||||
for (index in 0 until packed.size / burstSize) {
|
||||
val from = index * burstSize
|
||||
val to = from + burstSize
|
||||
assertArrayEquals(
|
||||
"burst $index differs from FFmpeg",
|
||||
golden.copyOfRange(from, to),
|
||||
packed.copyOfRange(from, to)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each burst is a full IEC 61937 frame: preamble, then payload, then zero padding. */
|
||||
@Test
|
||||
fun everyBurstCarriesTheDtsHdPreamble() {
|
||||
val packed = packAll(accessUnits)
|
||||
|
||||
for (index in 0 until packed.size / burstSize) {
|
||||
val at = index * burstSize
|
||||
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
|
||||
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
|
||||
// Data type 0x11 with subtype 4: an 8192 IEC 60958 frame repetition period.
|
||||
assertEquals("burst $index Pc (IEC61937_DTSHD)", 0x0411, readLittleEndianShort(packed, at + 4))
|
||||
val payloadSize = 10 + 2 + burstAccessUnitSize(packed, at)
|
||||
assertEquals(
|
||||
"burst $index Pd must carry FFmpeg's (length & 0xf) == 0x8 alignment",
|
||||
((payloadSize + 0x17) and 0x0F.inv()) - 8,
|
||||
readLittleEndianShort(packed, at + 6)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Master Audio peak the carrier cannot hold is stripped to the always-fitting core substream,
|
||||
* and stays stripped for ~60s so the receiver does not flap between core and MA decoding. The
|
||||
* fixture's fifth unit is oversized on purpose; everything after it is core-only.
|
||||
*/
|
||||
@Test
|
||||
fun anOversizedUnitStripsToTheCoreAndHolds() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val packed = packAll(accessUnits, packer)
|
||||
|
||||
val fullUnit = 3904
|
||||
val coreOnly = 1884
|
||||
for (index in 0 until packed.size / burstSize) {
|
||||
val expected = if (index < 4) fullUnit else coreOnly
|
||||
assertEquals("burst $index payload size", expected, burstAccessUnitSize(packed, index * burstSize))
|
||||
}
|
||||
assertTrue("the strip must be observable for logging", packer.strippedToCore)
|
||||
assertFalse("stripping is a downgrade, not an unsupported stream", packer.unsupportedStream)
|
||||
}
|
||||
|
||||
/** Core frames and their glued extension substreams split as one access unit each. */
|
||||
@Test
|
||||
fun accessUnitsSpanTheCoreAndItsExtensionSubstreams() {
|
||||
val packer = DtsHdIecPacker()
|
||||
var units = 0
|
||||
var offset = 0
|
||||
while (offset < accessUnits.size) {
|
||||
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
|
||||
if (length == 0) break
|
||||
units++
|
||||
offset += length
|
||||
}
|
||||
assertEquals("every byte of the fixture belongs to a unit", accessUnits.size, offset)
|
||||
assertEquals(golden.size / burstSize, units)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams with core sometimes open with a stray HD frame that has no core (FFmpeg discards
|
||||
* these). It must be consumed as a unit so the stream keeps moving, but never carried: there is
|
||||
* no core to derive the burst period from.
|
||||
*/
|
||||
@Test
|
||||
fun aStrayLeadingExtensionSubstreamIsConsumedButNotCarried() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val firstUnit = packer.accessUnitLength(accessUnits, 0, accessUnits.size)
|
||||
val exssSize = 2020
|
||||
val stray = accessUnits.copyOfRange(firstUnit - exssSize, firstUnit)
|
||||
|
||||
val length = packer.accessUnitLength(stray, 0, stray.size)
|
||||
|
||||
assertEquals("the stray frame's own size field walks it", exssSize, length)
|
||||
assertNull("a coreless frame cannot ride the carrier", packer.packAccessUnit(stray, 0, length))
|
||||
assertFalse("a stray frame is dropped, not latched", packer.unsupportedStream)
|
||||
}
|
||||
|
||||
/** The wide (bHeaderSizeType=1) extension-substream header carries 20-bit size fields. */
|
||||
@Test
|
||||
fun wideHeaderExtensionSubstreamSizesAreParsed() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val core = accessUnits.copyOfRange(0, 1884)
|
||||
val exssSize = 4100
|
||||
val unit = core + wideHeaderExtensionSubstream(exssSize)
|
||||
|
||||
assertEquals(1884 + exssSize, packer.accessUnitLength(unit, 0, unit.size))
|
||||
}
|
||||
|
||||
/**
|
||||
* Little-endian and 14-bit core framings (S/PDIF and DTS-in-WAV captures) have no period mapping
|
||||
* on this carrier; they must latch the stream unsupported so the sink hands it to the decoder,
|
||||
* not silently consume it.
|
||||
*/
|
||||
@Test
|
||||
fun nonBigEndianCoreFramingLatchesTheStreamUnsupported() {
|
||||
for (sync in listOf(
|
||||
byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()),
|
||||
byteArrayOf(0x1F, 0xFF.toByte(), 0xE8.toByte(), 0x00),
|
||||
byteArrayOf(0xFF.toByte(), 0x1F, 0x00, 0xE8.toByte())
|
||||
)) {
|
||||
val packer = DtsHdIecPacker()
|
||||
val buffer = sync + ByteArray(64)
|
||||
|
||||
assertEquals(0, packer.accessUnitLength(buffer, 0, buffer.size))
|
||||
assertTrue("sync ${sync.joinToString { "%02x".format(it) }}", packer.unsupportedStream)
|
||||
}
|
||||
}
|
||||
|
||||
/** A 44.1kHz-family core maps to no IEC 61937-11 repetition period at 192kHz; it must decode. */
|
||||
@Test
|
||||
fun aFortyFourFamilyCoreLatchesTheStreamUnsupported() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val unit = accessUnits.copyOfRange(0, 1884)
|
||||
// SFREQ sits in bits [5:2] of byte 8; index 8 is 44100Hz.
|
||||
unit[8] = ((unit[8].toInt() and 0b11000011) or (8 shl 2)).toByte()
|
||||
|
||||
assertNull(packer.packAccessUnit(unit, 0, unit.size))
|
||||
assertTrue(packer.unsupportedStream)
|
||||
}
|
||||
|
||||
/**
|
||||
* The flags gate every later call, so leaving them latched across a reset would make a packer
|
||||
* that once saw a bad stream emit nothing (or log strips) for the rest of its life.
|
||||
*/
|
||||
@Test
|
||||
fun resetClearsTheLatchesAndReproducesTheStream() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val leSync = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
|
||||
packer.accessUnitLength(leSync, 0, leSync.size)
|
||||
assertTrue(packer.unsupportedStream)
|
||||
|
||||
packer.reset()
|
||||
|
||||
assertFalse("reset must clear the unsupported latch", packer.unsupportedStream)
|
||||
assertFalse("reset must clear the strip latch", packer.strippedToCore)
|
||||
assertArrayEquals(
|
||||
"a clean packer must reproduce the stream after a poisoned one",
|
||||
golden,
|
||||
packAll(accessUnits, packer)
|
||||
)
|
||||
}
|
||||
|
||||
/** The packer must not allocate a burst per unit; buffers alternate and are reused. */
|
||||
@Test
|
||||
fun burstBuffersAreReusedRatherThanAllocated() {
|
||||
val packer = DtsHdIecPacker()
|
||||
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
|
||||
var bursts = 0
|
||||
var offset = 0
|
||||
while (offset < accessUnits.size) {
|
||||
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
|
||||
if (length == 0) break
|
||||
packer.packAccessUnit(accessUnits, offset, length)?.let {
|
||||
seen[it] = true
|
||||
bursts++
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
assertTrue("expected multiple bursts", bursts > 2)
|
||||
assertEquals("buffers must alternate between exactly two", 2, seen.size)
|
||||
}
|
||||
|
||||
private fun packAll(units: ByteArray, packer: DtsHdIecPacker = DtsHdIecPacker()): ByteArray {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
var offset = 0
|
||||
while (offset < units.size) {
|
||||
val length = packer.accessUnitLength(units, offset, units.size)
|
||||
if (length == 0) break
|
||||
packer.packAccessUnit(units, offset, length)?.let { burst ->
|
||||
val copy = ByteArray(burst.remaining())
|
||||
burst.duplicate().get(copy)
|
||||
out.write(copy)
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** The 16-bit big-endian size field after the start code, read from the byte-swapped burst. */
|
||||
private fun burstAccessUnitSize(packed: ByteArray, burstOffset: Int): Int {
|
||||
// Payload bytes 10 and 11 land swapped within their 16-bit word: 10 -> +19, 11 -> +18.
|
||||
val high = packed[burstOffset + 8 + 11].toInt() and 0xFF
|
||||
val low = packed[burstOffset + 8 + 10].toInt() and 0xFF
|
||||
return (high shl 8) or low
|
||||
}
|
||||
|
||||
/** An extension substream whose header uses the wide 12/20-bit size fields. */
|
||||
private fun wideHeaderExtensionSubstream(size: Int): ByteArray {
|
||||
val frame = ByteArray(size)
|
||||
frame[0] = 0x64
|
||||
frame[1] = 0x58
|
||||
frame[2] = 0x20
|
||||
frame[3] = 0x25
|
||||
// After UserDefinedBits(8): nExtSSIndex(2)=0, bHeaderSizeType(1)=1, then 12 header-size bits
|
||||
// and 20 frame-size bits, all values stored minus one.
|
||||
var bits = 0L
|
||||
bits = (bits shl 2) or 0L
|
||||
bits = (bits shl 1) or 1L
|
||||
bits = (bits shl 12) or (32L - 1)
|
||||
bits = (bits shl 20) or (size.toLong() - 1)
|
||||
// 35 bits, MSB-aligned into bytes 5..9.
|
||||
val aligned = bits shl (40 - 35)
|
||||
for (i in 0 until 5) {
|
||||
frame[5 + i] = ((aligned shr (32 - 8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
@@ -10,8 +10,6 @@ class DvBitstreamSanitizerTest {
|
||||
|
||||
private val sanitizer = DvBitstreamSanitizer()
|
||||
|
||||
// --- HDR10+ SEI stripping (native DV codec path) ---
|
||||
|
||||
@Test
|
||||
fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
|
||||
val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02))
|
||||
@@ -85,8 +83,6 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(original, remainingBytes(buffer))
|
||||
}
|
||||
|
||||
// --- DV RPU/EL stripping (HEVC fallback path) ---
|
||||
|
||||
@Test
|
||||
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
|
||||
val vcl = annexBNal(1, byteArrayOf(0x01))
|
||||
@@ -111,8 +107,6 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||
}
|
||||
|
||||
// --- Buffer handling ---
|
||||
|
||||
@Test
|
||||
fun respectsPositionAndRestoresIt() {
|
||||
val prefix = byteArrayOf(0xAA.toByte(), 0xBB.toByte())
|
||||
@@ -245,8 +239,6 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */
|
||||
private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray {
|
||||
val startCode = if (startCodeLen == 3) byteArrayOf(0, 0, 1) else byteArrayOf(0, 0, 0, 1)
|
||||
|
||||
@@ -24,8 +24,6 @@ class EndOfStreamPolicyTest {
|
||||
frameStallMs = frameStallMs
|
||||
)
|
||||
|
||||
// isFinishedFile
|
||||
|
||||
@Test
|
||||
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
|
||||
// The reported failure: the clock ran a minute past the last frame.
|
||||
@@ -65,8 +63,6 @@ class EndOfStreamPolicyTest {
|
||||
assertFalse(isFinishedFile(hasPlaybackOutput = false))
|
||||
}
|
||||
|
||||
// fallbackStartPositionMs
|
||||
|
||||
@Test
|
||||
fun fallbackResumesStrictlyInsideTheMedia() {
|
||||
// The observed hand-off asked MPV to start 59_816ms past the end.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalSubtitleIdsTest {
|
||||
|
||||
@Test
|
||||
fun unmergedTagResolves() {
|
||||
assertTrue(ExternalSubtitleIds.isExternal("external_0"))
|
||||
assertEquals(0, ExternalSubtitleIds.indexOf("external_0"))
|
||||
assertEquals(7, ExternalSubtitleIds.indexOf("external_7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaSourceFactoryMergePrefixResolves() {
|
||||
// DefaultMediaSourceFactory always merges side-loaded subtitles with the
|
||||
// primary source, so MergingMediaPeriod prefixes the period index.
|
||||
assertTrue(ExternalSubtitleIds.isExternal("1:external_0"))
|
||||
assertEquals(0, ExternalSubtitleIds.indexOf("1:external_0"))
|
||||
assertEquals(2, ExternalSubtitleIds.indexOf("3:external_2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun containerSidecarOuterMergePrefixResolves() {
|
||||
// The container-sidecar path wraps that merge in a second MergingMediaSource.
|
||||
assertTrue(ExternalSubtitleIds.isExternal("0:1:external_0"))
|
||||
assertEquals(0, ExternalSubtitleIds.indexOf("0:1:external_0"))
|
||||
assertEquals(4, ExternalSubtitleIds.indexOf("0:2:external_4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun embeddedAndUnknownIdsAreNotExternal() {
|
||||
assertFalse(ExternalSubtitleIds.isExternal(null))
|
||||
assertFalse(ExternalSubtitleIds.isExternal("1:2"))
|
||||
assertFalse(ExternalSubtitleIds.isExternal("0:"))
|
||||
// A container child whose own id merely ends in the tag's text.
|
||||
assertFalse(ExternalSubtitleIds.isExternal("1:not_external_0"))
|
||||
assertNull(ExternalSubtitleIds.indexOf("1:2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedTagIsExternalWithoutAnIndex() {
|
||||
// Still a side-loaded track, but no usable URI lookup.
|
||||
assertTrue(ExternalSubtitleIds.isExternal("1:external_x"))
|
||||
assertNull(ExternalSubtitleIds.indexOf("1:external_x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writtenIdRoundTrips() {
|
||||
for (index in 0..3) {
|
||||
val id = ExternalSubtitleIds.idFor(index)
|
||||
assertEquals(index, ExternalSubtitleIds.indexOf(id))
|
||||
assertEquals(index, ExternalSubtitleIds.indexOf("1:$id"))
|
||||
assertEquals(index, ExternalSubtitleIds.indexOf("0:1:$id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.media.AudioDeviceInfo
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.AuxEffectInfo
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.Clock
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Routing and back-pressure contract for the IEC 61937 carrier: TrueHD/MAT (#1804) and DTS-HD
|
||||
* (#1988).
|
||||
*
|
||||
* The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting
|
||||
* the wrong sink handle a carrier codec.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class IecCarrierSinkTest {
|
||||
|
||||
private val accessUnits: ByteArray =
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
|
||||
.use { it.readBytes() }
|
||||
|
||||
private val golden: ByteArray =
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_iec61937_golden.bin"))
|
||||
.use { it.readBytes() }
|
||||
|
||||
private val dtsHdAccessUnits: ByteArray by lazy {
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_access_units.bin"))
|
||||
.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private val dtsHdGolden: ByteArray by lazy {
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_iec61937_golden.bin"))
|
||||
.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun trueHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
|
||||
.setChannelCount(6)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun dtsHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_DTS_HD)
|
||||
.setChannelCount(6)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
|
||||
|
||||
private fun sink(
|
||||
carrier: FakeSink = FakeSink(),
|
||||
normal: FakeSink = FakeSink(),
|
||||
routeAvailable: Boolean = true,
|
||||
blocked: Boolean = false
|
||||
) = IecCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
||||
|
||||
/**
|
||||
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
|
||||
* its raw ENCODING_DOLBY_TRUEHD path, which is the configuration that wedges on these devices.
|
||||
*/
|
||||
@Test
|
||||
fun trueHdWithoutACarrierRouteIsReportedUnsupportedSoItDecodes() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val carrierSink = sink(normal = normal, routeAvailable = false)
|
||||
|
||||
assertFalse(carrierSink.supportsFormat(trueHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trueHdWithACarrierRouteIsSupportedDirectly() {
|
||||
val carrierSink = sink()
|
||||
assertTrue(carrierSink.supportsFormat(trueHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdWithACarrierRouteIsSupportedDirectly() {
|
||||
val carrierSink = sink()
|
||||
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
* The route that advertises the carrier and raw `ENCODING_DTS_HD` at once is the one whose raw
|
||||
* path renders silence (#1988), so while the carrier route exists DTS-HD is the carrier or it is
|
||||
* decoded — never the normal sink's raw path, even when policy declines the carrier.
|
||||
*/
|
||||
@Test
|
||||
fun dtsHdOnACarrierRouteNeverFallsThroughToTheRawPath() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val blockedSink = sink(normal = normal, blocked = true)
|
||||
|
||||
assertFalse(blockedSink.supportsFormat(dtsHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, blockedSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes without the IEC carrier never had it to lose, and some of them bitstream raw DTS-HD
|
||||
* genuinely; the pre-carrier behavior is preserved there.
|
||||
*/
|
||||
@Test
|
||||
fun dtsHdWithoutACarrierRouteFallsThroughToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val carrierSink = sink(normal = normal, routeAvailable = false)
|
||||
|
||||
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
|
||||
@Test
|
||||
fun dtsExpressIsLeftToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val carrierSink = sink(normal = normal)
|
||||
val express = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_DTS_EXPRESS)
|
||||
.setSampleRate(48_000)
|
||||
.build()
|
||||
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(express))
|
||||
assertTrue(carrierSink.supportsFormat(express))
|
||||
}
|
||||
|
||||
/** Downmix and normalization already force decoding; the carrier must not override that. */
|
||||
@Test
|
||||
fun blockedDirectOutputDeclinesTheCarrier() {
|
||||
val carrierSink = sink(blocked = true)
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
* 44.1kHz-family streams ride a 176.4kHz carrier this path does not build. It has to be decided
|
||||
* from the format: the packer only learns the rate from the bitstream, long after selection, and
|
||||
* selecting the carrier for it would produce silence.
|
||||
*/
|
||||
@Test
|
||||
fun theFortyFourPointOneFamilyDeclinesTheCarrierFromTheFormatAlone() {
|
||||
val carrierSink = sink()
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 44_100)))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 176_400)))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 44_100)))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 88_200)))
|
||||
}
|
||||
|
||||
/** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */
|
||||
@Test
|
||||
fun aSpeedChangeDeclinesTheCarrier() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
* The real UI path: speed changes while the carrier is already live. Querying support after
|
||||
* setting speed passes trivially; nothing re-asks the sink unless capabilities are invalidated,
|
||||
* so without this the carrier keeps running and is handed a speed it cannot apply.
|
||||
*/
|
||||
@Test
|
||||
fun leavingUnitSpeedMidPlaybackInvalidatesCapabilities() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
|
||||
assertEquals("the renderer must be asked to re-evaluate", 1, listener.capabilityInvalidations)
|
||||
assertEquals(
|
||||
"the carrier delegate must never receive a speed it cannot apply",
|
||||
1f,
|
||||
carrier.lastPlaybackParameters.speed,
|
||||
0f
|
||||
)
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
}
|
||||
|
||||
/** Returning to 1x has to re-offer the carrier, or Atmos is lost until the next open. */
|
||||
@Test
|
||||
fun returningToUnitSpeedInvalidatesCapabilitiesAgain() {
|
||||
val carrierSink = sink()
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
|
||||
|
||||
assertEquals(2, listener.capabilityInvalidations)
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
}
|
||||
|
||||
/** A speed change that stays at 1x is not a transition and must not disturb selection. */
|
||||
@Test
|
||||
fun aNoOpSpeedChangeDoesNotInvalidateCapabilities() {
|
||||
val carrierSink = sink()
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
|
||||
|
||||
assertEquals(0, listener.capabilityInvalidations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed.
|
||||
* When they disagree the packer emits nothing, so consuming the input would turn the stream into
|
||||
* silence. The unit has to survive for whoever takes over.
|
||||
*/
|
||||
@Test
|
||||
fun aRateFamilyMismatchLeavesTheCarrierInsteadOfGoingSilent() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
val buffer = ByteBuffer.wrap(fortyFourFamilyAccessUnits())
|
||||
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
|
||||
assertFalse("a mismatch must apply back pressure, not report success", accepted)
|
||||
assertEquals("the offending access unit must stay in the buffer", 0, buffer.position())
|
||||
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
|
||||
assertEquals(
|
||||
"TrueHD must now decode rather than ride the carrier",
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
carrierSink.getFormatSupport(trueHdFormat())
|
||||
)
|
||||
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
|
||||
}
|
||||
|
||||
/**
|
||||
* The DTS-HD equivalent: a bitstream whose framing the carrier cannot ride (an S/PDIF-style
|
||||
* little-endian capture) must latch onto the decoder, and the offending unit must survive.
|
||||
*/
|
||||
@Test
|
||||
fun aDtsHdFramingMismatchLeavesTheCarrierInsteadOfGoingSilent() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
val littleEndianCapture = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
|
||||
val buffer = ByteBuffer.wrap(littleEndianCapture)
|
||||
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
|
||||
assertFalse("a mismatch must apply back pressure, not report success", accepted)
|
||||
assertEquals("the offending unit must stay in the buffer", 0, buffer.position())
|
||||
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
|
||||
assertEquals(
|
||||
"DTS-HD must now decode rather than ride the carrier or the raw path",
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
carrierSink.getFormatSupport(dtsHdFormat())
|
||||
)
|
||||
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
|
||||
}
|
||||
|
||||
/**
|
||||
* media3 resets every renderer disabled by a new selection before enabling the replacement, and
|
||||
* both audio renderers share this sink. That reset lands mid-handover, so a latch cleared there
|
||||
* would re-offer the carrier and loop straight back into the mismatch.
|
||||
*/
|
||||
@Test
|
||||
fun theMismatchLatchSurvivesTheResetThatAccompaniesTheHandover() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
carrierSink.flush()
|
||||
carrierSink.reset()
|
||||
|
||||
assertEquals(
|
||||
"the carrier must stay off across the handover",
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
carrierSink.getFormatSupport(trueHdFormat())
|
||||
)
|
||||
}
|
||||
|
||||
/** The latch is scoped to the sink, so a released sink starts clean. */
|
||||
@Test
|
||||
fun releasingTheSinkClearsTheMismatchLatch() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
carrierSink.release()
|
||||
|
||||
assertEquals(
|
||||
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
|
||||
carrierSink.getFormatSupport(trueHdFormat())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Genuine 44.1kHz TrueHD access units, encoder-produced. Flipping the rate nibble in a 48kHz
|
||||
* stream would invalidate the major sync checksum, which is not the case being modelled.
|
||||
*/
|
||||
private val fortyFourFamilyUnits by lazy {
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_441_access_units.bin"))
|
||||
.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun fortyFourFamilyAccessUnits(): ByteArray = fortyFourFamilyUnits
|
||||
|
||||
/**
|
||||
* The mismatch is a property of one stream. The owner signals the real boundary, because the sink
|
||||
* cannot see it: renderer resets and flushes happen constantly within a single item.
|
||||
*/
|
||||
@Test
|
||||
fun aNewMediaItemClearsTheMismatchLatch() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
assertEquals(
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
carrierSink.getFormatSupport(trueHdFormat())
|
||||
)
|
||||
|
||||
carrierSink.beginMediaItem()
|
||||
|
||||
assertEquals(
|
||||
"a new item must be able to bitstream again",
|
||||
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
|
||||
carrierSink.getFormatSupport(trueHdFormat())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The boundary hook runs on the app thread while the mismatch is found on the playback thread, so
|
||||
* a buffer from the outgoing stream can be handled after the new item began. That write must name
|
||||
* the stream it came from rather than disabling the carrier for its successor.
|
||||
*/
|
||||
@Test
|
||||
fun aLateMismatchFromThePreviousItemDoesNotPoisonTheNewOne() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
// The new item starts before the outgoing stream's last buffer is handled.
|
||||
carrierSink.beginMediaItem()
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
assertEquals(
|
||||
"the stale mismatch belongs to the previous item",
|
||||
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
|
||||
carrierSink.getFormatSupport(trueHdFormat())
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything that is not a carrier codec keeps going to the existing processed sink. */
|
||||
@Test
|
||||
fun otherFormatsAreLeftToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
val carrierSink = sink(normal = normal)
|
||||
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
|
||||
|
||||
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(ac3))
|
||||
assertTrue(carrierSink.supportsFormat(ac3))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this exists for: a delegate that refuses a burst part-way through a sample must
|
||||
* not cost the access units behind it. media3 re-delivers the same buffer after a false return,
|
||||
* so the carrier has to resume from where it stopped and come out byte-identical.
|
||||
*/
|
||||
@Test
|
||||
fun aRefusedBurstMidSampleLosesNoAccessUnits() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
// Refuse every third burst once, so rejections land inside samples rather than between them.
|
||||
carrier.refuseEveryNth = 3
|
||||
|
||||
val produced = feedWholeStream(carrierSink, accessUnits)
|
||||
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
|
||||
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
|
||||
}
|
||||
|
||||
/** With no back pressure the same stream must produce exactly the same bytes. */
|
||||
@Test
|
||||
fun theCarrierOutputMatchesTheGoldenStream() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
|
||||
assertArrayEquals(golden, feedWholeStream(carrierSink, accessUnits))
|
||||
}
|
||||
|
||||
/** A DTS-HD stream must select the DTS packer and come out matching FFmpeg's bytes. */
|
||||
@Test
|
||||
fun theDtsHdCarrierOutputMatchesTheGoldenStream() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
assertArrayEquals(dtsHdGolden, feedWholeStream(carrierSink, dtsHdAccessUnits))
|
||||
}
|
||||
|
||||
/** Back pressure must not cost DTS-HD units either; bursts and units are one-to-one there. */
|
||||
@Test
|
||||
fun aRefusedDtsHdBurstLosesNoAccessUnits() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
carrier.refuseEveryNth = 3
|
||||
|
||||
val produced = feedWholeStream(carrierSink, dtsHdAccessUnits)
|
||||
assertArrayEquals(dtsHdGolden, produced)
|
||||
assertTrue(carrier.refusals > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Burst timestamps come from the carrier cadence, not the closing access unit. A DTS-HD burst is
|
||||
* 10666.67us of carrier — not a whole number — so the cadence must be derived from cumulative
|
||||
* carrier frames; per-burst rounding would drift ~62ms over a movie.
|
||||
*/
|
||||
@Test
|
||||
fun dtsHdBurstTimestampsFollowTheExactCarrierCadence() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
feedWholeStream(carrierSink, dtsHdAccessUnits)
|
||||
|
||||
val framesPerBurst = 32768L / IecCarrier.BYTES_PER_FRAME
|
||||
carrier.bufferTimesUs.forEachIndexed { index, timeUs ->
|
||||
assertEquals(
|
||||
"burst $index timestamp",
|
||||
index * framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE,
|
||||
timeUs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A refused burst must be re-offered before any further input is taken. */
|
||||
@Test
|
||||
fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrier.refuseNext = true
|
||||
|
||||
val buffer = ByteBuffer.wrap(accessUnits)
|
||||
// Drive until the first refusal is observed.
|
||||
while (buffer.hasRemaining() && carrierSink.handleBuffer(buffer, 0L, 1)) Unit
|
||||
|
||||
assertTrue("expected a refusal to leave input unconsumed", buffer.hasRemaining())
|
||||
assertTrue(carrierSink.hasPendingData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistentControlsReachBothDelegates() {
|
||||
val carrier = FakeSink()
|
||||
val normal = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier, normal = normal)
|
||||
|
||||
carrierSink.setVolume(0.5f)
|
||||
carrierSink.setAudioSessionId(7)
|
||||
|
||||
assertEquals(0.5f, carrier.lastVolume, 0f)
|
||||
assertEquals(0.5f, normal.lastVolume, 0f)
|
||||
assertEquals(7, carrier.sessionId)
|
||||
assertEquals(7, normal.sessionId)
|
||||
}
|
||||
|
||||
/** Silence skipping deletes "silent" bytes, which would break the carrier's frame cadence. */
|
||||
@Test
|
||||
fun silenceSkippingNeverReachesTheCarrier() {
|
||||
val carrier = FakeSink()
|
||||
val normal = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier, normal = normal)
|
||||
|
||||
carrierSink.setSkipSilenceEnabled(true)
|
||||
|
||||
assertTrue(normal.skipSilence)
|
||||
assertFalse("the carrier delegate must never skip silence", carrier.skipSilence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val inputConfig = AudioSink.AudioSinkConfig.Builder(trueHdFormat())
|
||||
.setPreferredBufferSizeOverride(4_096)
|
||||
.setTimeline(Timeline.EMPTY)
|
||||
.setMediaPeriodId(MediaSource.MediaPeriodId("period"))
|
||||
.build()
|
||||
|
||||
carrierSink.configure(inputConfig)
|
||||
|
||||
val configured = checkNotNull(carrier.configuredConfig)
|
||||
assertEquals(inputConfig.preferredBufferSizeOverride, configured.preferredBufferSizeOverride)
|
||||
assertSame(inputConfig.timeline, configured.timeline)
|
||||
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
|
||||
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
|
||||
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
|
||||
}
|
||||
|
||||
/** DTS-HD rides the same PCM-shaped carrier tuple TrueHD does. */
|
||||
@Test
|
||||
fun theDtsHdCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
|
||||
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
|
||||
|
||||
val configured = checkNotNull(carrier.configuredConfig)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
|
||||
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
|
||||
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonCarrierAudioIsConfiguredOnTheNormalSink() {
|
||||
val carrier = FakeSink()
|
||||
val normal = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier, normal = normal)
|
||||
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
|
||||
val config = audioSinkConfig(ac3)
|
||||
|
||||
carrierSink.configure(config)
|
||||
|
||||
assertSame(config, normal.configuredConfig)
|
||||
assertSame(ac3, normal.configuredConfig?.format)
|
||||
assertEquals(null, carrier.configuredConfig)
|
||||
}
|
||||
|
||||
private fun feedWholeStream(carrierSink: IecCarrierSink, units: ByteArray): ByteArray {
|
||||
val buffer = ByteBuffer.wrap(units)
|
||||
var guard = 0
|
||||
// media3 retries handleBuffer until it returns true, even once the buffer is fully consumed;
|
||||
// a refusal of the stream's final burst leaves it pending with nothing remaining to read.
|
||||
while ((buffer.hasRemaining() || carrierSink.hasPendingData()) && guard++ < 10_000) {
|
||||
carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
}
|
||||
val carrier = carrierSinkDelegate(carrierSink)
|
||||
return carrier.written.toByteArray()
|
||||
}
|
||||
|
||||
private fun carrierSinkDelegate(sink: IecCarrierSink): FakeSink {
|
||||
val field = IecCarrierSink::class.java.getDeclaredField("carrierSink")
|
||||
field.isAccessible = true
|
||||
return field.get(sink) as FakeSink
|
||||
}
|
||||
|
||||
private class RecordingSinkListener : AudioSink.Listener {
|
||||
var capabilityInvalidations = 0
|
||||
override fun onPositionDiscontinuity() = Unit
|
||||
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
|
||||
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = Unit
|
||||
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = Unit
|
||||
override fun onAudioSinkError(audioSinkError: Exception) = Unit
|
||||
override fun onAudioCapabilitiesChanged() {
|
||||
capabilityInvalidations++
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
|
||||
private class FakeSink : AudioSink {
|
||||
val written = ByteArrayOutputStream()
|
||||
val bufferTimesUs = mutableListOf<Long>()
|
||||
var configuredConfig: AudioSink.AudioSinkConfig? = null
|
||||
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
var lastVolume: Float = -1f
|
||||
var sessionId: Int = -1
|
||||
var skipSilence: Boolean = false
|
||||
var refuseNext: Boolean = false
|
||||
var refuseEveryNth: Int = 0
|
||||
var refusals: Int = 0
|
||||
var lastPlaybackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var offered = 0
|
||||
|
||||
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
|
||||
offered++
|
||||
val refuse = refuseNext || (refuseEveryNth > 0 && offered % refuseEveryNth == 0)
|
||||
if (refuse) {
|
||||
refuseNext = false
|
||||
refusals++
|
||||
// Refuse once per burst: the retry must be accepted or the stream cannot progress.
|
||||
offered++
|
||||
return false
|
||||
}
|
||||
val copy = ByteArray(buffer.remaining())
|
||||
buffer.duplicate().get(copy)
|
||||
written.write(copy)
|
||||
bufferTimesUs.add(presentationTimeUs)
|
||||
buffer.position(buffer.limit())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
|
||||
configuredConfig = audioSinkConfig
|
||||
}
|
||||
|
||||
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
|
||||
override fun getFormatSupport(format: Format) = formatSupport
|
||||
override fun setVolume(volume: Float) {
|
||||
lastVolume = volume
|
||||
}
|
||||
override fun setAudioSessionId(audioSessionId: Int) {
|
||||
sessionId = audioSessionId
|
||||
}
|
||||
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
|
||||
skipSilence = skipSilenceEnabled
|
||||
}
|
||||
override fun getSkipSilenceEnabled() = skipSilence
|
||||
|
||||
override fun setListener(listener: AudioSink.Listener) = Unit
|
||||
override fun setPlayerId(playerId: PlayerId?) = Unit
|
||||
override fun setClock(clock: Clock) = Unit
|
||||
override fun getCurrentPositionUs(sourceEnded: Boolean) = 0L
|
||||
override fun play() = Unit
|
||||
override fun handleDiscontinuity() = Unit
|
||||
override fun playToEndOfStream() = Unit
|
||||
override fun isEnded() = false
|
||||
override fun hasPendingData() = false
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
|
||||
lastPlaybackParameters = playbackParameters
|
||||
}
|
||||
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
override fun setAudioAttributes(audioAttributes: AudioAttributes) = Unit
|
||||
override fun getAudioAttributes(): AudioAttributes? = null
|
||||
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) = Unit
|
||||
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) = Unit
|
||||
override fun getAudioTrackBufferSizeUs() = 0L
|
||||
override fun enableTunnelingV21() = Unit
|
||||
override fun disableTunneling() = Unit
|
||||
override fun pause() = Unit
|
||||
override fun flush() = Unit
|
||||
override fun reset() = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* [LoadControlPolicyTest] checks that the policy's arithmetic satisfies the ordering we believe
|
||||
* media3 requires. This file checks the belief, by handing every value the settings screen can
|
||||
* produce to a real `DefaultLoadControl.Builder`.
|
||||
*
|
||||
* The distinction matters because media3 validates with Guava `Preconditions.checkArgument`, not
|
||||
* `androidx.media3.common.util.Assertions` and not a JVM `assert`. It is an unconditional throw
|
||||
* that survives R8 — nothing in `proguard-rules.pro` elides it — so a rejected pair is an
|
||||
* `IllegalArgumentException` out of player construction on a user's device, not a degraded buffer.
|
||||
* A media3 upgrade that tightens the ordering should fail here rather than there.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class LoadControlPolicyBuildTest {
|
||||
|
||||
/** Both Auto memory tiers, their boundary, and the unknown-memory case. */
|
||||
private val memoryTiers = intArrayOf(0, 512, 1024, 2048, 2049, 4096, 8192)
|
||||
|
||||
private fun build(tier: LoadControlPolicy.BufferTier, availableMB: Int): DefaultLoadControl {
|
||||
val durations = LoadControlPolicy.bufferDurations(tier, availableMB)
|
||||
return DefaultLoadControl.Builder()
|
||||
.setTargetBufferBytes(LoadControlPolicy.MIN_TARGET_BYTES)
|
||||
.setPrioritizeTimeOverSizeThresholds(false)
|
||||
.setBufferDurationsMs(
|
||||
durations.minBufferMs,
|
||||
durations.maxBufferMs,
|
||||
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
|
||||
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyTierBuildsOnEveryMemoryTier() {
|
||||
for (tier in LoadControlPolicy.BufferTier.entries) {
|
||||
for (availableMB in memoryTiers) {
|
||||
// build() throws IllegalArgumentException on a rejected ordering; reaching the next
|
||||
// iteration is the assertion.
|
||||
build(tier, availableMB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTierNameThisVersionDoesNotKnowStillBuilds() {
|
||||
// The tier crosses a MethodChannel as a string, so the enum is not the only possible input:
|
||||
// a Dart build newer than this native build can send a name that is not in `entries`.
|
||||
for (wire in arrayOf(null, "", "gigantic", "EXTRA_LARGE", "0")) {
|
||||
build(LoadControlPolicy.BufferTier.fromWire(wire), availableMB = 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theShippedAutoTiersAreUnchanged() {
|
||||
// #1816 adds a setting; it must not quietly retune the default everyone already has.
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(15_000, 50_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2048)
|
||||
)
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(30_000, 60_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2049)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,13 @@ package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private const val MIB = 1024 * 1024
|
||||
|
||||
class LoadControlPolicyTest {
|
||||
|
||||
// autoTargetBufferBytes
|
||||
|
||||
@Test
|
||||
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
|
||||
assertEquals(
|
||||
@@ -63,8 +62,6 @@ class LoadControlPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
// readAheadSeconds
|
||||
|
||||
@Test
|
||||
fun readAheadReportsSecondsAtAKnownBitrate() {
|
||||
// 64MiB of the #1618 stream (103_341 kbps) is ~5.2s — under the 15s minBufferMs, so the
|
||||
@@ -84,4 +81,108 @@ class LoadControlPolicyTest {
|
||||
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, 0L))
|
||||
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
|
||||
}
|
||||
|
||||
/**
|
||||
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
|
||||
* a JVM assert, so a bad pair takes down `DefaultLoadControl.Builder.build()` in release too.
|
||||
* Every path through the policy has to satisfy it, including inputs no UI can produce.
|
||||
*/
|
||||
private fun assertBuildable(durations: LoadControlPolicy.BufferDurations) {
|
||||
assertTrue(
|
||||
"bufferForPlaybackMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS}) must not exceed " +
|
||||
"minBufferMs (${durations.minBufferMs})",
|
||||
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS <= durations.minBufferMs
|
||||
)
|
||||
assertTrue(
|
||||
"bufferForPlaybackAfterRebufferMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS}) " +
|
||||
"must not exceed minBufferMs (${durations.minBufferMs})",
|
||||
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS <= durations.minBufferMs
|
||||
)
|
||||
assertTrue(
|
||||
"minBufferMs (${durations.minBufferMs}) must not exceed maxBufferMs (${durations.maxBufferMs})",
|
||||
durations.minBufferMs <= durations.maxBufferMs
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoKeepsTheConservativeTierOnALowMemoryDevice() {
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(15_000, 50_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 1024)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoKeepsTheRoomierTierWhenMemoryAllows() {
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(30_000, 60_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 4096)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownMemoryTakesTheConservativeTier() {
|
||||
// availMem is reported as non-positive when unknown; guessing roomy there would be the
|
||||
// wrong way to be wrong on the device that can least afford it.
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(15_000, 50_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 0)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExplicitTierIgnoresTheMemoryTier() {
|
||||
// The whole point of #1816: a low-memory box may still want a deep buffer, because the
|
||||
// constraint it is working around is the network, not the heap. Auto is the only mode that
|
||||
// gets to consult memory.
|
||||
assertEquals(
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 8192),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 512)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTiersMatchTheJellyfinClients() {
|
||||
// Borrowed verbatim from jellyfin-androidtv BufferLength and jellyfin-android
|
||||
// PlayerViewModel so the same words mean the same thing across Jellyfin clients.
|
||||
// Changing these silently diverges Plezy from that shared vocabulary.
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(50_000, 120_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 4096)
|
||||
)
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferDurations(80_000, 240_000),
|
||||
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.EXTRA_LARGE, availableMB = 4096)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyTierProducesABuildablePairOnEveryMemoryTier() {
|
||||
// A pair media3 rejects is a crash on play, not a bad buffer.
|
||||
for (tier in LoadControlPolicy.BufferTier.entries) {
|
||||
for (availableMB in intArrayOf(0, 512, 1024, 2048, 4096, 8192)) {
|
||||
assertBuildable(LoadControlPolicy.bufferDurations(tier, availableMB))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWireNamesMatchTheDartNativeValues() {
|
||||
assertEquals(LoadControlPolicy.BufferTier.LARGE, LoadControlPolicy.BufferTier.fromWire("large"))
|
||||
assertEquals(
|
||||
LoadControlPolicy.BufferTier.EXTRA_LARGE,
|
||||
LoadControlPolicy.BufferTier.fromWire("extra_large")
|
||||
)
|
||||
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("auto"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnrecognisedOrAbsentTierFallsBackToAuto() {
|
||||
// A Dart/Kotlin skew must degrade to the shipped defaults, not to a deep buffer nobody
|
||||
// asked for on a device that may not afford it.
|
||||
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(null))
|
||||
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(""))
|
||||
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("EXTRA_LARGE"))
|
||||
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("extraLarge"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.LoadControl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The wrapper has to be a complete [LoadControl], not a partial one.
|
||||
*
|
||||
* media3's interface gives almost every member a default implementation that throws
|
||||
* `IllegalStateException`, so a forwarder this class forgets does not fail to compile — it takes the
|
||||
* player down at construction, or the first time ExoPlayer asks. Building a real player is the only
|
||||
* assertion that covers all of them at once, including methods a media3 upgrade adds later.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ObservingLoadControlTest {
|
||||
|
||||
private fun loadControl() = DefaultLoadControl.Builder().setTargetBufferBytes(4 * 1024 * 1024).build()
|
||||
|
||||
@Test
|
||||
fun aRealPlayerCanBeBuiltAndReleasedThroughTheWrapper() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val player = ExoPlayer.Builder(activity).setLoadControl(ObservingLoadControl(loadControl())).build()
|
||||
try {
|
||||
// Construction alone already routes through getBackBufferDurationUs and getAllocator.
|
||||
assertEquals(ExoPlayer.STATE_IDLE, player.playbackState)
|
||||
} finally {
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theStartPlaybackVerdictIsRecordedAndResettable() {
|
||||
// A fake delegate, because a fabricated `Parameters` cannot satisfy DefaultLoadControl's own
|
||||
// timeline lookups. What matters here is that the wrapper reports what the delegate said.
|
||||
val delegate = FakeLoadControl()
|
||||
val observing = ObservingLoadControl(delegate)
|
||||
assertNull("nothing asked yet", observing.startPlaybackVerdict)
|
||||
|
||||
delegate.startPlayback = true
|
||||
assertTrue(observing.shouldStartPlayback(delegate.lastParameters))
|
||||
assertEquals(true, observing.startPlaybackVerdict)
|
||||
|
||||
delegate.startPlayback = false
|
||||
assertEquals(false, observing.shouldStartPlayback(delegate.lastParameters))
|
||||
assertEquals(false, observing.startPlaybackVerdict)
|
||||
|
||||
observing.reset()
|
||||
assertNull("a fresh buffering window must not inherit the last verdict", observing.startPlaybackVerdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherCallsReachTheDelegate() {
|
||||
val delegate = loadControl()
|
||||
val observing = ObservingLoadControl(delegate)
|
||||
val playerId = androidx.media3.exoplayer.analytics.PlayerId.UNSET
|
||||
observing.onPrepared(playerId)
|
||||
try {
|
||||
assertSame(delegate.getAllocator(playerId)::class.java, observing.getAllocator(playerId)::class.java)
|
||||
assertEquals(delegate.getBackBufferDurationUs(playerId), observing.getBackBufferDurationUs(playerId))
|
||||
assertEquals(delegate.retainBackBufferFromKeyframe(playerId), observing.retainBackBufferFromKeyframe(playerId))
|
||||
} finally {
|
||||
observing.onReleased(playerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal complete [LoadControl], so the wrapper's recording can be exercised on its own. */
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private class FakeLoadControl : LoadControl {
|
||||
var startPlayback = false
|
||||
|
||||
val lastParameters: LoadControl.Parameters = LoadControl.Parameters(
|
||||
androidx.media3.exoplayer.analytics.PlayerId.UNSET,
|
||||
androidx.media3.common.Timeline.EMPTY,
|
||||
LoadControl.EMPTY_MEDIA_PERIOD_ID,
|
||||
/* playbackPositionUs= */
|
||||
0L,
|
||||
/* bufferedDurationUs= */
|
||||
0L,
|
||||
/* playbackSpeed= */
|
||||
1f,
|
||||
/* playWhenReady= */
|
||||
true,
|
||||
/* rebuffering= */
|
||||
false,
|
||||
/* targetLiveOffsetUs= */
|
||||
androidx.media3.common.C.TIME_UNSET,
|
||||
/* lastRebufferRealtimeMs= */
|
||||
androidx.media3.common.C.TIME_UNSET
|
||||
)
|
||||
|
||||
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean = startPlayback
|
||||
|
||||
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = true
|
||||
|
||||
override fun onPrepared(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
|
||||
|
||||
override fun onTracksSelected(
|
||||
parameters: LoadControl.Parameters,
|
||||
trackGroups: androidx.media3.exoplayer.source.TrackGroupArray,
|
||||
trackSelections: Array<out androidx.media3.exoplayer.trackselection.ExoTrackSelection>
|
||||
) = Unit
|
||||
|
||||
override fun onStopped(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
|
||||
|
||||
override fun onReleased(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
|
||||
|
||||
override fun getAllocator(
|
||||
playerId: androidx.media3.exoplayer.analytics.PlayerId
|
||||
): androidx.media3.exoplayer.upstream.Allocator = androidx.media3.exoplayer.upstream.DefaultAllocator(true, 64 * 1024)
|
||||
|
||||
override fun getBackBufferDurationUs(playerId: androidx.media3.exoplayer.analytics.PlayerId): Long = 0L
|
||||
|
||||
override fun retainBackBufferFromKeyframe(playerId: androidx.media3.exoplayer.analytics.PlayerId): Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.extractor.text.CuesWithTiming
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
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
|
||||
|
||||
/**
|
||||
* Regression coverage for #1953: display sets with several composition objects must
|
||||
* produce one cue per object, and Normal-state display sets (palette-only fades,
|
||||
* cached-object reuse) must keep the composition instead of blanking it.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class PgsCompositionParserTest {
|
||||
|
||||
private companion object {
|
||||
const val WHITE = 0xFFFFFFFF.toInt()
|
||||
const val FADED_WHITE = 0x80FFFFFF.toInt()
|
||||
const val YELLOW = 0xFFFFF000.toInt()
|
||||
const val GREEN_BT709 = 0xFF00D800.toInt()
|
||||
const val GREEN_BT601 = 0xFF00FF01.toInt()
|
||||
|
||||
val WHITE_ENTRY = intArrayOf(1, 235, 128, 128, 255)
|
||||
val YELLOW_ENTRY = intArrayOf(2, 210, 146, 16, 255)
|
||||
|
||||
const val STATE_NORMAL = 0x00
|
||||
const val STATE_EPOCH_START = 0x80
|
||||
}
|
||||
|
||||
private class Buf {
|
||||
private val out = ByteArrayOutputStream()
|
||||
|
||||
fun u8(value: Int) = apply { out.write(value) }
|
||||
|
||||
fun u16(value: Int) = apply {
|
||||
out.write(value ushr 8)
|
||||
out.write(value)
|
||||
}
|
||||
|
||||
fun u24(value: Int) = apply {
|
||||
out.write(value ushr 16)
|
||||
out.write(value ushr 8)
|
||||
out.write(value)
|
||||
}
|
||||
|
||||
fun raw(bytes: ByteArray) = apply { out.write(bytes) }
|
||||
|
||||
fun build(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
private fun segment(type: Int, payload: ByteArray): ByteArray = Buf().u8(type).u16(payload.size).raw(payload).build()
|
||||
|
||||
private fun objectRef(id: Int, x: Int, y: Int, cropped: Boolean = false): ByteArray = Buf()
|
||||
.u16(id)
|
||||
.u8(0) // Window id.
|
||||
.u8(if (cropped) 0x80 else 0)
|
||||
.u16(x)
|
||||
.u16(y)
|
||||
.raw(if (cropped) ByteArray(8) else ByteArray(0)) // Crop rectangle.
|
||||
.build()
|
||||
|
||||
private fun pcs(planeWidth: Int, planeHeight: Int, state: Int, vararg refs: ByteArray): ByteArray {
|
||||
val payload = Buf()
|
||||
.u16(planeWidth)
|
||||
.u16(planeHeight)
|
||||
.u8(0x10) // Frame rate.
|
||||
.u16(0) // Composition number.
|
||||
.u8(state)
|
||||
.u8(0) // Palette update flag.
|
||||
.u8(0) // Palette id.
|
||||
.u8(refs.size)
|
||||
refs.forEach { payload.raw(it) }
|
||||
return segment(0x16, payload.build())
|
||||
}
|
||||
|
||||
private fun pds(vararg entries: IntArray): ByteArray {
|
||||
val payload = Buf().u8(0).u8(0) // Palette id, version.
|
||||
entries.forEach { e -> payload.u8(e[0]).u8(e[1]).u8(e[2]).u8(e[3]).u8(e[4]) }
|
||||
return segment(0x14, payload.build())
|
||||
}
|
||||
|
||||
private fun ods(id: Int, width: Int, height: Int, rle: ByteArray): ByteArray = segment(
|
||||
0x15,
|
||||
Buf().u16(id).u8(0).u8(0xC0).u24(rle.size + 4).u16(width).u16(height).raw(rle).build()
|
||||
)
|
||||
|
||||
/** RLE for a solid bitmap: one literal palette-index byte per pixel. */
|
||||
private fun solidRle(width: Int, height: Int, colorIndex: Int): ByteArray = ByteArray(width * height) { colorIndex.toByte() }
|
||||
|
||||
private fun end(): ByteArray = segment(0x80, ByteArray(0))
|
||||
|
||||
private fun displaySet(vararg segments: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
segments.forEach { out.write(it) }
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun parseCues(parser: PgsCompositionParser, sample: ByteArray): List<Cue> {
|
||||
val outputs = mutableListOf<CuesWithTiming>()
|
||||
parser.parse(sample, SubtitleParser.OutputOptions.allCues()) { outputs.add(it) }
|
||||
assertEquals(1, outputs.size)
|
||||
return outputs[0].cues
|
||||
}
|
||||
|
||||
/** Epoch-start display set: white 4x2 object at (100, 900) and yellow 6x3 object at (200, 100). */
|
||||
private fun twoObjectEpochStart(): ByteArray = displaySet(
|
||||
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 100, 900), objectRef(2, 200, 100)),
|
||||
pds(WHITE_ENTRY, YELLOW_ENTRY),
|
||||
ods(1, 4, 2, solidRle(4, 2, 1)),
|
||||
ods(2, 6, 3, solidRle(6, 3, 2)),
|
||||
end()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun twoCompositionObjectsProduceIndependentlyPositionedCues() {
|
||||
val cues = parseCues(PgsCompositionParser(), twoObjectEpochStart())
|
||||
|
||||
assertEquals(2, cues.size)
|
||||
val (dialogue, sign) = cues
|
||||
assertEquals(100f / 1920f, dialogue.position, 1e-6f)
|
||||
assertEquals(900f / 1080f, dialogue.line, 1e-6f)
|
||||
assertEquals(4f / 1920f, dialogue.size, 1e-6f)
|
||||
assertEquals(2f / 1080f, dialogue.bitmapHeight, 1e-6f)
|
||||
assertEquals(WHITE, dialogue.bitmap!!.getPixel(0, 0))
|
||||
assertEquals(200f / 1920f, sign.position, 1e-6f)
|
||||
assertEquals(100f / 1080f, sign.line, 1e-6f)
|
||||
assertEquals(6, sign.bitmap!!.width)
|
||||
assertEquals(3, sign.bitmap!!.height)
|
||||
assertEquals(YELLOW, sign.bitmap!!.getPixel(5, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paletteOnlyFadeUpdateKeepsBothObjectsAndPreservesUnsentEntries() {
|
||||
val parser = PgsCompositionParser()
|
||||
parseCues(parser, twoObjectEpochStart())
|
||||
|
||||
// Normal-state fade step: same references, only the white entry's alpha changes.
|
||||
val cues = parseCues(
|
||||
parser,
|
||||
displaySet(
|
||||
pcs(1920, 1080, STATE_NORMAL, objectRef(1, 100, 900), objectRef(2, 200, 100)),
|
||||
pds(intArrayOf(1, 235, 128, 128, 128)),
|
||||
end()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, cues.size)
|
||||
assertEquals(FADED_WHITE, cues[0].bitmap!!.getPixel(0, 0))
|
||||
// The yellow entry was not resent; an in-place palette update must preserve it.
|
||||
assertEquals(YELLOW, cues[1].bitmap!!.getPixel(0, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroObjectDisplaySetClearsTheComposition() {
|
||||
val parser = PgsCompositionParser()
|
||||
parseCues(parser, twoObjectEpochStart())
|
||||
|
||||
val cues = parseCues(parser, displaySet(pcs(1920, 1080, STATE_NORMAL), end()))
|
||||
|
||||
assertTrue(cues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun epochStartReleasesCachedObjects() {
|
||||
val parser = PgsCompositionParser()
|
||||
parseCues(parser, twoObjectEpochStart())
|
||||
|
||||
// New epoch referencing an object it never defines: nothing may leak across.
|
||||
val cues = parseCues(
|
||||
parser,
|
||||
displaySet(pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 100, 900)), end())
|
||||
)
|
||||
|
||||
assertTrue(cues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetDropsEpochState() {
|
||||
val parser = PgsCompositionParser()
|
||||
parseCues(parser, twoObjectEpochStart())
|
||||
|
||||
parser.reset()
|
||||
val cues = parseCues(
|
||||
parser,
|
||||
displaySet(pcs(1920, 1080, STATE_NORMAL, objectRef(1, 100, 900)), end())
|
||||
)
|
||||
|
||||
assertTrue(cues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fragmentedObjectReassembles() {
|
||||
val rle = solidRle(4, 2, 1)
|
||||
val first = segment(
|
||||
0x15,
|
||||
Buf().u16(1).u8(0).u8(0x80).u24(rle.size + 4).u16(4).u16(2).raw(rle.copyOfRange(0, 3)).build()
|
||||
)
|
||||
val continuation = segment(
|
||||
0x15,
|
||||
Buf().u16(1).u8(0).u8(0x40).raw(rle.copyOfRange(3, rle.size)).build()
|
||||
)
|
||||
|
||||
val cues = parseCues(
|
||||
PgsCompositionParser(),
|
||||
displaySet(pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 0, 0)), pds(WHITE_ENTRY), first, continuation, end())
|
||||
)
|
||||
|
||||
assertEquals(1, cues.size)
|
||||
assertEquals(WHITE, cues[0].bitmap!!.getPixel(3, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun croppedFirstReferenceDoesNotCorruptTheSecond() {
|
||||
val cues = parseCues(
|
||||
PgsCompositionParser(),
|
||||
displaySet(
|
||||
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 100, 900, cropped = true), objectRef(2, 200, 100)),
|
||||
pds(WHITE_ENTRY, YELLOW_ENTRY),
|
||||
ods(1, 4, 2, solidRle(4, 2, 1)),
|
||||
ods(2, 6, 3, solidRle(6, 3, 2)),
|
||||
end()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, cues.size)
|
||||
assertEquals(200f / 1920f, cues[1].position, 1e-6f)
|
||||
assertEquals(100f / 1080f, cues[1].line, 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runLengthEncodedBitmapsDecode() {
|
||||
// Short-form color run (16 white pixels), then a long-form run (100 pixels).
|
||||
val shortRun = byteArrayOf(0x00, 0x90.toByte(), 0x01)
|
||||
val longRun = byteArrayOf(0x00, 0xC0.toByte(), 0x64, 0x01)
|
||||
|
||||
val cues = parseCues(
|
||||
PgsCompositionParser(),
|
||||
displaySet(
|
||||
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 0, 0), objectRef(2, 0, 200)),
|
||||
pds(WHITE_ENTRY),
|
||||
ods(1, 16, 1, shortRun),
|
||||
ods(2, 100, 1, longRun),
|
||||
end()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, cues.size)
|
||||
assertEquals(WHITE, cues[0].bitmap!!.getPixel(15, 0))
|
||||
assertEquals(WHITE, cues[1].bitmap!!.getPixel(99, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibCompressedSampleParses() {
|
||||
val deflater = Deflater()
|
||||
deflater.setInput(twoObjectEpochStart())
|
||||
deflater.finish()
|
||||
val compressed = ByteArray(4096)
|
||||
val length = deflater.deflate(compressed)
|
||||
deflater.end()
|
||||
|
||||
val cues = parseCues(PgsCompositionParser(), compressed.copyOf(length))
|
||||
|
||||
assertEquals(2, cues.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paletteMatrixFollowsPlaneHeight() {
|
||||
val greenEntry = intArrayOf(1, 145, 34, 54, 255)
|
||||
|
||||
val hdCues = parseCues(
|
||||
PgsCompositionParser(),
|
||||
displaySet(
|
||||
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 0, 0)),
|
||||
pds(greenEntry),
|
||||
ods(1, 1, 1, solidRle(1, 1, 1)),
|
||||
end()
|
||||
)
|
||||
)
|
||||
val sdCues = parseCues(
|
||||
PgsCompositionParser(),
|
||||
displaySet(
|
||||
pcs(720, 576, STATE_EPOCH_START, objectRef(1, 0, 0)),
|
||||
pds(greenEntry),
|
||||
ods(1, 1, 1, solidRle(1, 1, 1)),
|
||||
end()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(GREEN_BT709, hdCues[0].bitmap!!.getPixel(0, 0))
|
||||
assertEquals(GREEN_BT601, sdCues[0].bitmap!!.getPixel(0, 0))
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.AudioOutput
|
||||
import androidx.media3.exoplayer.audio.AudioOutputProvider
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `DefaultAudioSink` increments a static, process-wide pending-release counter on every flush and
|
||||
* decrements it only when the output reports `onReleased`. A dropped report pins that counter above
|
||||
* zero for the life of the process, which silently disables media3's own AudioTrack-init retry and
|
||||
* turns any later init failure into a permanent buffering hang (#1790). These tests pin the
|
||||
* wrapper's side of that contract: exactly one report per release, from every path.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class RawPositionAudioOutputTest {
|
||||
|
||||
private val rawPositionUs = AtomicLong(Long.MIN_VALUE)
|
||||
|
||||
private fun config(
|
||||
encoding: Int = AudioFormat.ENCODING_PCM_16BIT,
|
||||
isOffload: Boolean = false,
|
||||
isTunneling: Boolean = false
|
||||
): AudioOutputProvider.OutputConfig = AudioOutputProvider.OutputConfig.Builder()
|
||||
.setEncoding(encoding)
|
||||
.setSampleRate(48_000)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
|
||||
.setBufferSize(40_000)
|
||||
.setIsOffload(isOffload)
|
||||
.setIsTunneling(isTunneling)
|
||||
.build()
|
||||
|
||||
private fun provider(delegate: FakeOutputProvider) = RawPositionOutputProvider(delegate, rawPositionUs, log = null, sdkInt = 34)
|
||||
|
||||
private fun acquire(
|
||||
provider: RawPositionOutputProvider,
|
||||
config: AudioOutputProvider.OutputConfig
|
||||
): Pair<RawPositionAudioOutput, RecordingListener> {
|
||||
val output = provider.getAudioOutput(config) as RawPositionAudioOutput
|
||||
val listener = RecordingListener()
|
||||
output.addListener(listener)
|
||||
return output to listener
|
||||
}
|
||||
|
||||
/**
|
||||
* A parked track is never going to release, so its flush has to be answered at once. Deferring
|
||||
* until the eviction would hold `DefaultAudioSink`'s process-wide pending-release count above
|
||||
* zero for the whole live track after the first seek, and any nonzero value stops media3
|
||||
* escalating *both* init and write failures — the #1790 hang, re-armed by an ordinary seek.
|
||||
*/
|
||||
@Test
|
||||
fun aParkedOutputAnswersItsFlushImmediately() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (output, listener) = acquire(provider, config())
|
||||
|
||||
output.release()
|
||||
|
||||
assertEquals("the flush must be answered while the track is parked", 1, listener.releasedCount)
|
||||
assertFalse("a parked output must keep its AudioTrack", delegate.outputs.single().released)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realReleaseReportsOnlyOnceTheDelegateConfirms() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
|
||||
|
||||
output.release()
|
||||
|
||||
val real = delegate.outputs.single()
|
||||
assertTrue("a bitstream output must really release", real.released)
|
||||
assertEquals("release must not be reported before the AudioTrack is gone", 0, listener.releasedCount)
|
||||
|
||||
real.confirmReleased()
|
||||
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseIsReportedOnceWhenTheDelegateNeverConfirms() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
|
||||
|
||||
// Player teardown: the sink flushes, then media3 posts the release completion to a playback
|
||||
// looper that is already quitting, so the confirmation never arrives.
|
||||
output.release()
|
||||
provider.release()
|
||||
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLateDelegateConfirmationDoesNotReportTwice() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
|
||||
|
||||
output.release()
|
||||
output.settleReleaseNow()
|
||||
delegate.outputs.single().confirmReleased()
|
||||
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (first, _) = acquire(provider, config())
|
||||
|
||||
first.release()
|
||||
val (second, _) = acquire(provider, config())
|
||||
|
||||
assertSame(first, second)
|
||||
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sink charges the pending-release counter once per flush and builds a fresh listener per
|
||||
* acquisition, so every reuse cycle has to settle its own flush. One unanswered cycle is enough
|
||||
* to pin the counter above zero for the rest of the process.
|
||||
*/
|
||||
@Test
|
||||
fun everyReuseCycleAnswersItsOwnFlush() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
|
||||
repeat(5) {
|
||||
val (output, listener) = acquire(provider, config())
|
||||
output.release()
|
||||
assertEquals("cycle $it must report exactly one release", 1, listener.releasedCount)
|
||||
}
|
||||
|
||||
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
|
||||
}
|
||||
|
||||
/** A stale listener per cycle on the real output is how the accounting drifted in the first place. */
|
||||
@Test
|
||||
fun reuseDoesNotAccumulateListenersOnTheRealOutput() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
|
||||
repeat(5) {
|
||||
val (output, _) = acquire(provider, config())
|
||||
output.release()
|
||||
}
|
||||
|
||||
assertEquals(1, delegate.outputs.single().listeners.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forwardedEventsReachTheCurrentListenerOnly() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (first, firstListener) = acquire(provider, config())
|
||||
first.release()
|
||||
val (_, secondListener) = acquire(provider, config())
|
||||
|
||||
delegate.outputs.single().emitUnderrun()
|
||||
|
||||
assertEquals(0, firstListener.underrunCount)
|
||||
assertEquals(1, secondListener.underrunCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* The replacement is deliberately built while the evicted track is still going away. Refusing
|
||||
* until it confirms looks safer, but the refusal reaches media3 as an init failure with no
|
||||
* pending release to excuse it, so its 200ms deadline starts immediately — and on the TVs this
|
||||
* cache exists for a teardown can outlast that, turning an ordinary config change into a
|
||||
* playback error. Upstream tolerates the same overlap.
|
||||
*/
|
||||
@Test
|
||||
fun anEvictedParkedOutputIsReplacedWithoutWaitingForItsRelease() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (output, _) = acquire(provider, config())
|
||||
|
||||
output.release()
|
||||
provider.getAudioOutput(config(isTunneling = true))
|
||||
|
||||
assertTrue("the parked output must have been released", delegate.outputs.first().released)
|
||||
assertEquals("the replacement must not wait for the release", 2, delegate.outputs.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* The evicted track's flush was already answered when it parked, so its late confirmation —
|
||||
* however late — must not report a second release and drive the process-wide count negative.
|
||||
*/
|
||||
@Test
|
||||
fun aSlowEvictionReleaseDoesNotReportASecondTime() {
|
||||
val delegate = FakeOutputProvider()
|
||||
val provider = provider(delegate)
|
||||
val (output, listener) = acquire(provider, config())
|
||||
|
||||
output.release()
|
||||
val evicted = delegate.outputs.first()
|
||||
provider.getAudioOutput(config(isTunneling = true))
|
||||
assertEquals(1, listener.releasedCount)
|
||||
|
||||
evicted.confirmReleased()
|
||||
evicted.confirmReleased()
|
||||
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
private class RecordingListener : AudioOutput.Listener {
|
||||
var releasedCount = 0
|
||||
var underrunCount = 0
|
||||
|
||||
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
|
||||
override fun onOffloadDataRequest() = Unit
|
||||
override fun onOffloadPresentationEnded() = Unit
|
||||
override fun onUnderrun() {
|
||||
underrunCount++
|
||||
}
|
||||
override fun onReleased() {
|
||||
releasedCount++
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeOutputProvider : AudioOutputProvider {
|
||||
val outputs = mutableListOf<FakeAudioOutput>()
|
||||
|
||||
override fun getFormatSupport(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getOutputConfig(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput = FakeAudioOutput().also { outputs.add(it) }
|
||||
|
||||
override fun addListener(listener: AudioOutputProvider.Listener) = Unit
|
||||
override fun removeListener(listener: AudioOutputProvider.Listener) = Unit
|
||||
override fun release() = Unit
|
||||
}
|
||||
|
||||
private class FakeAudioOutput : AudioOutput {
|
||||
val listeners = mutableListOf<AudioOutput.Listener>()
|
||||
var released = false
|
||||
var stopped = false
|
||||
var flushed = false
|
||||
|
||||
fun confirmReleased() {
|
||||
for (listener in listeners.toList()) listener.onReleased()
|
||||
}
|
||||
|
||||
fun emitUnderrun() {
|
||||
for (listener in listeners.toList()) listener.onUnderrun()
|
||||
}
|
||||
|
||||
override fun play() = Unit
|
||||
override fun pause() = Unit
|
||||
override fun flush() {
|
||||
flushed = true
|
||||
}
|
||||
override fun stop() {
|
||||
stopped = true
|
||||
}
|
||||
override fun release() {
|
||||
released = true
|
||||
}
|
||||
override fun write(buffer: ByteBuffer, encodedAccessUnitCount: Int, presentationTimeUs: Long) = true
|
||||
override fun setVolume(volume: Float) = Unit
|
||||
override fun isOffloadedPlayback() = false
|
||||
override fun getAudioSessionId() = 1
|
||||
override fun getSampleRate() = 48_000
|
||||
override fun getBufferSizeInFrames() = 0L
|
||||
override fun getPositionUs() = 0L
|
||||
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
override fun isStalled() = false
|
||||
override fun addListener(listener: AudioOutput.Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
override fun removeListener(listener: AudioOutput.Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
override fun setPlaybackParameters(playbackParams: PlaybackParameters) = Unit
|
||||
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = Unit
|
||||
override fun setOffloadEndOfStream() = Unit
|
||||
override fun attachAuxEffect(effectId: Int) = Unit
|
||||
override fun setAuxEffectSendLevel(level: Float) = Unit
|
||||
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = Unit
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import org.junit.Test
|
||||
|
||||
class ResumeStallPolicyTest {
|
||||
|
||||
// checkWindowMs
|
||||
|
||||
@Test
|
||||
fun windowFloorsAtDefaultForNormalFrameRates() {
|
||||
// 24fps → 4 × 42ms = 168ms, floored to 1000ms
|
||||
@@ -39,8 +37,6 @@ class ResumeStallPolicyTest {
|
||||
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
|
||||
}
|
||||
|
||||
// evaluate
|
||||
|
||||
@Test
|
||||
fun advancingFramesAreHealthy() {
|
||||
assertEquals(
|
||||
|
||||
@@ -9,16 +9,39 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun letterboxUsesVisibleVideoRectForTextAndBitmapSubtitles() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
val bitmap = bitmapDimensions()
|
||||
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun croppedVideoUsesBitmapCompositionPlaneAspect() {
|
||||
// #1945: 3840x1604 video with a 1920x1080 PGS plane in a 2400x1080 container.
|
||||
val planeAspect = SubtitleViewLayout.bitmapPlaneAspect(
|
||||
bitmapWidth = 480,
|
||||
bitmapHeight = 100,
|
||||
cueWidthFraction = 480f / 1920f,
|
||||
cueHeightFraction = 100f / 1080f
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SubtitleViewDimensions(1781, 1002),
|
||||
SubtitleViewLayout.bitmapDimensions(
|
||||
containerWidth = 2400,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 3840,
|
||||
videoHeight = 1604,
|
||||
pixelRatio = 1f,
|
||||
planeAspect = planeAspect
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coverKeepsTextOnScreenAndBitmapSubtitlesFullyVisible() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM)
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM)
|
||||
val bitmap = bitmapDimensions()
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
@@ -28,7 +51,7 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun manualZoomKeepsBitmapSubtitlesFullyVisible() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, zoomScale = 1.5f)
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, zoomScale = 1.5f)
|
||||
val bitmap = bitmapDimensions()
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
@@ -38,16 +61,56 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun stretchDoesNotStretchBitmapSubtitlesToScreenAspect() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL)
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL)
|
||||
val bitmap = bitmapDimensions()
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
assertAspectCloseTo16By9(bitmap!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anchorToScreenSizesTextToContainerAndKeepsBitmapOnVideoRect() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, anchorToScreen = true)
|
||||
val bitmap = bitmapDimensions()
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
assertAspectCloseTo16By9(bitmap!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anchorToScreenReachesBelowLetterboxedWideVideo() {
|
||||
// 2.37:1 video on a 16:9 screen — the #1730 use case. Without the anchor
|
||||
// the text view stops at the bottom letterbox bar; with it the view spans
|
||||
// the physical screen height.
|
||||
val unanchored = SubtitleViewLayout.textDimensions(
|
||||
containerWidth = 1920,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 2560,
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
|
||||
zoomScale = 1f
|
||||
)
|
||||
val anchored = SubtitleViewLayout.textDimensions(
|
||||
containerWidth = 1920,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 2560,
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
|
||||
zoomScale = 1f,
|
||||
anchorToScreen = true
|
||||
)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(1920, 810), unanchored)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), anchored)
|
||||
}
|
||||
|
||||
private fun textDimensions(
|
||||
resizeMode: Int,
|
||||
zoomScale: Float = 1f
|
||||
zoomScale: Float = 1f,
|
||||
anchorToScreen: Boolean = false
|
||||
): SubtitleViewDimensions? = SubtitleViewLayout.textDimensions(
|
||||
containerWidth = 2424,
|
||||
containerHeight = 1080,
|
||||
@@ -55,20 +118,17 @@ class SubtitleViewLayoutTest {
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = resizeMode,
|
||||
zoomScale = zoomScale
|
||||
zoomScale = zoomScale,
|
||||
anchorToScreen = anchorToScreen
|
||||
)
|
||||
|
||||
private fun bitmapDimensions(
|
||||
resizeMode: Int,
|
||||
zoomScale: Float = 1f
|
||||
): SubtitleViewDimensions? = SubtitleViewLayout.bitmapDimensions(
|
||||
private fun bitmapDimensions(): SubtitleViewDimensions? = SubtitleViewLayout.bitmapDimensions(
|
||||
containerWidth = 2424,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 1920,
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
resizeMode = resizeMode,
|
||||
zoomScale = zoomScale
|
||||
planeAspect = null
|
||||
)
|
||||
|
||||
private fun assertAspectCloseTo16By9(dimensions: SubtitleViewDimensions) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the TrueHD -> MAT/IEC 61937 carrier against FFmpeg (#1804).
|
||||
*
|
||||
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
|
||||
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
|
||||
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
|
||||
* FFmpeg's own input and output:
|
||||
*
|
||||
* ```
|
||||
* ffmpeg -i surround_5_1_truehd.mka -c:a copy -f truehd truehd_access_units.bin
|
||||
* ffmpeg -i surround_5_1_truehd.mka -c:a copy -f spdif truehd_iec61937_golden.bin
|
||||
* ```
|
||||
*/
|
||||
class TrueHdMatPackerTest {
|
||||
|
||||
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
|
||||
.use { it.readBytes() }
|
||||
|
||||
private val accessUnits by lazy { resource("truehd_access_units.bin") }
|
||||
private val golden by lazy { resource("truehd_iec61937_golden.bin") }
|
||||
|
||||
/** The whole point: our carrier and FFmpeg's are the same bytes. */
|
||||
@Test
|
||||
fun packedCarrierMatchesFfmpegByteForByte() {
|
||||
val packed = packAll(accessUnits)
|
||||
|
||||
assertEquals(
|
||||
"burst count differs from FFmpeg",
|
||||
golden.size / TrueHdMatPacker.MAT_PKT_OFFSET,
|
||||
packed.size / TrueHdMatPacker.MAT_PKT_OFFSET
|
||||
)
|
||||
// Compare per burst so a failure names the frame rather than dumping 240KB.
|
||||
for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) {
|
||||
val from = index * TrueHdMatPacker.MAT_PKT_OFFSET
|
||||
val to = from + TrueHdMatPacker.MAT_PKT_OFFSET
|
||||
assertArrayEquals(
|
||||
"burst $index differs from FFmpeg",
|
||||
golden.copyOfRange(from, to),
|
||||
packed.copyOfRange(from, to)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each burst is a full IEC 61937 frame: preamble, then payload, then the inter-frame gap. */
|
||||
@Test
|
||||
fun everyBurstCarriesTheTrueHdPreamble() {
|
||||
val packed = packAll(accessUnits)
|
||||
assertTrue("expected at least one burst", packed.isNotEmpty())
|
||||
|
||||
for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) {
|
||||
val at = index * TrueHdMatPacker.MAT_PKT_OFFSET
|
||||
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
|
||||
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
|
||||
assertEquals("burst $index Pc (IEC61937_TRUEHD)", 0x16, readLittleEndianShort(packed, at + 4))
|
||||
assertEquals("burst $index Pd", TrueHdMatPacker.MAT_FRAME_SIZE, readLittleEndianShort(packed, at + 6))
|
||||
}
|
||||
}
|
||||
|
||||
/** A burst is exactly 20ms of carrier, which is what makes the PCM-domain accounting downstream correct. */
|
||||
@Test
|
||||
fun aBurstIsTwentyMillisecondsOfCarrier() {
|
||||
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / IecCarrier.BYTES_PER_FRAME
|
||||
val durationUs = framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE
|
||||
assertEquals(20_000L, durationUs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3's Matroska path concatenates 16 syncframes into one sample, so the packer has to split
|
||||
* them itself; feeding a whole sample as if it were one access unit reads a single timing for
|
||||
* sixteen frames and desynchronises the carrier.
|
||||
*/
|
||||
@Test
|
||||
fun aRechunkedSampleIsSplitIntoItsAccessUnits() {
|
||||
var offset = 0
|
||||
var units = 0
|
||||
while (offset < accessUnits.size) {
|
||||
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
|
||||
if (length == 0) break
|
||||
units++
|
||||
offset += length
|
||||
}
|
||||
assertTrue("fixture should contain many access units, found $units", units > 16)
|
||||
assertEquals("access unit walk must consume the stream exactly", accessUnits.size, offset)
|
||||
}
|
||||
|
||||
/** Reset must not leave a half-filled frame behind, or the next seek emits a spliced burst. */
|
||||
@Test
|
||||
fun resetDropsPartialCarrierState() {
|
||||
val packer = TrueHdMatPacker()
|
||||
feed(packer, accessUnits, stopAfterBursts = 1)
|
||||
packer.reset()
|
||||
|
||||
val afterReset = packAll(accessUnits, packer)
|
||||
assertArrayEquals(
|
||||
"a reset packer must reproduce the stream from the start",
|
||||
packAll(accessUnits),
|
||||
afterReset
|
||||
)
|
||||
}
|
||||
|
||||
/** The packer must not allocate a burst per frame; buffers alternate and are reused. */
|
||||
@Test
|
||||
fun burstBuffersAreReusedRatherThanAllocated() {
|
||||
val packer = TrueHdMatPacker()
|
||||
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
|
||||
var bursts = 0
|
||||
var offset = 0
|
||||
while (offset < accessUnits.size) {
|
||||
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
|
||||
if (length == 0) break
|
||||
packer.packAccessUnit(accessUnits, offset, length)?.let {
|
||||
seen[it] = true
|
||||
bursts++
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
assertTrue("expected several bursts, saw $bursts", bursts > 2)
|
||||
assertTrue("at most two buffers may ever be handed out, saw ${seen.size}", seen.size <= 2)
|
||||
}
|
||||
|
||||
private fun packAll(units: ByteArray, packer: TrueHdMatPacker = TrueHdMatPacker()): ByteArray {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
var offset = 0
|
||||
while (offset < units.size) {
|
||||
val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size)
|
||||
if (length == 0) break
|
||||
packer.packAccessUnit(units, offset, length)?.let { burst ->
|
||||
val copy = ByteArray(burst.remaining())
|
||||
burst.duplicate().get(copy)
|
||||
out.write(copy)
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun feed(packer: TrueHdMatPacker, units: ByteArray, stopAfterBursts: Int) {
|
||||
var offset = 0
|
||||
var bursts = 0
|
||||
while (offset < units.size && bursts < stopAfterBursts) {
|
||||
val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size)
|
||||
if (length == 0) break
|
||||
if (packer.packAccessUnit(units, offset, length) != null) bursts++
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
/**
|
||||
* The flag gates every later call, so leaving it latched across a reset would make a packer that
|
||||
* once saw a 44.1kHz-family stream emit nothing for the rest of its life.
|
||||
*/
|
||||
@Test
|
||||
fun resetClearsTheUnsupportedRateFamilyFlag() {
|
||||
val packer = TrueHdMatPacker()
|
||||
val units = resource("truehd_441_access_units.bin")
|
||||
|
||||
val length = TrueHdMatPacker.accessUnitLength(units, 0, units.size)
|
||||
packer.packAccessUnit(units, 0, length)
|
||||
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedStream)
|
||||
|
||||
packer.reset()
|
||||
|
||||
assertFalse("reset must clear the flag", packer.unsupportedStream)
|
||||
assertArrayEquals(
|
||||
"a clean packer must reproduce the stream after a poisoned one",
|
||||
golden,
|
||||
packAll(accessUnits, TrueHdMatPacker())
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
@@ -9,13 +9,24 @@ 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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,30 @@ class MpvPlayerPluginTest {
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
|
||||
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
|
||||
// with no context to inspect the audio route the only safe answer is "" (#1703, #1991).
|
||||
val result = RecordingResult()
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(MethodCall("getAudioSpdifCodecs", null), result)
|
||||
|
||||
assertEquals("", result.successValue)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceQuirksTest {
|
||||
|
||||
private fun unreliable(
|
||||
manufacturer: String = "Amazon",
|
||||
model: String = "AFTMM",
|
||||
fps: Float = 23.976f
|
||||
) = DeviceQuirks.hasUnreliableTunneledPlayback(manufacturer, model, fps)
|
||||
|
||||
@Test
|
||||
fun fireTvStick4kJuddersOnFilmRateSoTunnelingIsWithdrawn() {
|
||||
assertTrue(unreliable(fps = 23.976f))
|
||||
assertTrue(unreliable(fps = 24f))
|
||||
assertTrue(unreliable(fps = 25f))
|
||||
assertTrue(unreliable(fps = 29.97f))
|
||||
assertTrue(unreliable(fps = DeviceQuirks.MAX_UNRELIABLE_TUNNELED_FPS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun highFrameRateKeepsTunnelingOnTheSameDevice() {
|
||||
// Tunneling exists for this workload and Amazon recommends it here, so the
|
||||
// quirk must not reach 4K50/60 content.
|
||||
assertFalse(unreliable(fps = 50f))
|
||||
assertFalse(unreliable(fps = 59.94f))
|
||||
assertFalse(unreliable(fps = 60f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownFrameRateLeavesBehaviourUnchanged() {
|
||||
assertFalse(unreliable(fps = -1f))
|
||||
assertFalse(unreliable(fps = 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherAmazonHardwareIsUnaffected() {
|
||||
// Only AFTMM was reported and A/B-tested; siblings keep stock behaviour.
|
||||
assertFalse(unreliable(model = "AFTKA"))
|
||||
assertFalse(unreliable(model = "AFTMM2"))
|
||||
assertFalse(unreliable(model = "AFTSSS"))
|
||||
assertFalse(unreliable(model = "KFMAWI"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherManufacturersAreUnaffected() {
|
||||
assertFalse(unreliable(manufacturer = "NVIDIA", model = "SHIELD Android TV"))
|
||||
assertFalse(unreliable(manufacturer = "Sony", model = "BRAVIA 4K GB"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStringCasingDoesNotDefeatTheMatch() {
|
||||
assertTrue(unreliable(manufacturer = "amazon", model = "aftmm"))
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -291,7 +298,7 @@ class WatchNextProviderTest {
|
||||
MethodCall(
|
||||
"sync",
|
||||
mapOf(
|
||||
"schemaVersion" to 2,
|
||||
"schemaVersion" to 3,
|
||||
"ownerId" to "active-owner",
|
||||
"generation" to 1L,
|
||||
"items" to listOf(mapOf("contentId" to "active", "title" to "Active"))
|
||||
@@ -308,7 +315,7 @@ class WatchNextProviderTest {
|
||||
MethodCall(
|
||||
"sync",
|
||||
mapOf(
|
||||
"schemaVersion" to 2,
|
||||
"schemaVersion" to 3,
|
||||
"ownerId" to "queued-owner",
|
||||
"generation" to 2L,
|
||||
"items" to listOf(mapOf("contentId" to "queued", "title" to "Queued"))
|
||||
@@ -720,6 +727,26 @@ class WatchNextProviderTest {
|
||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packageUpdateCancelsLegacyShelfRefreshWork() {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<LegacyShelfRefreshStandIn>(6, TimeUnit.HOURS).build()
|
||||
).result.get()
|
||||
|
||||
SystemShelfUpdateReceiver(Executor { command -> command.run() })
|
||||
.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||
|
||||
val states = workManager
|
||||
.getWorkInfosForUniqueWork(SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK)
|
||||
.get()
|
||||
.map { it.state }
|
||||
assertEquals(listOf(WorkInfo.State.CANCELLED), states)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
|
||||
ScriptedHttpServer(
|
||||
@@ -1281,7 +1308,7 @@ class WatchNextProviderTest {
|
||||
private fun syncCall(ownerId: String, generation: Long) = MethodCall(
|
||||
"sync",
|
||||
mapOf(
|
||||
"schemaVersion" to 2,
|
||||
"schemaVersion" to 3,
|
||||
"ownerId" to ownerId,
|
||||
"generation" to generation,
|
||||
"items" to emptyList<Map<String, Any?>>()
|
||||
@@ -1605,3 +1632,15 @@ private class ManualExecutorService : AbstractExecutorService() {
|
||||
tasks.removeFirst().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the removed ShelfRefreshWorker so the legacy periodic job can
|
||||
* be enqueued. Public because WorkManager's default factory instantiates
|
||||
* workers reflectively and cannot access a package-private class.
|
||||
*/
|
||||
class LegacyShelfRefreshStandIn(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : Worker(context, params) {
|
||||
override fun doWork(): Result = Result.success()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerates the DTS-HD MA packer fixtures (#1988).
|
||||
|
||||
FFmpeg has no DTS-HD MA encoder, so the access-unit fixture is synthesized: real
|
||||
DTS core frames from FFmpeg's `dca` encoder, each followed by a hand-built
|
||||
extension substream (EXSS) whose header fields are coherent enough for FFmpeg's
|
||||
DCA parser to glue core+EXSS into one access unit. The IEC 61937 wrapping never
|
||||
inspects EXSS payload bytes, only the sync word and size fields, so this pins
|
||||
the same wire format genuine Master Audio does.
|
||||
|
||||
One mid-stream access unit is oversized so `ffmpeg -f spdif` overflows the
|
||||
burst and strips to core-only for `dtshd_fallback_time` (60s, i.e. the rest of
|
||||
the fixture) — the golden therefore also pins the strip-and-hold behavior.
|
||||
|
||||
Usage (from this directory):
|
||||
ffmpeg -y -f lavfi -i "aevalsrc=0.3*sin(2*PI*440*t)|0.3*sin(2*PI*554*t)|0.3*sin(2*PI*659*t)|0.2*sin(2*PI*220*t)|0.25*sin(2*PI*330*t)|0.25*sin(2*PI*392*t):s=48000:d=0.15" \
|
||||
-c:a dca -strict experimental -f dts /tmp/dts_core.dts
|
||||
python3 dtshd_fixture_generator.py /tmp/dts_core.dts
|
||||
ffmpeg -y -f dts -i dtshd_ma_access_units.bin -c:a copy \
|
||||
-f spdif -dtshd_rate 768000 dtshd_ma_iec61937_golden.bin
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CORE_SYNC = 0x7FFE8001
|
||||
EXSS_SYNC = 0x64582025
|
||||
|
||||
# Payload bytes past which a 32768-byte burst (512 samples at 48kHz on the
|
||||
# 768kHz HBR carrier) overflows: 32768 - 8 preamble - 12 start code/length.
|
||||
BURST_CAPACITY = 32768 - 8 - 12
|
||||
NORMAL_EXSS_SIZE = 2020
|
||||
# Large enough that core (1884) + EXSS exceeds the burst capacity.
|
||||
OVERSIZED_EXSS_SIZE = BURST_CAPACITY
|
||||
OVERSIZED_AU_INDEX = 4
|
||||
|
||||
|
||||
def build_exss(size: int) -> bytes:
|
||||
"""A minimal EXSS frame: sync, then the narrow (bHeaderSizeType=0) header.
|
||||
|
||||
Bit layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
|
||||
bHeaderSizeType(1), nuBits4Header(8) = header bytes - 1,
|
||||
nuBits4ExSSFsize(16) = frame bytes - 1. Everything else is padding the
|
||||
parser and packer never read.
|
||||
"""
|
||||
header_size = 16
|
||||
bits = 0
|
||||
bits = (bits << 8) | 0 # UserDefinedBits
|
||||
bits = (bits << 2) | 0 # nExtSSIndex
|
||||
bits = (bits << 1) | 0 # bHeaderSizeType
|
||||
bits = (bits << 8) | (header_size - 1)
|
||||
bits = (bits << 16) | (size - 1)
|
||||
packed = bits.to_bytes(5, "big") # 35 bits, left-aligned below
|
||||
body = bytearray(size)
|
||||
struct.pack_into(">I", body, 0, EXSS_SYNC)
|
||||
shifted = int.from_bytes(packed, "big") << (40 - 35)
|
||||
body[4:9] = shifted.to_bytes(5, "big")
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
core = open(sys.argv[1], "rb").read()
|
||||
out = bytearray()
|
||||
offset = 0
|
||||
index = 0
|
||||
while offset + 9 <= len(core):
|
||||
sync = struct.unpack_from(">I", core, offset)[0]
|
||||
assert sync == CORE_SYNC, hex(sync)
|
||||
b24 = (core[offset + 5] << 16) | (core[offset + 6] << 8) | core[offset + 7]
|
||||
fsize = ((b24 >> 4) & 0x3FFF) + 1
|
||||
out += core[offset : offset + fsize]
|
||||
exss_size = OVERSIZED_EXSS_SIZE if index == OVERSIZED_AU_INDEX else NORMAL_EXSS_SIZE
|
||||
out += build_exss(exss_size)
|
||||
offset += fsize
|
||||
index += 1
|
||||
open("dtshd_ma_access_units.bin", "wb").write(out)
|
||||
print(f"{index} access units, {len(out)} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,7 +11,6 @@ default_platform(:android)
|
||||
platform :android do
|
||||
desc "Build and deploy to Google Play Store"
|
||||
lane :release do
|
||||
# Get version from pubspec.yaml
|
||||
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
|
||||
pubspec_content = File.read(pubspec_path)
|
||||
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
|
||||
@@ -26,7 +25,6 @@ platform :android do
|
||||
|
||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||
|
||||
# Build the Flutter app
|
||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
|
||||
|
||||
@@ -37,7 +35,6 @@ platform :android do
|
||||
aab: "../build/app/outputs/bundle/release/app-release.aab"
|
||||
)
|
||||
|
||||
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
|
||||
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ kotlin {
|
||||
dependencies {
|
||||
implementation("androidx.annotation:annotation:1.10.0")
|
||||
implementation("androidx.annotation:annotation-experimental:1.6.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.10.1")
|
||||
implementation("androidx.media3:media3-ui:1.10.1")
|
||||
implementation("androidx.media3:media3-exoplayer:1.11.0")
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ static inline long long nowMs(void) {
|
||||
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
#include "AssPack.h"
|
||||
#include "ass/ass.h"
|
||||
|
||||
#define LOG_TAG "SubtitleRenderer"
|
||||
@@ -243,35 +244,8 @@ Java_com_edde746_plezy_libass_AssRender_nativeAssRenderDeinit(JNIEnv* env, jclas
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap on atlas pages (see the packing comment below). 4 pages of a GL-max
|
||||
// texture is far above the worst real frame measured (a 4K-rendered full-screen
|
||||
// typeset letter needs 3); beyond it tiles are dropped and counted in truncated.
|
||||
#define MAX_ATLAS_PAGES 4
|
||||
|
||||
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image
|
||||
// can exceed one atlas page only when the render frame is larger than a page
|
||||
// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split
|
||||
// one image across pages (a quad samples one texture), so tiling does, keeping
|
||||
// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the
|
||||
// multi-page pack below, not oversized single images.) Tiles are built in list
|
||||
// order (= libass blend/painter order, preserved for emission); the single-page
|
||||
// pack runs height-sorted via a separate key array so emission order is untouched.
|
||||
typedef struct {
|
||||
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
|
||||
int ox, oy; // tile offset within the source bitmap
|
||||
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
|
||||
int page; // atlas page the tile is packed into; -1 if dropped for capacity
|
||||
int sx, sy; // packed slot within the page; valid when page >= 0
|
||||
} PackTile;
|
||||
|
||||
typedef struct {
|
||||
int th; // tile height (the sort key)
|
||||
int idx; // index into the build-order tiles[] array
|
||||
} TileSortKey;
|
||||
|
||||
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
|
||||
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
|
||||
}
|
||||
// Packing/composite policy lives in AssPack.c (pure C, desktop-testable); this
|
||||
// file owns the JNI boundary, buffer plumbing and logging.
|
||||
|
||||
static int imageListHasOutput(ASS_Image* image) {
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
@@ -293,11 +267,12 @@ static int truncationLogCounter = 0;
|
||||
// [5]=hasOutput [6]=pageCount
|
||||
// [7 .. 7+MAX-1] = pageHeights[pageCount]
|
||||
// [7+MAX .. 7+2*MAX-1] = pageQuadCounts[pageCount]
|
||||
#define ASS_HEADER_INTS (7 + 2 * MAX_ATLAS_PAGES)
|
||||
// [7+2*MAX] = mode (ASS_PACK_MODE_ATLAS | ASS_PACK_MODE_COMPOSITE)
|
||||
#define ASS_HEADER_INTS (7 + 2 * ASS_PACK_MAX_PAGES + 1)
|
||||
|
||||
static jint writeAtlasHeader(
|
||||
JNIEnv* env, jintArray headerBuf, int atlasWidth, int quadCount, int changed, int truncated, int requiredPages,
|
||||
int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads) {
|
||||
int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads, int mode) {
|
||||
int hdr[ASS_HEADER_INTS];
|
||||
memset(hdr, 0, sizeof(hdr));
|
||||
hdr[0] = atlasWidth;
|
||||
@@ -307,36 +282,32 @@ static jint writeAtlasHeader(
|
||||
hdr[4] = requiredPages;
|
||||
hdr[5] = hasOutput;
|
||||
hdr[6] = pageCount;
|
||||
for (int i = 0; i < pageCount && i < MAX_ATLAS_PAGES; i++) {
|
||||
for (int i = 0; i < pageCount && i < ASS_PACK_MAX_PAGES; i++) {
|
||||
hdr[7 + i] = pageHeights ? pageHeights[i] : 0;
|
||||
hdr[7 + MAX_ATLAS_PAGES + i] = pageQuads ? pageQuads[i] : 0;
|
||||
hdr[7 + ASS_PACK_MAX_PAGES + i] = pageQuads ? pageQuads[i] : 0;
|
||||
}
|
||||
hdr[7 + 2 * ASS_PACK_MAX_PAGES] = mode;
|
||||
(*env)->SetIntArrayRegion(env, headerBuf, 0, ASS_HEADER_INTS, hdr);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Renders a frame into the provided atlas + vertex direct ByteBuffers.
|
||||
//
|
||||
// - atlasBuf holds one or more vertically-stacked ALPHA_8 *pages*, each atlasMaxW ×
|
||||
// atlasMaxH (row stride atlasMaxW); page p starts at byte offset p*atlasMaxW*atlasMaxH.
|
||||
// The buffer's capacity bounds how many pages this render may fill; AssAtlasFrame
|
||||
// reports pageHeights (rows worth uploading per page) and requiredPages.
|
||||
// - In the common ATLAS mode, atlasBuf holds one or more vertically-stacked ALPHA_8
|
||||
// *pages*, each atlasMaxW × atlasMaxH (row stride atlasMaxW); page p starts at byte
|
||||
// offset p*atlasMaxW*atlasMaxH. The buffer's capacity bounds how many pages this
|
||||
// render may fill; AssAtlasFrame reports pageHeights (rows worth uploading per page)
|
||||
// and requiredPages. UVs are page-local, normalized against atlasMaxW × atlasMaxH.
|
||||
// - In COMPOSITE mode (frames whose tiles can never fit ASS_PACK_MAX_PAGES pages or
|
||||
// the vertex budget) atlasBuf instead starts with one premultiplied RGBA rect of
|
||||
// atlasWidth × pageHeights[0] pixels, drawn as the single emitted quad (UVs 0..1).
|
||||
// - vertexBuf holds a per-quad vertex stream (6 vertices × (2 pos + 2 uv + 4 color)
|
||||
// floats = 48 floats = 192 bytes per quad). Must match BYTES_PER_QUAD/VERTEX in
|
||||
// AssSubtitleAtlasPipeline.kt. UVs are page-local, normalized against atlasMaxW ×
|
||||
// atlasMaxH (the per-page texture dims).
|
||||
// - The common case packs everything into a single height-sorted page (minimizes
|
||||
// packed height, byte-identical to the prior single-page packer). When that
|
||||
// overflows (a 4K full-screen sign can exceed one GL-max texture), the packer
|
||||
// spills into additional pages in list order: page assignment is monotonic in
|
||||
// libass's painter order, so each page's quads are one contiguous run in the
|
||||
// vertex stream and drawing the pages in turn reproduces the blend order.
|
||||
// - Vertices are always emitted in original list order (= libass's painter order).
|
||||
// AssSubtitleAtlasPipeline.kt. Vertices are emitted in libass's painter order.
|
||||
//
|
||||
// Never fails on content size: when the frame needs more pages than the buffer holds
|
||||
// (requiredPages > pageHeights.size) the caller grows the buffer and re-renders; any
|
||||
// genuinely undrawable tiles (past MAX_ATLAS_PAGES / the vertex budget) are dropped
|
||||
// and counted in truncated.
|
||||
// Never drops content for size: when the frame needs more capacity than the buffer
|
||||
// holds (requiredPages > pageHeights.size) the caller grows the buffer and re-renders;
|
||||
// frames too dense for the paged atlas flatten into the RGBA composite (see AssPack.c).
|
||||
//
|
||||
// Returns 0 for missing buffers/handles (the caller maps that to a null frame); 1 when
|
||||
// the header was written. On changed == 0 the header carries (atlasWidth=0, quadCount=0,
|
||||
@@ -359,7 +330,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
|
||||
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time,
|
||||
tAss - t0, changed, hasOutput);
|
||||
}
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL);
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL, ASS_PACK_MODE_ATLAS);
|
||||
}
|
||||
|
||||
if (image == NULL) {
|
||||
@@ -368,7 +339,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
|
||||
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time,
|
||||
tAss - t0, changed);
|
||||
}
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL);
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL, ASS_PACK_MODE_ATLAS);
|
||||
}
|
||||
|
||||
uint8_t* atlasPixels = (uint8_t*)(*env)->GetDirectBufferAddress(env, atlasBuf);
|
||||
@@ -382,253 +353,45 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
|
||||
(long long)atlasCap);
|
||||
return 0;
|
||||
}
|
||||
// 48 floats per quad × 4 bytes = 192 bytes/quad
|
||||
const int maxQuads = (int)(vertexCap / 192);
|
||||
const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH;
|
||||
int providedPages = (int)(atlasCap / (jlong)pageBytes);
|
||||
if (providedPages < 1) return 0; // one page is guaranteed above; keep the page math safe
|
||||
if (providedPages > MAX_ATLAS_PAGES) providedPages = MAX_ATLAS_PAGES;
|
||||
|
||||
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
|
||||
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
|
||||
// sorted by height for the single-page pack so it produces tight rows.
|
||||
int total = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w > 0 && img->h > 0) {
|
||||
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
|
||||
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
|
||||
total += cols * rows;
|
||||
}
|
||||
}
|
||||
if (total == 0) {
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL);
|
||||
}
|
||||
|
||||
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
|
||||
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
|
||||
if (!tiles || !keys) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
AssPackResult pack;
|
||||
if (!ass_pack_frame(image, atlasPixels, (size_t)atlasCap, atlasMaxW, atlasMaxH, vertices, (size_t)vertexCap, &pack)) {
|
||||
return 0;
|
||||
}
|
||||
int n = 0;
|
||||
long long srcPixels = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
srcPixels += (long long)img->w * img->h;
|
||||
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
|
||||
int th = img->h - oy;
|
||||
if (th > atlasMaxH) th = atlasMaxH;
|
||||
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
|
||||
int tw = img->w - ox;
|
||||
if (tw > atlasMaxW) tw = atlasMaxW;
|
||||
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1};
|
||||
keys[n] = (TileSortKey){.th = th, .idx = n};
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
int pageHeights[MAX_ATLAS_PAGES] = {0};
|
||||
int pageQuads[MAX_ATLAS_PAGES] = {0};
|
||||
int pageCount = 1;
|
||||
int requiredPages = 1;
|
||||
int truncated = 0;
|
||||
|
||||
// Pass 1a: height-sorted single page — the common case, minimal packed height
|
||||
// (byte-identical to the prior single-page packer when the frame fits one page).
|
||||
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
|
||||
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[keys[i].idx];
|
||||
if (accepted >= maxQuads) break;
|
||||
int cx = cursorX, cy = cursorY, rh = rowH;
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page
|
||||
t->page = 0;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
cursorX = cx + t->tw;
|
||||
cursorY = cy;
|
||||
rowH = (t->th > rh) ? t->th : rh;
|
||||
if (cy + t->th > packedH) packedH = cy + t->th;
|
||||
accepted++;
|
||||
}
|
||||
|
||||
if (accepted == n) {
|
||||
pageHeights[0] = packedH;
|
||||
pageQuads[0] = accepted;
|
||||
} else {
|
||||
// Pass 1b: the frame overflows one page. Re-pack in list order, starting a new
|
||||
// page whenever a tile won't fit the current one. List order keeps the page
|
||||
// index monotonic in painter order, so each page's quads stay one contiguous run.
|
||||
for (int i = 0; i < n; i++) {
|
||||
tiles[i].page = -1;
|
||||
tiles[i].sx = -1;
|
||||
tiles[i].sy = -1;
|
||||
}
|
||||
int page = 0, cx = 0, cy = 0, rh = 0, placed = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) {
|
||||
page++;
|
||||
cx = 0;
|
||||
cy = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (page + 1 > requiredPages) requiredPages = page + 1;
|
||||
if (page < providedPages && placed < maxQuads) {
|
||||
t->page = page;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
if (cy + t->th > pageHeights[page]) pageHeights[page] = cy + t->th;
|
||||
pageQuads[page]++;
|
||||
placed++;
|
||||
}
|
||||
cx += t->tw;
|
||||
rh = (t->th > rh) ? t->th : rh;
|
||||
}
|
||||
pageCount = (requiredPages < providedPages) ? requiredPages : providedPages;
|
||||
accepted = placed;
|
||||
truncated = n - placed;
|
||||
}
|
||||
|
||||
// Warn only for genuinely-unrecoverable loss. A frame that needs more pages than
|
||||
// the buffer currently holds, yet fits within MAX_ATLAS_PAGES and the vertex
|
||||
// budget, is recoverable: the caller grows the buffer and re-renders, so the
|
||||
// first (discarded) render's truncated > 0 is a false alarm, not data loss. Tiles
|
||||
// are only truly lost past the page cap or the vertex budget.
|
||||
const int recoverableGrow = requiredPages <= MAX_ATLAS_PAGES && n <= maxQuads;
|
||||
if (truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
|
||||
// Warn only for genuinely-unrecoverable loss. A frame that needs more capacity than
|
||||
// the buffer currently holds is recoverable: the caller grows the buffer and
|
||||
// re-renders, so the first (discarded) render's truncated > 0 is a false alarm, not
|
||||
// data loss. With the composite fallback, unrecoverable truncation should be
|
||||
// unreachable for real content.
|
||||
const int maxQuads = (int)(vertexCap / 192);
|
||||
const int recoverableGrow =
|
||||
pack.requiredPages <= ASS_PACK_MAX_PAGES && (pack.mode == ASS_PACK_MODE_COMPOSITE || pack.totalTiles <= maxQuads);
|
||||
if (pack.truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
|
||||
__android_log_print(
|
||||
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %d)",
|
||||
truncated, n, atlasMaxW, atlasMaxH, requiredPages, providedPages);
|
||||
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %lld)",
|
||||
pack.truncated, pack.totalTiles, atlasMaxW, atlasMaxH, pack.requiredPages,
|
||||
(long long)(atlasCap / ((jlong)atlasMaxW * atlasMaxH)));
|
||||
}
|
||||
if (accepted == 0) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, truncated, requiredPages, 1, 1, NULL, NULL);
|
||||
}
|
||||
|
||||
// Clear only the packed rows of each written page.
|
||||
for (int p = 0; p < pageCount; p++) {
|
||||
memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * pageHeights[p]);
|
||||
}
|
||||
|
||||
// Emit tiles in list order (= libass's painter/blend order), copying each placed
|
||||
// tile into its page slot and emitting its quad. Monotonic page assignment makes
|
||||
// each page's quads a contiguous run, matching pageQuads[] for the per-page draw.
|
||||
int qi = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (t->page < 0) continue;
|
||||
ASS_Image* img = t->img;
|
||||
const int px = t->sx;
|
||||
const int py = t->sy;
|
||||
uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes;
|
||||
|
||||
for (int y = 0; y < t->th; y++) {
|
||||
uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px;
|
||||
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
|
||||
memcpy(dst, src, (size_t)t->tw);
|
||||
}
|
||||
|
||||
const float x0 = (float)(img->dst_x + t->ox);
|
||||
const float y0 = (float)(img->dst_y + t->oy);
|
||||
const float x1 = x0 + (float)t->tw;
|
||||
const float y1 = y0 + (float)t->th;
|
||||
const float u0 = (float)px / (float)atlasMaxW;
|
||||
const float v0 = (float)py / (float)atlasMaxH;
|
||||
const float u1 = (float)(px + t->tw) / (float)atlasMaxW;
|
||||
const float v1 = (float)(py + t->th) / (float)atlasMaxH;
|
||||
const unsigned int c = img->color;
|
||||
const float r = (float)((c >> 24) & 0xFFu) / 255.0f;
|
||||
const float g = (float)((c >> 16) & 0xFFu) / 255.0f;
|
||||
const float b = (float)((c >> 8) & 0xFFu) / 255.0f;
|
||||
const float a = (float)(0xFFu - (c & 0xFFu)) / 255.0f;
|
||||
|
||||
float* vx = vertices + (size_t)qi * 48;
|
||||
// 8 floats per vertex: x, y, u, v, r, g, b, a.
|
||||
// Triangle 1: (x0,y0) (x1,y0) (x0,y1)
|
||||
vx[0] = x0;
|
||||
vx[1] = y0;
|
||||
vx[2] = u0;
|
||||
vx[3] = v0;
|
||||
vx[4] = r;
|
||||
vx[5] = g;
|
||||
vx[6] = b;
|
||||
vx[7] = a;
|
||||
vx[8] = x1;
|
||||
vx[9] = y0;
|
||||
vx[10] = u1;
|
||||
vx[11] = v0;
|
||||
vx[12] = r;
|
||||
vx[13] = g;
|
||||
vx[14] = b;
|
||||
vx[15] = a;
|
||||
vx[16] = x0;
|
||||
vx[17] = y1;
|
||||
vx[18] = u0;
|
||||
vx[19] = v1;
|
||||
vx[20] = r;
|
||||
vx[21] = g;
|
||||
vx[22] = b;
|
||||
vx[23] = a;
|
||||
// Triangle 2: (x1,y0) (x1,y1) (x0,y1)
|
||||
vx[24] = x1;
|
||||
vx[25] = y0;
|
||||
vx[26] = u1;
|
||||
vx[27] = v0;
|
||||
vx[28] = r;
|
||||
vx[29] = g;
|
||||
vx[30] = b;
|
||||
vx[31] = a;
|
||||
vx[32] = x1;
|
||||
vx[33] = y1;
|
||||
vx[34] = u1;
|
||||
vx[35] = v1;
|
||||
vx[36] = r;
|
||||
vx[37] = g;
|
||||
vx[38] = b;
|
||||
vx[39] = a;
|
||||
vx[40] = x0;
|
||||
vx[41] = y1;
|
||||
vx[42] = u0;
|
||||
vx[43] = v1;
|
||||
vx[44] = r;
|
||||
vx[45] = g;
|
||||
vx[46] = b;
|
||||
vx[47] = a;
|
||||
|
||||
qi++;
|
||||
}
|
||||
|
||||
free(tiles);
|
||||
free(keys);
|
||||
|
||||
// Slow-render breakdown: separates libass's own cost (rasterize/blur/shape)
|
||||
// from this function's packing + memcpy, so device logs attribute the time.
|
||||
// from this function's packing/compositing, so device logs attribute the time.
|
||||
const long long tEnd = nowMs();
|
||||
if (tEnd - t0 > 40) {
|
||||
__android_log_print(
|
||||
ANDROID_LOG_WARN, LOG_TAG,
|
||||
"slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk "
|
||||
"atlas=%dx%d pages=%d quads=%d",
|
||||
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, n, srcPixels / 1000, atlasMaxW, atlasMaxH, pageCount, qi);
|
||||
"atlas=%dx%d pages=%d quads=%d mode=%d",
|
||||
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, pack.totalTiles, pack.srcPixels / 1000, atlasMaxW,
|
||||
atlasMaxH, pack.pageCount, pack.quadCount, pack.mode);
|
||||
}
|
||||
|
||||
// atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width);
|
||||
// pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
|
||||
// ATLAS: atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width)
|
||||
// and pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
|
||||
// COMPOSITE: atlasWidth × pageHeights[0] are the RGBA rect dims for the one quad.
|
||||
return writeAtlasHeader(
|
||||
env, headerBuf, atlasMaxW, qi, changed, truncated, requiredPages, 1, pageCount, pageHeights, pageQuads);
|
||||
env, headerBuf, pack.atlasWidth, pack.quadCount, changed, pack.truncated, pack.requiredPages,
|
||||
pack.totalTiles > 0 ? 1 : 0, pack.pageCount, pack.pageHeights, pack.pageQuads, pack.mode);
|
||||
}
|
||||
|
||||
// --- AssFrameTimestamps (EGL_ANDROID_get_frame_timestamps) ---
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#include "AssPack.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image
|
||||
// can exceed one atlas page only when the render frame is larger than a page
|
||||
// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split
|
||||
// one image across pages (a quad samples one texture), so tiling does, keeping
|
||||
// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the
|
||||
// multi-page pack below, not oversized single images.) Tiles are built in list
|
||||
// order (= libass blend/painter order, preserved for emission); the single-page
|
||||
// pack runs height-sorted via a separate key array so emission order is untouched.
|
||||
typedef struct {
|
||||
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
|
||||
int ox, oy; // tile offset within the source bitmap
|
||||
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
|
||||
int page; // atlas page the tile is packed into; -1 if dropped for capacity
|
||||
int sx, sy; // packed slot within the page; valid when page >= 0
|
||||
} PackTile;
|
||||
|
||||
typedef struct {
|
||||
int th; // tile height (the sort key)
|
||||
int idx; // index into the build-order tiles[] array
|
||||
} TileSortKey;
|
||||
|
||||
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
|
||||
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
|
||||
}
|
||||
|
||||
// 8 floats per vertex (x, y, u, v, r, g, b, a) x 6 vertices; layout must match
|
||||
// BYTES_PER_QUAD/VERTEX in AssSubtitleAtlasPipeline.kt.
|
||||
static void emitQuad(
|
||||
float* vx, float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1, float r, float g,
|
||||
float b, float a) {
|
||||
const float pos[6][2] = {{x0, y0}, {x1, y0}, {x0, y1}, {x1, y0}, {x1, y1}, {x0, y1}};
|
||||
const float uv[6][2] = {{u0, v0}, {u1, v0}, {u0, v1}, {u1, v0}, {u1, v1}, {u0, v1}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
*vx++ = pos[i][0];
|
||||
*vx++ = pos[i][1];
|
||||
*vx++ = uv[i][0];
|
||||
*vx++ = uv[i][1];
|
||||
*vx++ = r;
|
||||
*vx++ = g;
|
||||
*vx++ = b;
|
||||
*vx++ = a;
|
||||
}
|
||||
}
|
||||
|
||||
// Flattens the whole image list into one premultiplied RGBA rect (the union
|
||||
// bounding box) at the start of `atlasPixels`, emitted as a single quad. The
|
||||
// blend is libass painter-order src-over, the same math the GL path applies to
|
||||
// alpha-atlas quads, so output is visually identical — memory just becomes
|
||||
// O(union area <= frame area) instead of O(sum of image areas). Used for
|
||||
// frames whose summed image area cannot fit ASS_PACK_MAX_PAGES alpha pages
|
||||
// (#1868: ~150 overlapping paint-strokes put ~5 pages of tiles at 1080p and
|
||||
// ~19 at 4K behind a 4-page cap, silently dropping the painter-order tail —
|
||||
// the sign's text).
|
||||
static void compositeFrame(
|
||||
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, size_t pageBytes, float* vertices, size_t vertexCap,
|
||||
AssPackResult* out) {
|
||||
int ux0 = INT_MAX, uy0 = INT_MAX, ux1 = INT_MIN, uy1 = INT_MIN;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
if (img->dst_x < ux0) ux0 = img->dst_x;
|
||||
if (img->dst_y < uy0) uy0 = img->dst_y;
|
||||
if (img->dst_x + img->w > ux1) ux1 = img->dst_x + img->w;
|
||||
if (img->dst_y + img->h > uy1) uy1 = img->dst_y + img->h;
|
||||
}
|
||||
const int uw = ux1 - ux0;
|
||||
const int uh = uy1 - uy0;
|
||||
const size_t rgbaBytes = (size_t)uw * uh * 4;
|
||||
|
||||
out->mode = ASS_PACK_MODE_COMPOSITE;
|
||||
out->requiredPages = (int)((rgbaBytes + pageBytes - 1) / pageBytes);
|
||||
out->pageCount = 1;
|
||||
if (rgbaBytes > atlasCap || vertexCap < 192) {
|
||||
// Buffers too small for the flattened rect: report the needed capacity and
|
||||
// write nothing — the caller grows and re-renders (same contract as the
|
||||
// multi-page atlas grow). truncated flags the frame as not presentable.
|
||||
out->truncated = out->totalTiles;
|
||||
return;
|
||||
}
|
||||
|
||||
memset(atlasPixels, 0, rgbaBytes);
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
const unsigned int c = img->color;
|
||||
const unsigned cr = (c >> 24) & 0xFFu;
|
||||
const unsigned cg = (c >> 16) & 0xFFu;
|
||||
const unsigned cb = (c >> 8) & 0xFFu;
|
||||
const unsigned ca = 0xFFu - (c & 0xFFu);
|
||||
if (ca == 0) continue;
|
||||
for (int y = 0; y < img->h; y++) {
|
||||
const uint8_t* src = img->bitmap + (size_t)y * img->stride;
|
||||
uint8_t* dst = atlasPixels + (((size_t)(img->dst_y - uy0 + y) * uw) + (size_t)(img->dst_x - ux0)) * 4;
|
||||
for (int x = 0; x < img->w; x++, dst += 4) {
|
||||
const unsigned a = (src[x] * ca + 127u) / 255u;
|
||||
if (a == 0) continue;
|
||||
const unsigned inv = 255u - a;
|
||||
dst[0] = (uint8_t)((cr * a + dst[0] * inv + 127u) / 255u);
|
||||
dst[1] = (uint8_t)((cg * a + dst[1] * inv + 127u) / 255u);
|
||||
dst[2] = (uint8_t)((cb * a + dst[2] * inv + 127u) / 255u);
|
||||
dst[3] = (uint8_t)((255u * a + dst[3] * inv + 127u) / 255u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out->atlasWidth = uw;
|
||||
out->pageHeights[0] = uh;
|
||||
out->pageQuads[0] = 1;
|
||||
out->quadCount = 1;
|
||||
out->truncated = 0;
|
||||
emitQuad(
|
||||
vertices, (float)ux0, (float)uy0, (float)(ux0 + uw), (float)(uy0 + uh), 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
|
||||
1.0f);
|
||||
}
|
||||
|
||||
int ass_pack_frame(
|
||||
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices,
|
||||
size_t vertexCap, AssPackResult* out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->mode = ASS_PACK_MODE_ATLAS;
|
||||
out->requiredPages = 1;
|
||||
out->pageCount = 1;
|
||||
|
||||
// 48 floats per quad x 4 bytes = 192 bytes/quad
|
||||
const int maxQuads = (int)(vertexCap / 192);
|
||||
const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH;
|
||||
int providedPages = (int)(atlasCap / pageBytes);
|
||||
if (providedPages > ASS_PACK_MAX_PAGES) providedPages = ASS_PACK_MAX_PAGES;
|
||||
|
||||
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
|
||||
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
|
||||
// sorted by height for the single-page pack so it produces tight rows.
|
||||
int total = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w > 0 && img->h > 0) {
|
||||
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
|
||||
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
|
||||
total += cols * rows;
|
||||
}
|
||||
}
|
||||
out->totalTiles = total;
|
||||
if (total == 0) return 1;
|
||||
|
||||
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
|
||||
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
|
||||
if (!tiles || !keys) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
return 0;
|
||||
}
|
||||
int n = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
out->srcPixels += (long long)img->w * img->h;
|
||||
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
|
||||
int th = img->h - oy;
|
||||
if (th > atlasMaxH) th = atlasMaxH;
|
||||
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
|
||||
int tw = img->w - ox;
|
||||
if (tw > atlasMaxW) tw = atlasMaxW;
|
||||
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1};
|
||||
keys[n] = (TileSortKey){.th = th, .idx = n};
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
int truncated = 0;
|
||||
|
||||
// Pass 1a: height-sorted single page — the common case, minimal packed height
|
||||
// (byte-identical to the prior single-page packer when the frame fits one page).
|
||||
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
|
||||
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[keys[i].idx];
|
||||
if (accepted >= maxQuads) break;
|
||||
int cx = cursorX, cy = cursorY, rh = rowH;
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page
|
||||
t->page = 0;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
cursorX = cx + t->tw;
|
||||
cursorY = cy;
|
||||
rowH = (t->th > rh) ? t->th : rh;
|
||||
if (cy + t->th > packedH) packedH = cy + t->th;
|
||||
accepted++;
|
||||
}
|
||||
|
||||
if (accepted == n) {
|
||||
out->pageHeights[0] = packedH;
|
||||
out->pageQuads[0] = accepted;
|
||||
} else {
|
||||
// Pass 1b: the frame overflows one page. Re-pack in list order, starting a new
|
||||
// page whenever a tile won't fit the current one. List order keeps the page
|
||||
// index monotonic in painter order, so each page's quads stay one contiguous run.
|
||||
for (int i = 0; i < n; i++) {
|
||||
tiles[i].page = -1;
|
||||
tiles[i].sx = -1;
|
||||
tiles[i].sy = -1;
|
||||
}
|
||||
int requiredPages = 1;
|
||||
int page = 0, cx = 0, cy = 0, rh = 0, placed = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) {
|
||||
page++;
|
||||
cx = 0;
|
||||
cy = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (page + 1 > requiredPages) requiredPages = page + 1;
|
||||
if (page < providedPages && placed < maxQuads) {
|
||||
t->page = page;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
if (cy + t->th > out->pageHeights[page]) out->pageHeights[page] = cy + t->th;
|
||||
out->pageQuads[page]++;
|
||||
placed++;
|
||||
}
|
||||
cx += t->tw;
|
||||
rh = (t->th > rh) ? t->th : rh;
|
||||
}
|
||||
if (requiredPages > ASS_PACK_MAX_PAGES || n > maxQuads) {
|
||||
// The frame can never fit the paged alpha atlas: its tiles exceed the page
|
||||
// cap or the vertex budget outright. Flatten instead of dropping the
|
||||
// painter-order tail (#1868).
|
||||
free(tiles);
|
||||
free(keys);
|
||||
memset(out->pageHeights, 0, sizeof(out->pageHeights));
|
||||
memset(out->pageQuads, 0, sizeof(out->pageQuads));
|
||||
compositeFrame(image, atlasPixels, atlasCap, pageBytes, vertices, vertexCap, out);
|
||||
return 1;
|
||||
}
|
||||
out->requiredPages = requiredPages;
|
||||
out->pageCount = (requiredPages < providedPages) ? requiredPages : providedPages;
|
||||
accepted = placed;
|
||||
truncated = n - placed;
|
||||
}
|
||||
|
||||
out->truncated = truncated;
|
||||
if (accepted == 0) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
memset(out->pageHeights, 0, sizeof(out->pageHeights));
|
||||
memset(out->pageQuads, 0, sizeof(out->pageQuads));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clear only the packed rows of each written page.
|
||||
for (int p = 0; p < out->pageCount; p++) {
|
||||
memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * out->pageHeights[p]);
|
||||
}
|
||||
|
||||
// Emit tiles in list order (= libass's painter/blend order), copying each placed
|
||||
// tile into its page slot and emitting its quad. Monotonic page assignment makes
|
||||
// each page's quads a contiguous run, matching pageQuads[] for the per-page draw.
|
||||
int qi = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (t->page < 0) continue;
|
||||
ASS_Image* img = t->img;
|
||||
const int px = t->sx;
|
||||
const int py = t->sy;
|
||||
uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes;
|
||||
|
||||
for (int y = 0; y < t->th; y++) {
|
||||
uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px;
|
||||
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
|
||||
memcpy(dst, src, (size_t)t->tw);
|
||||
}
|
||||
|
||||
const unsigned int c = img->color;
|
||||
emitQuad(
|
||||
vertices + (size_t)qi * 48, (float)(img->dst_x + t->ox), (float)(img->dst_y + t->oy),
|
||||
(float)(img->dst_x + t->ox + t->tw), (float)(img->dst_y + t->oy + t->th), (float)px / (float)atlasMaxW,
|
||||
(float)py / (float)atlasMaxH, (float)(px + t->tw) / (float)atlasMaxW, (float)(py + t->th) / (float)atlasMaxH,
|
||||
(float)((c >> 24) & 0xFFu) / 255.0f, (float)((c >> 16) & 0xFFu) / 255.0f, (float)((c >> 8) & 0xFFu) / 255.0f,
|
||||
(float)(0xFFu - (c & 0xFFu)) / 255.0f);
|
||||
qi++;
|
||||
}
|
||||
|
||||
free(tiles);
|
||||
free(keys);
|
||||
out->quadCount = qi;
|
||||
out->atlasWidth = atlasMaxW;
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Pure-C frame packer behind the JNI atlas render entry point (AssKt.c).
|
||||
// Kept free of JNI/Android includes so desktop test harnesses can compile the
|
||||
// exact shipped packing/composite logic against a host libass build.
|
||||
#ifndef PLEZY_ASS_PACK_H
|
||||
#define PLEZY_ASS_PACK_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "ass/ass.h"
|
||||
|
||||
// Hard cap on atlas pages. 4 pages of a GL-max texture covers the worst real
|
||||
// frame measured for ordinary typesetting (a 4K-rendered full-screen letter
|
||||
// needs 3); denser frames (#1868: ~1500 overlapping paint-stroke images whose
|
||||
// summed area is ~19 pages at 4K) fall back to the RGBA composite below instead
|
||||
// of dropping tiles. Must match AssAtlasPipelineConfig.MAX_ATLAS_PAGES.
|
||||
#define ASS_PACK_MAX_PAGES 4
|
||||
|
||||
// Result modes. ATLAS: one or more ALPHA_8 pages plus per-quad vertices, the
|
||||
// common fast path. COMPOSITE: the frame's images were flattened CPU-side into
|
||||
// a single premultiplied RGBA rect (the union bounding box) drawn as one quad —
|
||||
// memory is bounded by frame area instead of the sum of per-image areas.
|
||||
#define ASS_PACK_MODE_ATLAS 0
|
||||
#define ASS_PACK_MODE_COMPOSITE 1
|
||||
|
||||
typedef struct {
|
||||
int mode; // ASS_PACK_MODE_*
|
||||
// ATLAS: row stride of every page. COMPOSITE: width of the RGBA rect.
|
||||
int atlasWidth;
|
||||
int quadCount;
|
||||
// Tiles dropped for capacity (frame incomplete). Composite output never
|
||||
// drops content; it reports truncated == totalTiles only in the
|
||||
// nothing-written grow request state (quadCount == 0).
|
||||
int truncated;
|
||||
// Pages of caller buffer capacity this frame needs to render completely.
|
||||
// When it exceeds the provided capacity the caller grows and re-renders.
|
||||
int requiredPages;
|
||||
int pageCount;
|
||||
int totalTiles; // tiles the frame splits into (caller-side logging)
|
||||
long long srcPixels; // summed source image area (caller-side logging)
|
||||
// ATLAS: packed rows per page / quads per page (contiguous vertex runs).
|
||||
// COMPOSITE: pageHeights[0] = RGBA rect height, pageQuads[0] = 1.
|
||||
int pageHeights[ASS_PACK_MAX_PAGES];
|
||||
int pageQuads[ASS_PACK_MAX_PAGES];
|
||||
} AssPackResult;
|
||||
|
||||
// Packs libass's image list for `atlasPixels`/`vertices` (layout documented at
|
||||
// the JNI entry point in AssKt.c). Never drops content for size: frames that
|
||||
// cannot fit ASS_PACK_MAX_PAGES alpha pages (or the vertex budget) flatten into
|
||||
// an RGBA composite; when the provided buffers are too small for either
|
||||
// representation, requiredPages tells the caller how much to grow before
|
||||
// re-rendering, and nothing is written. Returns 0 only on allocation failure.
|
||||
int ass_pack_frame(
|
||||
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices,
|
||||
size_t vertexCap, AssPackResult* out);
|
||||
|
||||
#endif // PLEZY_ASS_PACK_H
|
||||
@@ -44,7 +44,7 @@ set_target_properties(ass PROPERTIES
|
||||
IMPORTED_LOCATION "${LIBASS_ARCHIVE}")
|
||||
target_include_directories(ass INTERFACE "${LIBASS_ROOT}/include")
|
||||
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c SurfaceTxProbe.c)
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c AssPack.c SurfaceTxProbe.c)
|
||||
add_dependencies(${CMAKE_PROJECT_NAME} libass_prebuilt_${LIBASS_ABI_TARGET})
|
||||
# HarfBuzz brings C++; link the shared STL that the app already packages.
|
||||
# (SurfaceTxProbe resolves its libandroid/libsync entry points via dlsym, so no extra link.)
|
||||
|
||||
@@ -4,33 +4,44 @@ package com.edde746.plezy.libass
|
||||
* Result of a packed-atlas render. The atlas pixel data is stored in the direct ByteBuffer
|
||||
* that was passed into [AssRender.renderFrameAtlas]; the vertex stream is in the other.
|
||||
*
|
||||
* The atlas may span more than one *page* — a heavy full-screen sign can produce more
|
||||
* sub-pixels than a single GL-max texture holds. Pages are vertically stacked in the
|
||||
* atlas ByteBuffer (page `p` at byte offset `p * atlasWidth * atlasMaxHeight`), each its
|
||||
* own texture, and are drawn in turn. Quads are emitted in libass painter order and page
|
||||
* assignment is monotonic in that order, so each page's quads form one contiguous run in
|
||||
* the vertex stream ([pageQuadCounts]); the runner uploads page `p`, then draws its run.
|
||||
* In the common [MODE_ATLAS] the atlas may span more than one ALPHA_8 *page* — a heavy
|
||||
* full-screen sign can produce more sub-pixels than a single GL-max texture holds. Pages
|
||||
* are vertically stacked in the atlas ByteBuffer (page `p` at byte offset
|
||||
* `p * atlasWidth * atlasMaxHeight`), each its own texture, and are drawn in turn. Quads
|
||||
* are emitted in libass painter order and page assignment is monotonic in that order, so
|
||||
* each page's quads form one contiguous run in the vertex stream ([pageQuadCounts]); the
|
||||
* runner uploads page `p`, then draws its run.
|
||||
*
|
||||
* In [MODE_COMPOSITE] the frame was too dense for the paged atlas (its summed image area
|
||||
* exceeds every page: overlapping paint-stroke signs, #1868) and the native side flattened
|
||||
* it into one premultiplied RGBA rect of [atlasWidth] × `pageHeights[0]` pixels at the
|
||||
* start of the atlas ByteBuffer, drawn as the single emitted quad with UVs 0..1.
|
||||
*
|
||||
* Built in Kotlin by [AssRender.renderFrameAtlas] from the int[] header the native
|
||||
* renderer fills (see `writeAtlasHeader` in AssKt.c) — never constructed from JNI, so
|
||||
* the minifier may obfuscate it freely without breaking the native boundary.
|
||||
*
|
||||
* @param atlasWidth atlas row stride in pixels (= the allocated width; same for every
|
||||
* page; 0 when [changed] == 0)
|
||||
* page; 0 when [changed] == 0) — or the RGBA rect width in
|
||||
* [MODE_COMPOSITE]
|
||||
* @param pageHeights packed height (rows worth uploading) of each page; `size` = page count
|
||||
* @param pageQuadCounts quads on each page, contiguous in the vertex stream in this order;
|
||||
* `size` = page count, `sum` = [quadCount]
|
||||
* @param quadCount total quads; the vertex buffer holds [quadCount] * 6 vertices
|
||||
* @param changed libass change flag (0 = no change, 1 = positions, 2 = content)
|
||||
* @param truncated images dropped because the frame needed more than [requiredPages]
|
||||
* pages of capacity or exceeded the vertex budget; the frame is
|
||||
* incomplete but never stale (should be unreachable for real content)
|
||||
* @param requiredPages pages this frame needs to render completely. When it exceeds
|
||||
* [pageHeights].size the caller must grow the atlas buffer and
|
||||
* re-render; the rendered pages are still valid in the meantime.
|
||||
* @param truncated images dropped because the frame needed more capacity than the
|
||||
* buffer holds; the frame is incomplete but never stale. Recoverable
|
||||
* by growing to [requiredPages]; with the composite fallback,
|
||||
* unrecoverable truncation should be unreachable for real content
|
||||
* @param requiredPages pages of buffer capacity this frame needs to render completely.
|
||||
* When it exceeds [pageHeights].size the caller must grow the atlas
|
||||
* buffer and re-render; the rendered pages are still valid in the
|
||||
* meantime.
|
||||
* @param hasOutput true when libass reported at least one visible image for this
|
||||
* timestamp, even when [changed] is 0 and the buffers were not
|
||||
* rewritten. false means this timestamp should be blank.
|
||||
* @param mode [MODE_ATLAS] or [MODE_COMPOSITE]; must match the ASS_PACK_MODE_*
|
||||
* constants in AssPack.h
|
||||
*/
|
||||
class AssAtlasFrame(
|
||||
val atlasWidth: Int,
|
||||
@@ -40,8 +51,17 @@ class AssAtlasFrame(
|
||||
val changed: Int,
|
||||
val truncated: Int,
|
||||
val requiredPages: Int,
|
||||
val hasOutput: Boolean
|
||||
val hasOutput: Boolean,
|
||||
val mode: Int = MODE_ATLAS
|
||||
) {
|
||||
companion object {
|
||||
/** One or more ALPHA_8 atlas pages, per-quad colors in the vertex stream. */
|
||||
const val MODE_ATLAS = 0
|
||||
|
||||
/** One premultiplied RGBA rect at the start of the atlas buffer, one quad. */
|
||||
const val MODE_COMPOSITE = 1
|
||||
}
|
||||
|
||||
/** Number of atlas pages this frame occupies. */
|
||||
val pageCount: Int get() = pageHeights.size
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
||||
|
||||
companion object {
|
||||
|
||||
/** Must match MAX_ATLAS_PAGES + the header layout in AssKt.c (`writeAtlasHeader`). */
|
||||
/** Must match ASS_PACK_MAX_PAGES + the header layout in AssPack.h/AssKt.c (`writeAtlasHeader`). */
|
||||
private const val MAX_ATLAS_PAGES = 4
|
||||
private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES
|
||||
private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES + 1
|
||||
|
||||
@JvmStatic
|
||||
external fun nativeAssRenderInit(ass: Long): Long
|
||||
@@ -89,6 +89,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
||||
lock.withLock { this.track = track }
|
||||
}
|
||||
|
||||
/** False after [setTrack] with null (subtitles disabled) — renders would return null. */
|
||||
val hasTrack: Boolean get() = lock.withLock { track != null }
|
||||
|
||||
fun setFontScale(scale: Float) {
|
||||
generation.incrementAndGet()
|
||||
withNative { nativeAssRenderSetFontScale(it, scale) }
|
||||
@@ -175,7 +178,8 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
||||
changed = header[2],
|
||||
truncated = header[3],
|
||||
requiredPages = header[4],
|
||||
hasOutput = header[5] != 0
|
||||
hasOutput = header[5] != 0,
|
||||
mode = header[7 + 2 * MAX_ATLAS_PAGES]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+135
-13
@@ -58,9 +58,11 @@ internal object AssAtlasPipelineConfig {
|
||||
* Hard cap on vertically-stacked atlas pages per slot. A frame whose packed
|
||||
* sub-pixels exceed one [ATLAS_PIXEL_BUDGET] texture (a 4K-rendered full-screen
|
||||
* typeset sign) spills into extra pages so nothing is dropped (#1436); the atlas
|
||||
* buffer grows on demand toward this cap. 4 covers the worst frame measured (a 4K
|
||||
* letter needs 3); past it tiles are dropped and counted in `truncated`. Must match
|
||||
* MAX_ATLAS_PAGES in AssKt.c.
|
||||
* buffer grows on demand toward this cap. 4 covers the worst frame measured for
|
||||
* ordinary typesetting (a 4K letter needs 3); frames too dense even for that
|
||||
* (overlapping paint-stroke signs, #1868) flatten into a single RGBA composite
|
||||
* ([AssAtlasFrame.MODE_COMPOSITE]) instead of dropping tiles. Must match
|
||||
* ASS_PACK_MAX_PAGES in AssPack.h.
|
||||
*/
|
||||
internal const val MAX_ATLAS_PAGES = 4
|
||||
|
||||
@@ -311,8 +313,9 @@ internal class AssAtlasPipeline(
|
||||
|
||||
/**
|
||||
* Re-renders the last requested position — for renderer state changes (margins,
|
||||
* use-margins) that must become visible while playback is paused. Safe during
|
||||
* playback: the next video frame's [requestRender] supersedes it (latest-wins).
|
||||
* use-margins, a disabled track) that must become visible while playback is
|
||||
* paused. Safe during playback: the next video frame's [requestRender]
|
||||
* supersedes it (latest-wins).
|
||||
*/
|
||||
fun invalidate() {
|
||||
libassThread.invalidate()
|
||||
@@ -580,6 +583,12 @@ private class AtlasLibassThread(
|
||||
@Volatile private var lastRequestedPtsUs = UNSET
|
||||
private var contentSeqCounter = 0L
|
||||
|
||||
/** True once any payload reached the GL thread — the overlay may be showing content. */
|
||||
private var hasEverPosted = false
|
||||
|
||||
/** State generation a trackless blank clear was posted for; MIN_VALUE = none pending. */
|
||||
private var tracklessClearedGeneration = Long.MIN_VALUE
|
||||
|
||||
// Thread-confined; created on first render so non-ASS playback never allocates.
|
||||
private var engine: SpecRenderEngine? = null
|
||||
private var engineStateGeneration = Long.MIN_VALUE
|
||||
@@ -718,8 +727,9 @@ private class AtlasLibassThread(
|
||||
var frame = render.renderFrameAtlas(timeMs, payload.atlasBuf, slots.atlasW, slots.atlasH, payload.vertexBuf)
|
||||
?: return null
|
||||
// A frame overflows one atlas page only on dense full-screen typesetting. When it
|
||||
// does, grow this slot's buffer to the pages it needs (capped) and render once more
|
||||
// — libass's caches make the re-render cheap, and the slot keeps the larger buffer
|
||||
// does — multi-page atlas or an RGBA composite rect needing more than one page —
|
||||
// grow this slot's buffer to the pages it needs (capped) and render once more:
|
||||
// libass's caches make the re-render cheap, and the slot keeps the larger buffer
|
||||
// so the same density never re-grows. The truncated first result is never handed off.
|
||||
if (frame.requiredPages > payload.pageCapacity && payload.pageCapacity < AssAtlasPipelineConfig.MAX_ATLAS_PAGES) {
|
||||
payload.growAtlas(
|
||||
@@ -757,7 +767,22 @@ private class AtlasLibassThread(
|
||||
val waitMs = (tDrain - request.enqueueNs) / 1_000_000
|
||||
// Before any ASS render exists (SRT/VTT or no subs) do nothing — this also
|
||||
// keeps the slot buffers unallocated for non-ASS playback.
|
||||
if (assHandler.render == null) return
|
||||
val render = assHandler.render ?: return
|
||||
if (!render.hasTrack) {
|
||||
// Subtitles were disabled mid-cue: every render now returns null, so no
|
||||
// payload would ever reach the GL thread again and the last swapped cue
|
||||
// would sit on the overlay until playback ends (#1884). Swap one explicit
|
||||
// blank frame instead. Keyed on the state generation ([AssRender.setTrack]
|
||||
// bumped it) so a clear the GL thread drops as stale is retried by the
|
||||
// next request; reset below once a track is live again.
|
||||
val tracklessGeneration = stateGeneration()
|
||||
if (hasEverPosted && tracklessClearedGeneration != tracklessGeneration) {
|
||||
tracklessClearedGeneration = tracklessGeneration
|
||||
postBlankClear(request, tracklessGeneration)
|
||||
}
|
||||
return
|
||||
}
|
||||
tracklessClearedGeneration = Long.MIN_VALUE
|
||||
val slots = acquireSlots()
|
||||
val generation = stateGeneration()
|
||||
val engine = ensureEngine(slots, generation)
|
||||
@@ -787,6 +812,7 @@ private class AtlasLibassThread(
|
||||
payload.requestSeq = request.sequence
|
||||
payload.stateGeneration = generation
|
||||
onFrameReady(payload)
|
||||
hasEverPosted = true
|
||||
if (AssAtlasPipelineConfig.TIMING_LOGS) {
|
||||
Log.d(
|
||||
TAG,
|
||||
@@ -794,7 +820,8 @@ private class AtlasLibassThread(
|
||||
"releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
|
||||
"libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " +
|
||||
"specHit=${outcome.specHit} changed=${payload.frame.changed} output=${payload.frame.hasOutput} quads=${payload.frame.quadCount} " +
|
||||
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated}"
|
||||
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated} " +
|
||||
"mode=${payload.frame.mode}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -824,6 +851,29 @@ private class AtlasLibassThread(
|
||||
maybePrefetch(engine, slots, pts, pinned)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the GL thread a zero-quad payload — [AtlasRenderer.onDrawFrame] clears
|
||||
* and draws nothing — so a cue still on screen when its track is disabled goes
|
||||
* away immediately instead of outliving the selection (#1884).
|
||||
*/
|
||||
private fun postBlankClear(request: PendingFrame, generation: Long) {
|
||||
val slots = acquireSlots()
|
||||
// Never rewrite the slot the GL thread may still be reading (slotCount >= 2).
|
||||
val payload = slots.payloads.first { it.slotIndex != glTakenSlot() }
|
||||
payload.frame = AssAtlasFrame(0, 0, 0, 0, 0)
|
||||
payload.contentSeq = ++contentSeqCounter
|
||||
payload.sourcePresentationTimeUs = request.sourcePtsUs
|
||||
payload.presentationTimeUs = request.ptsUs
|
||||
payload.releaseTimeNs = request.releaseNs
|
||||
payload.phaseLeadUs = request.phaseLeadUs
|
||||
payload.requestSeq = request.sequence
|
||||
payload.stateGeneration = generation
|
||||
onFrameReady(payload)
|
||||
if (AssAtlasPipelineConfig.TIMING_LOGS) {
|
||||
Log.d(TAG, "trackless-clear req=${request.sequence} pts=${request.ptsUs / 1000}ms gen=$generation")
|
||||
}
|
||||
}
|
||||
|
||||
/** Start time of the last event boundary we cache-warmed (libass/track ms). */
|
||||
private var lastPrefetchedStartMs = Long.MIN_VALUE
|
||||
|
||||
@@ -1403,15 +1453,19 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// u_Rgba switches the sampling mode: 0 = ALPHA_8 atlas mask tinted by the per-vertex
|
||||
// color (the common path), 1 = premultiplied RGBA composite sampled directly
|
||||
// (AssAtlasFrame.MODE_COMPOSITE). Both end premultiplied, matching the blend state.
|
||||
private val fragmentShaderCode = """
|
||||
precision mediump float;
|
||||
varying vec2 v_TexCoord;
|
||||
varying vec4 v_Color;
|
||||
uniform sampler2D u_Texture;
|
||||
uniform float u_Rgba;
|
||||
void main() {
|
||||
float mask = texture2D(u_Texture, v_TexCoord).a;
|
||||
float alpha = v_Color.a * mask;
|
||||
gl_FragColor = vec4(v_Color.rgb * alpha, alpha);
|
||||
vec4 texel = texture2D(u_Texture, v_TexCoord);
|
||||
float alpha = v_Color.a * texel.a;
|
||||
gl_FragColor = mix(vec4(v_Color.rgb * alpha, alpha), texel, u_Rgba);
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@@ -1423,11 +1477,18 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
private var allocatedPages = 0
|
||||
private var vertexBufferId = 0
|
||||
|
||||
// Texture for MODE_COMPOSITE frames (premultiplied RGBA rect); allocated lazily on
|
||||
// the first composite frame and re-specced when the rect outgrows it.
|
||||
private var rgbaTexId = 0
|
||||
private var rgbaAllocW = 0
|
||||
private var rgbaAllocH = 0
|
||||
|
||||
private var aPosition = 0
|
||||
private var aTexCoord = 0
|
||||
private var aColor = 0
|
||||
private var uTexture = 0
|
||||
private var uSurfaceSize = 0
|
||||
private var uRgba = 0
|
||||
|
||||
private var atlasAllocatedW = 0
|
||||
private var atlasAllocatedH = 0
|
||||
@@ -1477,6 +1538,7 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
aColor = glProgram.getAttributeArrayLocationAndEnable("a_Color")
|
||||
uTexture = glProgram.getUniformLocation("u_Texture")
|
||||
uSurfaceSize = glProgram.getUniformLocation("u_SurfaceSize")
|
||||
uRgba = glProgram.getUniformLocation("u_Rgba")
|
||||
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glUniform1i(uTexture, 0)
|
||||
@@ -1527,11 +1589,22 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, stride, 0)
|
||||
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, stride, 8)
|
||||
GLES20.glVertexAttribPointer(aColor, 4, GLES20.GL_FLOAT, false, stride, 16)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
|
||||
if (frame.mode == AssAtlasFrame.MODE_COMPOSITE) {
|
||||
// The whole frame is one premultiplied RGBA rect (atlasWidth × pageHeights[0])
|
||||
// at the start of the atlas buffer, drawn as the single emitted quad.
|
||||
GLES20.glUniform1f(uRgba, 1f)
|
||||
bindCompositeTexture()
|
||||
if (!reuseUploads) uploadComposite(payload.atlasBuf, frame.atlasWidth, frame.pageHeights[0])
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, quadCount * 6)
|
||||
return
|
||||
}
|
||||
|
||||
GLES20.glUniform1f(uRgba, 0f)
|
||||
// Each atlas page is its own texture; its quads are one contiguous run in the
|
||||
// stream (page assignment is monotonic in painter order). Upload + draw each in
|
||||
// turn, which reproduces the libass blend order across pages.
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
var quadOffset = 0
|
||||
for (p in 0 until frame.pageCount) {
|
||||
val pageQuads = frame.pageQuadCounts[p]
|
||||
@@ -1545,6 +1618,48 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates (once) and binds the RGBA composite texture. */
|
||||
private fun bindCompositeTexture() {
|
||||
if (rgbaTexId == 0) {
|
||||
val tex = IntArray(1)
|
||||
GLES20.glGenTextures(1, tex, 0)
|
||||
rgbaTexId = tex[0]
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
rgbaAllocW = 0
|
||||
rgbaAllocH = 0
|
||||
} else {
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads the composite RGBA rect from the start of the stacked atlas buffer into
|
||||
* the bound composite texture. GLES2 has no UNPACK_ROW_LENGTH, so a sub-image
|
||||
* update is only stride-correct at the allocated width; otherwise re-spec. */
|
||||
private fun uploadComposite(atlasBuf: ByteBuffer, width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) return
|
||||
atlasBuf.clear()
|
||||
atlasBuf.limit(width * height * 4)
|
||||
atlasBuf.position(0)
|
||||
if (width == rgbaAllocW && height <= rgbaAllocH) {
|
||||
GLES20.glTexSubImage2D(
|
||||
GLES20.GL_TEXTURE_2D, 0, 0, 0, width, height,
|
||||
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf
|
||||
)
|
||||
} else {
|
||||
GLES20.glTexImage2D(
|
||||
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
|
||||
width, height, 0,
|
||||
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf
|
||||
)
|
||||
rgbaAllocW = width
|
||||
rgbaAllocH = height
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads page [page]'s packed rows from the stacked atlas buffer into the
|
||||
* currently-bound page texture. */
|
||||
private fun uploadPage(atlasBuf: ByteBuffer, page: Int, atlasW: Int, pageH: Int) {
|
||||
@@ -1578,6 +1693,13 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
atlasTexIds.fill(0)
|
||||
allocatedPages = 0
|
||||
}
|
||||
if (rgbaTexId != 0) {
|
||||
val tex = intArrayOf(rgbaTexId)
|
||||
GLES20.glDeleteTextures(1, tex, 0)
|
||||
rgbaTexId = 0
|
||||
rgbaAllocW = 0
|
||||
rgbaAllocH = 0
|
||||
}
|
||||
if (vertexBufferId != 0) {
|
||||
val buf = intArrayOf(vertexBufferId)
|
||||
GLES20.glDeleteBuffers(1, buf, 0)
|
||||
|
||||
+2
-1
@@ -45,7 +45,8 @@ class AssSubtitleSurfaceView(
|
||||
pipeline?.requestRender(presentationTimeUs, releaseTimeNs)
|
||||
}
|
||||
|
||||
/** Re-renders the last position, e.g. after margin changes while paused. */
|
||||
/** Re-renders the last position — margin changes while paused, or clearing a
|
||||
* still-visible cue right after subtitles were disabled (#1884). */
|
||||
fun invalidateSubtitles() {
|
||||
pipeline?.invalidate()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user