Compare commits
251
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
+53
-21
@@ -36,7 +36,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 +271,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 +492,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 +768,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 +787,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 +795,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
|
||||
|
||||
+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.14.0"
|
||||
sha256 "35fcbc5bcad13691fda335f662af0e9e5ffd0d9d202ecae17705a25cda2f58a6"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1282,7 +1313,7 @@ class ExoPlayerPlugin :
|
||||
val bufferSize = configuredBufferSizeBytes
|
||||
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
|
||||
core.setProperty("vo", "gpu")
|
||||
core.setProperty("vo", "gpu-next,gpu")
|
||||
core.setProperty("ao", "audiotrack")
|
||||
|
||||
if (bufferSize != null && bufferSize > 0) {
|
||||
@@ -1386,6 +1417,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()
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,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 +109,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 +273,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 +293,17 @@ class MpvPlayerCore private constructor(
|
||||
setOption("audio-display", "no")
|
||||
setOption("gapless-audio", "weak")
|
||||
} else {
|
||||
setOption("vo", "gpu")
|
||||
// gpu-next (libplacebo) is mpv 0.41's default VO and the only
|
||||
// Android path that applies Dolby Vision RPU reshaping — vo=gpu
|
||||
// renders the raw base layer, which for DV Profile 5 means
|
||||
// pink/purple IPTPQc2 output (#1902). Keep the explicit chain so
|
||||
// a device where gpu-next cannot initialize still gets the
|
||||
// legacy renderer instead of no video output. Film grain is left
|
||||
// on its `auto` default: applied by the VO under gpu-next, by
|
||||
// the decoder on the fallback path.
|
||||
setOption("vo", "gpu-next,gpu")
|
||||
setOption("gpu-context", "android")
|
||||
setOption("opengl-es", "yes")
|
||||
setOption("vd-lavc-film-grain", "cpu")
|
||||
if (displayFpsOverride != null) {
|
||||
setOption("display-fps-override", displayFpsOverride)
|
||||
}
|
||||
@@ -1041,6 +1045,7 @@ 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"),
|
||||
|
||||
@@ -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,8 +146,6 @@ open class MpvPlayerPlugin(
|
||||
channels.cancel()
|
||||
}
|
||||
|
||||
// MethodChannel.MethodCallHandler
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(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,6 +170,16 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(result: MethodChannel.Result) {
|
||||
// Video cores need the Activity (surface/view hierarchy); the audio-only
|
||||
// core is built on the application context so it can outlive it.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -48,6 +48,19 @@ 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 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.
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 790 B |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M1.928.029A2.47 2.47 0 0 0 .093 1.673c-.085.248-.09.629-.09 10.33s.005 10.08.09 10.33a2.51 2.51 0 0 0 1.512 1.558l.276.108h20.237l.277-.108a2.51 2.51 0 0 0 1.512-1.559c.085-.25.09-.63.09-10.33s-.005-10.08-.09-10.33A2.51 2.51 0 0 0 22.395.115l-.277-.109L12.117 0C6.615-.004 2.032.011 1.929.029m7.48 8.067 2.123 2.004v1.54c0 .897-.02 1.536-.043 1.527s-.92-.845-1.995-1.86c-1.071-1.01-1.962-1.84-1.977-1.84s-.024 1.91-.024 4.248v4.25H4.911V6.085h1.188l1.183.006zm9.729 3.93v5.94h-2.63l-.01-4.25-.013-4.25-1.907 1.795a367 367 0 0 1-1.98 1.864c-.076.056-.08-.047-.08-1.489v-1.555l2.127-1.995 2.122-1.995 1.187-.005h1.184z"/></svg>
|
||||
|
After Width: | Height: | Size: 715 B |
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata id="metadata3396">
|
||||
<rdf:RDF>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata id="metadata3886">
|
||||
<rdf:RDF>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user