Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc8fdf7e8 | ||
|
|
f107da32c3 |
+2
-2
@@ -3,5 +3,5 @@
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro/maestro_fixtures.py
|
||||
!scripts/maestro/maestro_real_jellyfin.py
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
|
||||
@@ -77,12 +77,11 @@ body:
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Are you using Jellyfin, Plex or Emby?
|
||||
description: Are you using Jellyfin or Plex?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
- Emby
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ body:
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
- Emby
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: >-
|
||||
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
|
||||
in the stable manifest) and `channel: master` would clone master HEAD, whose
|
||||
engine is not the patched revision install-patched-engine.ps1 asserts
|
||||
(5d531788). This file is the only place that pin lives: the tag is fetched so
|
||||
(4c525dac). This file is the only place that pin lives: the tag is fetched so
|
||||
the SDK reports its own version, then verified against the immutable commit
|
||||
and against the version Flutter itself reports, so a moved tag fails the job
|
||||
instead of quietly changing SDKs.
|
||||
@@ -16,8 +16,8 @@ runs:
|
||||
- name: Clone Flutter from its immutable commit
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "3.47.1"
|
||||
$expectedCommit = "6655482ec06e547f90abf8ae7590466f4415978d"
|
||||
$version = "3.44.0"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
name: Build
|
||||
run-name: ${{ inputs.release_tag != '' && format('Release {0}', inputs.release_tag) || format('Build {0}', github.sha) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Tag for a draft release; omit to build Actions artifacts only
|
||||
default: ''
|
||||
type: string
|
||||
build_android:
|
||||
description: Build Android
|
||||
default: true
|
||||
@@ -31,7 +26,7 @@ on:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.47.1"
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
# Shared by every release build command; SENTRY_DIST stays per-platform.
|
||||
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
|
||||
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
|
||||
@@ -53,7 +48,10 @@ jobs:
|
||||
- name: Require the protected main branch
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
|
||||
# TEMPORARY: ab/hdr-keep-alive-probe is admitted exactly (no
|
||||
# wildcards) to build the #1874 HDR-oscillation A/B artifact. Drop
|
||||
# this arm together with the keepalive probe commit.
|
||||
if [[ "$GITHUB_REF" != "refs/heads/main" && "$GITHUB_REF" != "refs/heads/ab/hdr-keep-alive-probe" ]]; then
|
||||
echo "Release builds may only run from refs/heads/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -72,7 +70,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
@@ -865,7 +863,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux && inputs.release_tag != '' }}
|
||||
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -884,21 +882,6 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate release tag
|
||||
if: ${{ inputs.release_tag != '' }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Release tag must use semantic versioning: $RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$RELEASE_TAG" != "$VERSION" ]]; then
|
||||
echo "Release tag $RELEASE_TAG does not match pubspec version $VERSION." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download Android artifacts
|
||||
if: ${{ inputs.build_android }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -1063,8 +1046,5 @@ jobs:
|
||||
files: ${{ steps.release-files.outputs.files }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
name: ${{ inputs.release_tag }}
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.47.1"
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
run: scripts/codegen.sh --check
|
||||
|
||||
- name: Verify translation hygiene
|
||||
run: python3 scripts/checks/clean_translations.py --check --strict
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
@@ -75,10 +75,10 @@ jobs:
|
||||
xargs -0 -r dart format --output=none --set-exit-if-changed
|
||||
|
||||
- name: Verify icon consistency
|
||||
run: dart run scripts/checks/check_icon_consistency.dart
|
||||
run: dart run scripts/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/checks/check_analyzer.dart
|
||||
run: dart run scripts/check_analyzer.dart
|
||||
|
||||
- name: Check for unused code
|
||||
run: |
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -317,8 +317,7 @@ jobs:
|
||||
mpv_property_result_contract_test \
|
||||
hdr_metadata_test \
|
||||
plane_geometry_test \
|
||||
video_params_test \
|
||||
plane_render_executor_test
|
||||
video_params_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
|
||||
@@ -10,7 +10,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro/maestro_fixtures.py scripts/maestro/maestro_real_jellyfin.py scripts/maestro/prepare_maestro_media.py ./
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/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/
|
||||
|
||||
+7
-11
@@ -16,10 +16,6 @@
|
||||
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
|
||||
- Test your changes thoroughly
|
||||
|
||||
### AI-assisted contributions
|
||||
|
||||
AI-assisted pull requests must state in the PR description which model(s) were used (e.g. Claude Sonnet 4.5, GPT-5, Gemini 3 Pro).
|
||||
|
||||
### Code Quality Checks
|
||||
|
||||
The project includes automated CI checks that run on all pull requests:
|
||||
@@ -55,22 +51,22 @@ 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/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
|
||||
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
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/maestro/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
python3 scripts/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 groups are declared in `scripts/maestro/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/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.
|
||||
|
||||
@@ -79,7 +75,7 @@ manual target rather than an automatic group. Run them only against the pre-seed
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/maestro/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
python3 scripts/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
|
||||
@@ -106,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/checks/check_container_image_pins.py`, `python3 scripts/checks/test_check_container_image_pins.py`, and
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/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.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
version "2.13.0"
|
||||
sha256 "045f632883b3418a509c95170fc7767b619280873a66c8d96aa71e01cd35408d"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -148,7 +148,7 @@ Package managers:
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
- Flutter SDK 3.47.0+
|
||||
- Flutter SDK 3.44.0+
|
||||
- A Plex account, or a Jellyfin or Emby server with user credentials
|
||||
|
||||
### Setup
|
||||
@@ -190,7 +190,7 @@ scripts/setup_hooks.sh
|
||||
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
|
||||
|
||||
```bash
|
||||
python3 scripts/maestro/run_maestro.py basic
|
||||
python3 scripts/run_maestro.py basic
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -4,13 +4,6 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -58,19 +58,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.2.2"
|
||||
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
// Dev-only escape hatch: point the build at a locally built libmpv AAR
|
||||
// (libmpv-android's build-local.sh output) to test fork patches before a
|
||||
// release is pinned. The URL + sha256 above stay authoritative otherwise.
|
||||
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
val media3Version = "1.10.1"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
@@ -82,8 +76,6 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
inputs.property("localOverride", localMpvAar ?: "")
|
||||
localMpvAar?.let { inputs.file(it) }
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
@@ -91,20 +83,15 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
if (localMpvAar != null) {
|
||||
File(localMpvAar).copyTo(stagedAar, overwrite = true)
|
||||
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
|
||||
} else {
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
|
||||
Vendored
-17
@@ -18,20 +18,3 @@
|
||||
|
||||
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
|
||||
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
|
||||
|
||||
# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
|
||||
# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
|
||||
# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
|
||||
# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
|
||||
# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
|
||||
# extractorOutput the same way to wrap LATM tracks.
|
||||
#
|
||||
# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
|
||||
# them in its companion object, so the throw surfaces as ExceptionInInitializerError
|
||||
# while constructing the extractor — every MKV direct-play, release builds only. Only
|
||||
# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
|
||||
# shrinking through the compile-time subclass references.
|
||||
-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
|
||||
private androidx.media3.extractor.ExtractorOutput extractorOutput;
|
||||
private androidx.media3.common.util.ParsableByteArray subtitleSample;
|
||||
}
|
||||
|
||||
Binary file not shown.
-171
@@ -1,171 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
|
||||
*
|
||||
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
|
||||
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
|
||||
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
|
||||
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
|
||||
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
|
||||
* be retired. The second test drives the production wrapper stack and requires the seek to
|
||||
* actually land.
|
||||
*/
|
||||
class MatroskaLateTracksSeekDeviceTest {
|
||||
private companion object {
|
||||
const val TAG = "MkvLateTracksSeek"
|
||||
const val SEEK_TARGET_MS = 500L
|
||||
const val SETTLE_MS = 2_000L
|
||||
}
|
||||
|
||||
private class Session(
|
||||
val handler: Handler,
|
||||
val thread: HandlerThread,
|
||||
val player: AtomicReference<ExoPlayer?>,
|
||||
val fixture: File
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stockMatroskaExtractorSnapsSeeksToStart() {
|
||||
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
|
||||
assertFalse(
|
||||
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
|
||||
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
|
||||
result.seekable
|
||||
)
|
||||
assertTrue(
|
||||
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrappedExtractorKeepsSeekPosition() {
|
||||
val result = runSeekScenario("wrapped") {
|
||||
val assHandler = AssHandler()
|
||||
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
|
||||
}
|
||||
assertTrue("wrapped extractor must report the item seekable", result.seekable)
|
||||
assertTrue(
|
||||
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
|
||||
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
|
||||
)
|
||||
}
|
||||
|
||||
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
|
||||
|
||||
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
|
||||
val session = openPaused(label, extractorFactory)
|
||||
try {
|
||||
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
|
||||
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
|
||||
// The masked position is the seek target; the snap surfaces once the period resolves the
|
||||
// seek, so give the load a moment before sampling the settled position.
|
||||
Thread.sleep(SETTLE_MS)
|
||||
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
|
||||
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
|
||||
return ScenarioResult(seekable, positionAfterSeekMs)
|
||||
} finally {
|
||||
teardown(session)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixture = copyFixture(context)
|
||||
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val player = AtomicReference<ExoPlayer?>(null)
|
||||
val ready = CountDownLatch(1)
|
||||
val failed = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
val exo = ExoPlayer.Builder(context).build()
|
||||
player.set(exo)
|
||||
exo.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) ready.countDown()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Log.e(TAG, "$label player error", error)
|
||||
failed.set(true)
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
ExtractorsFactory { arrayOf(extractorFactory()) }
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
|
||||
exo.setMediaSource(source)
|
||||
exo.playWhenReady = false
|
||||
exo.prepare()
|
||||
}
|
||||
|
||||
val session = Session(handler, thread, player, fixture)
|
||||
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
|
||||
teardown(session)
|
||||
error("$label playback never became ready")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
|
||||
val value = AtomicReference<T>()
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.let { value.set(read(it)) }
|
||||
done.countDown()
|
||||
}
|
||||
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
|
||||
return checkNotNull(value.get())
|
||||
}
|
||||
|
||||
private fun teardown(session: Session) {
|
||||
val done = CountDownLatch(1)
|
||||
session.handler.post {
|
||||
session.player.get()?.release()
|
||||
done.countDown()
|
||||
}
|
||||
done.await(10, TimeUnit.SECONDS)
|
||||
session.thread.quitSafely()
|
||||
session.thread.join(5_000)
|
||||
session.fixture.delete()
|
||||
}
|
||||
|
||||
private fun copyFixture(context: android.content.Context): File {
|
||||
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("ffmpeg/matroska_tracks_at_end.mkv")
|
||||
.use { input -> output.outputStream().use { input.copyTo(it) } }
|
||||
return output
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -107,7 +107,7 @@ class TrueHdSpeedTransitionTest {
|
||||
// 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) {
|
||||
if (carrierEncoding != TrueHdMatPacker.CARRIER_SAMPLE_RATE || decoderBefore != null) {
|
||||
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
|
||||
teardown(handler, player, thread, fixture)
|
||||
return
|
||||
@@ -137,12 +137,12 @@ class TrueHdSpeedTransitionTest {
|
||||
|
||||
assertEquals(
|
||||
"returning to 1x must put TrueHD back on the carrier",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
rateRestored
|
||||
)
|
||||
assertNotEquals(
|
||||
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
|
||||
IecCarrier.SAMPLE_RATE,
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
encodingAfter
|
||||
)
|
||||
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
|
||||
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
|
||||
@Test
|
||||
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
if (!supportsIecCarrier(context)) {
|
||||
if (!supportsTrueHdMatCarrier()) {
|
||||
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
||||
return
|
||||
}
|
||||
@@ -297,13 +297,13 @@ class TrueHdSpeedTransitionTest {
|
||||
// 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("MAT/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,
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
rate
|
||||
)
|
||||
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
|
||||
|
||||
@@ -32,7 +32,6 @@ import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.shared.DeviceQuirks
|
||||
import com.edde746.plezy.shared.MediaCodecQuery
|
||||
import com.edde746.plezy.shared.ThemeHelper
|
||||
import com.edde746.plezy.watchnext.WatchNextPlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
@@ -736,7 +735,6 @@ class MainActivity : FlutterActivity() {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getVideoDecodeCapabilities" -> result.success(MediaCodecQuery.hardwareVideoDecodeSupport())
|
||||
"getBackgroundWorkSignals" -> result.success(
|
||||
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
|
||||
)
|
||||
@@ -884,8 +882,7 @@ class MainActivity : FlutterActivity() {
|
||||
} catch (e: IllegalStateException) {
|
||||
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to enter PiP", e)
|
||||
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to e.message))
|
||||
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error")))
|
||||
}
|
||||
}
|
||||
"setAutoPipReady" -> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -31,38 +30,6 @@ 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.
|
||||
@@ -76,88 +43,36 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** The IEC 61937 track shape a codec's spdif burst rides. */
|
||||
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
|
||||
|
||||
private class MpvSpdifCodec(
|
||||
val name: String,
|
||||
val encoding: Int,
|
||||
val shape: MpvIecShape,
|
||||
/** Whether the fork's audiotrack AO opens this codec as a raw bitstream track first. */
|
||||
val raw: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
|
||||
* bitstream, and the track shape the fork's `ao_audiotrack` opens for it.
|
||||
*
|
||||
* Since the raw-passthrough patch (0102), the AO feeds AC3, E-AC3 and the DTS core to a raw
|
||||
* `ENCODING_AC3`/`E_AC3`/`DTS` track at 48kHz/stereo — the transport ExoPlayer and Kodi use —
|
||||
* and only falls back to the IEC 61937 carrier when the route rejects the raw encoding
|
||||
* outright. Raw tracks keep the platform's Dolby/DTS transcoder in the path; a pre-packed IEC
|
||||
* track bypasses it and drains into silence on routes whose sink cannot decode the codec
|
||||
* itself (#2177's Shield in front of a Dolby-Digital-only Sonos). TrueHD (MAT) and DTS-HD MA
|
||||
* stay on the 8-channel 192kHz IEC carrier the multichannel patch (0101) added: their raw
|
||||
* forms are not recoverable from the burst stream.
|
||||
*
|
||||
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
|
||||
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
|
||||
* still chosen per file for tracks that are not HD (`ad_spdif.c:240-249`, `:400-418`). HRA rides
|
||||
* a 2ch/192kHz burst under the same name, so gating it on the 8-channel carrier is the
|
||||
* conservative choice.
|
||||
* mpv `audio-spdif` codec names and the exact platform encoding a route must
|
||||
* advertise to carry that bitstream.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
|
||||
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K, raw = true),
|
||||
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
|
||||
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
|
||||
* advertises the codec's encoding *and* a transport the AO's ladder can open exists —
|
||||
* [supportsRawTrack] for the raw bitstream track it tries first, or [supportsShape] for the
|
||||
* IEC 61937 burst it falls back to. Plain `dts` is dropped whenever `dts-hd` qualifies, which
|
||||
* already covers the core burst.
|
||||
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
|
||||
*
|
||||
* mpv force-passes through every codec named here and has no decode fallback, so an
|
||||
* unsupported name leaves the file rendering video against a dead audio output (#1703).
|
||||
*
|
||||
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
|
||||
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
|
||||
* channel counts above the route's PCM maximum, neither of which describes what a
|
||||
* passthrough track carries.
|
||||
* 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.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(
|
||||
supportsEncoding: (Int) -> Boolean,
|
||||
supportsShape: (MpvIecShape) -> Boolean,
|
||||
supportsRawTrack: (Int) -> Boolean = { false }
|
||||
): String {
|
||||
val carried = MPV_SPDIF_CODECS.filter {
|
||||
supportsEncoding(it.encoding) &&
|
||||
((it.raw && supportsRawTrack(it.encoding)) || supportsShape(it.shape))
|
||||
}
|
||||
val dtsHd = carried.any { it.name == "dts-hd" }
|
||||
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
|
||||
}
|
||||
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.
|
||||
*
|
||||
* Two conditions per codec, both required:
|
||||
* - The route must accept a track shape the AO's ladder actually opens. For AC3, E-AC3 and the
|
||||
* DTS core that is the raw bitstream track probed by [supportsMpvRawTrack], with the IEC
|
||||
* stereo shapes ([supportsMpvIecShape], [supportsMpvHighRateIecShape]) as the AO's fallback
|
||||
* transport; TrueHD and DTS-HD MA need the 192kHz/7.1 carrier ([supportsIecCarrier]).
|
||||
* Advertising the raw encoding only says the receiver decodes it, not that the HAL takes the
|
||||
* track: #1991's Shield strands playback on every mpv IEC attempt while bitstreaming AC3 raw.
|
||||
* The probes are independent, so none of them may veto the whole list: a route that takes the
|
||||
* 192kHz carrier but no raw track still bitstreams TrueHD and DTS-HD MA.
|
||||
* - The receiver must decode the codec itself — the raw encoding on the current
|
||||
* [AudioCapabilities] — because passthrough is transport, not transcoding. (On a raw track
|
||||
* the platform may additionally transcode, which is exactly why the AO prefers it.)
|
||||
*/
|
||||
/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */
|
||||
// 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")
|
||||
@@ -173,93 +88,11 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
|
||||
return ""
|
||||
}
|
||||
// Every probe costs real route calls and may be shared by more than one codec, so probe once.
|
||||
val shapeProbed = HashMap<MpvIecShape, Boolean>(3)
|
||||
val rawProbed = HashMap<Int, Boolean>(3)
|
||||
val codecs = mpvSpdifCodecs(
|
||||
capabilities::supportsEncoding,
|
||||
{ shape -> shapeProbed.getOrPut(shape) { routeTakesIecShape(context, shape) } },
|
||||
{ encoding -> rawProbed.getOrPut(encoding) { supportsMpvRawTrack(context, encoding) } }
|
||||
)
|
||||
if (codecs.isEmpty()) {
|
||||
Log.i(TAG, "Route takes no passthrough track mpv can fill; mpv will decode instead of bitstreaming")
|
||||
} else {
|
||||
Log.i(TAG, "mpv will bitstream: $codecs")
|
||||
}
|
||||
return codecs
|
||||
return mpvSpdifCodecs(capabilities::supportsEncoding)
|
||||
}
|
||||
|
||||
private fun routeTakesIecShape(context: Context, shape: MpvIecShape): Boolean = when (shape) {
|
||||
MpvIecShape.STEREO_48K -> supportsMpvIecShape(context)
|
||||
MpvIecShape.STEREO_192K -> supportsMpvHighRateIecShape(context)
|
||||
MpvIecShape.SURROUND_192K -> supportsIecCarrier(context)
|
||||
}
|
||||
|
||||
/** The track shape mpv's audiotrack AO opens for an AC3 or DTS-core burst: stereo at the mixer rate. */
|
||||
private const val MPV_IEC_SAMPLE_RATE = 48_000
|
||||
private const val MPV_IEC_CHANNEL_COUNT = 2
|
||||
private const val MPV_IEC_HIGH_SAMPLE_RATE = 192_000
|
||||
|
||||
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
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 the route takes a raw bitstream `AudioTrack` for [encoding] at the shape the fork's
|
||||
* `ao_audiotrack` opens first: the codec frame rate with a stereo mask (Kodi's raw shape; the
|
||||
* HAL reads the real channel layout from the bitstream). Same probe tiering as the IEC shapes,
|
||||
* except below API 29, where no runtime oracle exists for raw tracks and media3 gates its raw
|
||||
* path on the advertised encoding alone — which [supportedMpvSpdifCodecs] already requires via
|
||||
* [AudioCapabilities]. #1991's API 28 Shield bitstreams AC3 exactly this way.
|
||||
*/
|
||||
internal fun supportsMpvRawTrack(context: Context, encoding: Int): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeDirectBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, encoding) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
|
||||
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
|
||||
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_HIGH_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
|
||||
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
|
||||
* Whether this route can carry TrueHD as MAT inside IEC 61937 (#1804).
|
||||
*
|
||||
* 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
|
||||
@@ -267,8 +100,8 @@ internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSu
|
||||
* 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
|
||||
* Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback 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.
|
||||
*
|
||||
@@ -279,105 +112,72 @@ internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSu
|
||||
* 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.
|
||||
* - Below API 29 there is no oracle at all, so the carrier is not offered and TrueHD decodes as
|
||||
* before.
|
||||
*/
|
||||
internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
|
||||
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
|
||||
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
|
||||
canSizeCarrierBuffer = {
|
||||
try {
|
||||
AudioTrack.getMinBufferSize(
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
|
||||
AudioFormat.ENCODING_IEC61937
|
||||
) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
},
|
||||
// The SDK_INT guards repeat trueHdMatCarrierSupported'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))
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported()
|
||||
},
|
||||
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) }
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && iecCarrierDirectPlaybackSupported()
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* [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).
|
||||
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
|
||||
* API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
|
||||
* [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32.
|
||||
*/
|
||||
internal fun iecRouteSupported(
|
||||
internal fun trueHdMatCarrierSupported(
|
||||
sdkInt: Int,
|
||||
canSizeBuffer: () -> Boolean,
|
||||
canSizeCarrierBuffer: () -> Boolean,
|
||||
bitstreamSupported: () -> Boolean,
|
||||
directPlaybackSupported: () -> Boolean,
|
||||
hdmiRouteAdvertised: () -> Boolean
|
||||
directPlaybackSupported: () -> Boolean
|
||||
): Boolean = when {
|
||||
// ENCODING_IEC61937 itself only exists from API 24.
|
||||
sdkInt < Build.VERSION_CODES.N -> false
|
||||
!canSizeBuffer() -> false
|
||||
sdkInt < Build.VERSION_CODES.Q -> false
|
||||
!canSizeCarrierBuffer() -> false
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
|
||||
sdkInt >= Build.VERSION_CODES.Q -> directPlaybackSupported()
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
|
||||
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
|
||||
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
else -> directPlaybackSupported()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private fun iecBitstreamSupported(format: AudioFormat): Boolean = try {
|
||||
val support = AudioManager.getDirectPlaybackSupport(format, movieAudioAttributes())
|
||||
private fun iecCarrierBitstreamSupported(): Boolean = try {
|
||||
val support = AudioManager.getDirectPlaybackSupport(iecCarrierProbeFormat(), 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)
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", 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())
|
||||
private fun iecCarrierDirectPlaybackSupported(): Boolean = try {
|
||||
AudioTrack.isDirectPlaybackSupported(iecCarrierProbeFormat(), movieAudioAttributes())
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", 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 =
|
||||
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
|
||||
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(encoding)
|
||||
.setChannelMask(channelMask)
|
||||
.setSampleRate(sampleRate)
|
||||
/** The exact tuple the carrier's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
|
||||
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
|
||||
.build()
|
||||
|
||||
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
|
||||
|
||||
+5
-62
@@ -8,21 +8,14 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.SeekPoint
|
||||
import androidx.media3.extractor.TrackAwareSeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
|
||||
* Extractor wrapper that enables approximate seeking for MKV files without Cues.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
class CuelessSeekExtractorWrapper(
|
||||
@@ -45,9 +38,6 @@ 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) {
|
||||
@@ -135,13 +125,7 @@ class CuelessSeekExtractorWrapper(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
if (registeredTracks.none { it.first == id }) {
|
||||
registeredTracks.add(id to type)
|
||||
}
|
||||
return delegate.track(id, type)
|
||||
}
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
@@ -153,11 +137,6 @@ 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
|
||||
@@ -187,40 +166,4 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,17 +124,6 @@ object DoviBridge {
|
||||
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device can natively render single-layer Dolby Vision
|
||||
* Profile 5 (IPT-PQ-c2): it needs both a decoder advertising DvheStn and a
|
||||
* Dolby Vision display pipeline. P5 has no compatible base layer, so a
|
||||
* device that fails either check decodes it as plain HEVC with garbage
|
||||
* colors; callers route those sessions to software decode + gpu-next,
|
||||
* where libplacebo applies the RPU reshaping instead.
|
||||
*/
|
||||
fun canPlayDolbyVisionP5(context: Context): Boolean = deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn) &&
|
||||
displaySupportsDolbyVision(context)
|
||||
|
||||
fun displaySupportsDolbyVision(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
|
||||
@@ -156,17 +145,6 @@ object DoviBridge {
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Whether the active display advertises any HDR output type. */
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val display = getCurrentDisplay(context) ?: return false
|
||||
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
|
||||
Log.w(TAG, "Display HDR support: failed to query HDR types", error)
|
||||
return false
|
||||
}
|
||||
return hdrTypes.isNotEmpty()
|
||||
}
|
||||
|
||||
fun describeDisplayHdrCapabilities(context: Context): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
|
||||
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
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
|
||||
@@ -229,12 +226,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
private var trackSelector: DefaultTrackSelector? = null
|
||||
private var tunnelingUserEnabled: Boolean = true
|
||||
private var tunnelingDisabledForAudioCodec: Boolean = false
|
||||
|
||||
// Tunnelled playback is driven by the audio codec's clock, so without a
|
||||
// hardware audio decoder media3 never tunnels regardless of the flag —
|
||||
// used to skip selector churn for no-op flips (see
|
||||
// updateCurrentTunnelingState). True until an evaluation says otherwise.
|
||||
private var selectedAudioHasHwDecoder: Boolean = true
|
||||
private var tunnelingDisabledForVideoCodec: Boolean = false
|
||||
private var tunnelingDisabledForDecodedPcm: Boolean = false
|
||||
private var tunnelingDisabledForAudioRecovery: Boolean = false
|
||||
@@ -320,7 +311,6 @@ 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
|
||||
@@ -571,6 +561,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
fun initialize(
|
||||
bufferSizeBytes: Int? = null,
|
||||
bufferSizeAuto: Boolean = false,
|
||||
tunnelingEnabled: Boolean = true,
|
||||
audioPassthroughEnabled: Boolean = false,
|
||||
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
|
||||
@@ -735,12 +727,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
val handler = AssHandler()
|
||||
assHandler = 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))
|
||||
val assParserFactory = AssSubtitleParserFactory(handler)
|
||||
|
||||
// Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM
|
||||
// variant, wrap MP4 extractors with the DV converter when enabled.
|
||||
// Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
|
||||
// wrap MP4 extractors with DV converter when enabled.
|
||||
// Reads this.dvMode each time (not captured) so DV7→8.1 retry can
|
||||
// change mode and reload without reinitializing the player.
|
||||
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
|
||||
@@ -749,7 +739,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
extractorsFactory.createExtractors().map { extractor ->
|
||||
when {
|
||||
extractor is MatroskaExtractor -> {
|
||||
val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler)
|
||||
val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler)
|
||||
val inner = if (doviEnabled) {
|
||||
DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
|
||||
emitLog(level, prefix, message)
|
||||
@@ -775,7 +765,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
|
||||
.setSubtitleParserFactory(subtitleParserFactory)
|
||||
.setSubtitleParserFactory(assParserFactory)
|
||||
playbackMediaSourceFactory = mediaSourceFactory
|
||||
|
||||
// Wrap text renderers with subtitle delay support
|
||||
@@ -785,15 +775,21 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
// Buffer budget. Derived natively from device memory (LoadControlPolicy);
|
||||
// mpv's demuxer sizes itself the same way in MpvPlayerCore.
|
||||
// Buffer budget. `bufferSizeBytes` carries the user's explicit Buffer Size choice; on
|
||||
// Auto it still arrives (Dart derives it for mpv's demuxer, which shares the property)
|
||||
// but `bufferSizeAuto` says to ignore it here, because mpv's demuxer and ExoPlayer's
|
||||
// sample allocator have different shapes and different failure modes.
|
||||
val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
val availableMB = (memoryInfo.availMem / (1024 * 1024)).toInt()
|
||||
val largeHeapMB = activityManager.largeMemoryClass
|
||||
|
||||
val targetBufferBytes = LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
val targetBufferBytes = if (!bufferSizeAuto && bufferSizeBytes != null && bufferSizeBytes > 0) {
|
||||
bufferSizeBytes
|
||||
} else {
|
||||
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
|
||||
}
|
||||
|
||||
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
|
||||
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
|
||||
@@ -816,8 +812,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
emitLog(
|
||||
"info",
|
||||
"init",
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit " +
|
||||
"(heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
|
||||
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
|
||||
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
|
||||
"(${resolvedTier.name.lowercase()}), " +
|
||||
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
|
||||
@@ -1114,31 +1110,11 @@ 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
|
||||
@@ -1254,13 +1230,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
val hasSelectedText = hasSelectedTextTrack(tracks)
|
||||
if (!hasSelectedText && hadSelectedTextTrack) {
|
||||
lastSubtitleCues = emptyList()
|
||||
@@ -1792,7 +1767,8 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
pixelRatio,
|
||||
bitmapSubtitlePlaneAspect
|
||||
resizeMode,
|
||||
videoZoomScale
|
||||
)
|
||||
|
||||
activity.runOnUiThread {
|
||||
@@ -2317,20 +2293,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 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
|
||||
return false
|
||||
}
|
||||
|
||||
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
|
||||
@@ -2439,33 +2402,6 @@ 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(
|
||||
@@ -2697,18 +2633,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean {
|
||||
if (shouldTunnel == currentTunneledPlayback) return false
|
||||
// A switch to "off" that the selected audio decoder could never have
|
||||
// honored anyway is transparent to media3: tunnelled playback needs a
|
||||
// tunneling-capable audio codec (it owns the AV-sync clock), so a
|
||||
// software decoder already ignored the flag. Writing the selector
|
||||
// parameter regardless forces a renderer rebuild that can tear down a
|
||||
// live codec mid-queueInputBuffer — observed as
|
||||
// "queueInputBuffer ... Released state" right after tracks arrive from
|
||||
// the ffmpeg demuxer. Record the state without the churn.
|
||||
if (!shouldTunnel && !selectedAudioHasHwDecoder) {
|
||||
currentTunneledPlayback = false
|
||||
return false
|
||||
}
|
||||
currentTunneledPlayback = shouldTunnel
|
||||
val speed = exoPlayer?.playbackParameters?.speed ?: 1f
|
||||
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
|
||||
@@ -2736,7 +2660,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
}
|
||||
|
||||
val newDisabled = !hasHardwareAudioDecoder(mimeType)
|
||||
selectedAudioHasHwDecoder = !newDisabled
|
||||
if (newDisabled != tunnelingDisabledForAudioCodec) {
|
||||
tunnelingDisabledForAudioCodec = newDisabled
|
||||
emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}")
|
||||
@@ -3458,7 +3381,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
externalSubtitleUris.clear()
|
||||
externalSubtitleContainerUris.clear()
|
||||
lastSubtitleCues = emptyList()
|
||||
bitmapSubtitlePlaneAspect = null
|
||||
hadSelectedTextTrack = false
|
||||
audioTrackGroupMap.clear()
|
||||
subtitleTrackGroupMap.clear()
|
||||
@@ -4122,7 +4044,6 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
@@ -4130,17 +4051,11 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, matchResolution, onComplete)
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
// HDR content on an HDR display means the decoder's dataspace put the
|
||||
// display into HDR signaling; defer the rate restore past the HDR exit
|
||||
// (see FrameRateManager.clearVideoFrameRate).
|
||||
val transfer = currentVideoFormat?.colorInfo?.colorTransfer
|
||||
val hdrActive = (transfer == C.COLOR_TRANSFER_ST2084 || transfer == C.COLOR_TRANSFER_HLG) &&
|
||||
DoviBridge.displaySupportsHdr(activity)
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrActive)
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
}
|
||||
|
||||
private fun computeFrameRate(timestamps: LongArray): Float {
|
||||
@@ -4231,7 +4146,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
"audioMimeType" to audioFormat?.sampleMimeType,
|
||||
"audioSampleRate" to audioFormat?.sampleRate,
|
||||
"audioChannels" to audioFormat?.channelCount,
|
||||
"audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 },
|
||||
"audioBitrate" to audioFormat?.bitrate,
|
||||
"audioDecoderName" to audioDecoderInitName,
|
||||
"audioOutputEncoding" to audioTrackConfig?.encoding,
|
||||
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.mpv.MpvPlayerCore
|
||||
@@ -38,11 +40,6 @@ class ExoPlayerPlugin :
|
||||
private val mainHandler get() = channels.mainHandler
|
||||
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||
private var playerCore: ExoPlayerCore? = null
|
||||
|
||||
// The Dart instanceId that created the current core. A `dispose` carrying a
|
||||
// different token lost the ownership race to a successor and must not tear
|
||||
// down that successor's session; it is acknowledged without touching it.
|
||||
private var coreInstanceId: Long? = null
|
||||
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
|
||||
private var usingMpvFallback: Boolean = false
|
||||
private var fallbackInProgress: Boolean = false
|
||||
@@ -111,6 +108,8 @@ class ExoPlayerPlugin :
|
||||
|
||||
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
|
||||
|
||||
private var configuredBufferSizeBytes: Int? = null
|
||||
|
||||
private var sessionGeneration = 0
|
||||
private var mediaGeneration = 0
|
||||
private var fallbackMediaGeneration: Int? = null
|
||||
@@ -120,8 +119,7 @@ class ExoPlayerPlugin :
|
||||
private var mpvForwardGeneration: Int? = null
|
||||
private var mpvSignalGate: MpvSignalGate? = null
|
||||
private var mpvCoreNeedsReplacement = false
|
||||
internal var createMpvCore: (Activity) -> MpvPlayerCore =
|
||||
{ MpvPlayerCore(it, hardwareDecoding = fallbackHardwareDecoding()) }
|
||||
internal var createMpvCore: (Activity) -> MpvPlayerCore = { MpvPlayerCore(it) }
|
||||
internal var initializeMpvCore: (MpvPlayerCore, (Boolean) -> Unit) -> Unit = { core, onInitialized ->
|
||||
core.initialize(onInitialized)
|
||||
}
|
||||
@@ -141,12 +139,6 @@ class ExoPlayerPlugin :
|
||||
// every codec in audio-spdif with no decode fallback, so the fallback core's value is
|
||||
// derived from the audio route at the moment mpv actually starts (#1703).
|
||||
private var audioPassthroughRequested = false
|
||||
|
||||
// `dv-conversion-mode` is not an mpv property and never reaches
|
||||
// pendingMpvProperties: Dart routes it through setDvConversionMode (see
|
||||
// PlayerAndroid.setProperty), so the fallback core has to be seeded from the
|
||||
// last configured value or it loses the fork's P7/P5 handling entirely.
|
||||
private var dvConversionMode = "auto"
|
||||
private var currentExternalSubtitles: List<Map<String, Any?>>? = null
|
||||
|
||||
// FlutterPlugin
|
||||
@@ -166,7 +158,6 @@ class ExoPlayerPlugin :
|
||||
val exoCore = playerCore
|
||||
val fallbackCore = mpvCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
mpvCore = null
|
||||
usingMpvFallback = false
|
||||
fallbackInProgress = false
|
||||
@@ -183,7 +174,6 @@ class ExoPlayerPlugin :
|
||||
currentExternalSubtitles = null
|
||||
pendingMpvProperties.clear()
|
||||
audioPassthroughRequested = false
|
||||
dvConversionMode = "auto"
|
||||
if (clearActivity) {
|
||||
activity = null
|
||||
activityBinding = null
|
||||
@@ -234,7 +224,7 @@ class ExoPlayerPlugin :
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"open" -> handleOpen(call, result)
|
||||
"play" -> handlePlay(result)
|
||||
"pause" -> handlePause(result)
|
||||
@@ -260,6 +250,10 @@ class ExoPlayerPlugin :
|
||||
)
|
||||
"getStats" -> handleGetStats(result)
|
||||
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
|
||||
"getHeapSize" -> {
|
||||
val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
result.success(am?.largeMemoryClass ?: 0)
|
||||
}
|
||||
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
|
||||
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
|
||||
"setVideoZoom" -> handleSetVideoZoom(call, result)
|
||||
@@ -297,14 +291,20 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: false
|
||||
dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
|
||||
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
|
||||
// Auto sizing is decided natively (LoadControlPolicy). `bufferSizeBytes` still arrives
|
||||
// on Auto because Dart derives one for mpv's demuxer, which shares the property, and
|
||||
// the fallback replay below needs it.
|
||||
val bufferSizeAuto = call.argument<Boolean>("bufferSizeAuto") ?: false
|
||||
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
|
||||
val dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
|
||||
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
|
||||
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
|
||||
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
|
||||
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
|
||||
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
|
||||
configuredBufferSizeBytes = bufferSizeBytes
|
||||
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
|
||||
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
|
||||
audioPassthroughRequested = audioPassthroughEnabled
|
||||
@@ -343,8 +343,9 @@ class ExoPlayerPlugin :
|
||||
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
|
||||
}
|
||||
playerCore = core
|
||||
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
|
||||
val success = core.initialize(
|
||||
bufferSizeBytes = bufferSizeBytes,
|
||||
bufferSizeAuto = bufferSizeAuto,
|
||||
tunnelingEnabled = tunnelingEnabled,
|
||||
audioPassthroughEnabled = audioPassthroughEnabled,
|
||||
bufferTier = bufferTier
|
||||
@@ -372,15 +373,8 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<Number>("instanceId")?.toLong()
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
runOnMain {
|
||||
val owner = coreInstanceId
|
||||
if ((playerCore != null || mpvCore != null) && token != null && owner != null && token != owner) {
|
||||
Log.d(TAG, "Ignoring stale dispose (token=$token, core owner=$owner)")
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
teardownSession(clearActivity = false)
|
||||
Log.d(TAG, "Disposed")
|
||||
result.success(null)
|
||||
@@ -1021,19 +1015,14 @@ class ExoPlayerPlugin :
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
|
||||
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
||||
val matchResolution = call.argument<Boolean>("matchResolution") ?: false
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, " +
|
||||
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution"
|
||||
)
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
||||
val core = activeSurfaceCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
@@ -1134,24 +1123,12 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
if (usingMpvFallback) {
|
||||
// The fallback core owns the property now; dropping the write would
|
||||
// strand it on the mode ExoPlayer started with.
|
||||
val core = mpvCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
dvConversionMode = mode
|
||||
core.setProperty("dv-conversion-mode", mode) { outcome ->
|
||||
completeMpvPropertyResult(result, outcome, successValue = true)
|
||||
}
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
val handled = playerCore?.setDebugDvConversionMode(mode) == true
|
||||
if (handled) {
|
||||
// Keep the fallback seed on the value ExoPlayer is actually running.
|
||||
dvConversionMode = mode
|
||||
result.success(true)
|
||||
} else {
|
||||
result.error("INVALID_ARGS", "Invalid DV conversion mode: $mode", null)
|
||||
@@ -1324,16 +1301,6 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode intent for a fallback mpv core, read from the `hwdec` value Dart
|
||||
* already wrote for this session. It picks the core's initial vo chain
|
||||
* ([MpvPlayerCore.initialVideoOutput]), so a software-decoding session must
|
||||
* not inherit the hardware default: only gpu-next applies Dolby Vision RPU
|
||||
* reshaping, and a session that asked for software decode is usually the
|
||||
* one that needs it.
|
||||
*/
|
||||
private fun fallbackHardwareDecoding(): Boolean = pendingMpvProperties["hwdec"]?.let { it != "no" } ?: true
|
||||
|
||||
/**
|
||||
* Configure a freshly initialized MPV fallback core: replay the properties
|
||||
* and observers Dart registered against the ExoPlayer session, then resume
|
||||
@@ -1343,13 +1310,15 @@ class ExoPlayerPlugin :
|
||||
private fun prepareMpvFallback(core: MpvPlayerCore) {
|
||||
val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList()
|
||||
val observedProps = observedProperties.toList()
|
||||
val bufferSize = configuredBufferSizeBytes
|
||||
|
||||
// hwdec is not seeded here — Dart's write in pendingMpvProperties is the
|
||||
// single source of the fallback core's chain.
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
|
||||
core.setProperty("vo", "gpu")
|
||||
core.setProperty("ao", "audiotrack")
|
||||
// Dart routes dv-conversion-mode through setDvConversionMode, so unlike
|
||||
// hwdec it never reaches pendingMpvProperties.
|
||||
core.setProperty("dv-conversion-mode", dvConversionMode)
|
||||
|
||||
if (bufferSize != null && bufferSize > 0) {
|
||||
core.setProperty("demuxer-max-bytes", bufferSize.toString())
|
||||
}
|
||||
|
||||
for ((propName, propValue) in pendingProps) {
|
||||
core.setProperty(propName, propValue) { outcome ->
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -151,14 +151,14 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
)
|
||||
}
|
||||
|
||||
private var iecCarrierSink: IecCarrierSink? = null
|
||||
private var trueHdCarrierSink: TrueHdCarrierSink? = 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].
|
||||
* Call before setting a new source; see [TrueHdCarrierSink.beginMediaItem].
|
||||
*/
|
||||
fun beginMediaItem() {
|
||||
iecCarrierSink?.beginMediaItem()
|
||||
trueHdCarrierSink?.beginMediaItem()
|
||||
}
|
||||
|
||||
override fun buildAudioSink(
|
||||
@@ -166,15 +166,16 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean
|
||||
): AudioSink {
|
||||
// Media3 1.11's replacement is fixed at 500ms; preserve the dynamic 500–1000ms PCM policy.
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
|
||||
|
||||
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).
|
||||
// media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms — 40000
|
||||
// bytes at 640 kbps. Some HDMI routes reject a buffer that short outright and the only
|
||||
// retry media3 1.10.1 has is to keep halving it (#1790). Ask for a second up front;
|
||||
// upstream adopted the same 1s floor as its last-resort retry in #3207.
|
||||
.setPassthroughBufferDurationUs(500_000)
|
||||
.build()
|
||||
|
||||
@@ -204,17 +205,17 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
audioDiagnosticsLogger
|
||||
)
|
||||
|
||||
return IecCarrierSink(
|
||||
return TrueHdCarrierSink(
|
||||
defaultSink = processedSink,
|
||||
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
||||
carrierRouteAvailable = { supportsIecCarrier(context) },
|
||||
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
|
||||
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
||||
log = audioDiagnosticsLogger
|
||||
).also { iecCarrierSink = it }
|
||||
).also { trueHdCarrierSink = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
|
||||
* The delegate that carries packed TrueHD (#1804).
|
||||
*
|
||||
* Deliberately separate from the processed sink, and deliberately barren: an empty
|
||||
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
|
||||
|
||||
@@ -42,35 +42,16 @@ internal object SubtitleViewLayout {
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
pixelRatio: Float,
|
||||
planeAspect: Float?
|
||||
resizeMode: Int,
|
||||
zoomScale: Float
|
||||
): SubtitleViewDimensions? {
|
||||
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
|
||||
if (containerWidth <= 0 || containerHeight <= 0) return null
|
||||
|
||||
// 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 }
|
||||
// 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)
|
||||
}
|
||||
|
||||
private fun videoAspect(videoWidth: Int, videoHeight: Int, pixelRatio: Float): Float? {
|
||||
|
||||
+69
-114
@@ -16,16 +16,13 @@ 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.
|
||||
* Routes Dolby TrueHD through a MAT/IEC 61937 carrier, and everything else through the normal sink
|
||||
* (#1804).
|
||||
*
|
||||
* 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.
|
||||
* Android will not bitstream raw TrueHD on the TV routes measured for this issue: the platform
|
||||
* reports `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1
|
||||
* as bitstream-capable. Kodi models the same split and packs the carrier itself; media3 only ever
|
||||
* hands Android raw TrueHD, at the stream rate. This sink adds the missing path.
|
||||
*
|
||||
* **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,
|
||||
@@ -42,7 +39,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* delegates so either can be activated later; per-stream calls go to the active one alone.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class IecCarrierSink(
|
||||
internal class TrueHdCarrierSink(
|
||||
private val defaultSink: AudioSink,
|
||||
private val carrierSink: AudioSink,
|
||||
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
|
||||
@@ -52,39 +49,39 @@ internal class IecCarrierSink(
|
||||
private val log: ((String, String, String) -> Unit)? = null
|
||||
) : AudioSink {
|
||||
|
||||
private val matPacker = TrueHdMatPacker()
|
||||
private val dtsHdPacker = DtsHdIecPacker()
|
||||
private companion object {
|
||||
/** One MAT frame is exactly one carrier period: 3840 frames at 192kHz. */
|
||||
const val CARRIER_BURST_DURATION_US =
|
||||
TrueHdMatPacker.MAT_PKT_OFFSET.toLong() / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME *
|
||||
1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
|
||||
}
|
||||
|
||||
/** The packer for the configured stream; chosen by mime type in [configure]. */
|
||||
private var activePacker: IecCarrierPacker = matPacker
|
||||
private val packer = TrueHdMatPacker()
|
||||
|
||||
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.
|
||||
* Anchor for carrier timestamps, and how many bursts 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).
|
||||
* Each MAT frame is exactly one carrier period of audio, so timestamps are derived from the
|
||||
* cadence rather than from whichever access unit happened to close the frame. 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 frame.
|
||||
*/
|
||||
private var carrierAnchorUs: Long = C.TIME_UNSET
|
||||
private var carrierFramesSinceAnchor: Long = 0
|
||||
private var burstsSinceAnchor: 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.
|
||||
* Latched when a stream's bitstream contradicts the rate 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
|
||||
@@ -109,6 +106,7 @@ internal class IecCarrierSink(
|
||||
@Volatile
|
||||
private var mismatchGeneration = -1
|
||||
|
||||
|
||||
/**
|
||||
* True when this format should ride the carrier.
|
||||
*
|
||||
@@ -117,11 +115,11 @@ internal class IecCarrierSink(
|
||||
* [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.
|
||||
* from a major sync 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 (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false
|
||||
if (mismatchGeneration == mediaGeneration.get()) return false
|
||||
if (!isCarrierRateFamily(format.sampleRate)) return false
|
||||
if (playbackParameters.speed != 1f) return false
|
||||
@@ -139,15 +137,15 @@ internal class IecCarrierSink(
|
||||
private fun carrierFormat(): Format = Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setPcmEncoding(C.ENCODING_PCM_16BIT)
|
||||
.setChannelCount(IecCarrier.CHANNEL_COUNT)
|
||||
.setSampleRate(IecCarrier.SAMPLE_RATE)
|
||||
.setChannelCount(TrueHdMatPacker.CARRIER_CHANNEL_COUNT)
|
||||
.setSampleRate(TrueHdMatPacker.CARRIER_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
|
||||
* configuration this issue 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
|
||||
@@ -156,68 +154,44 @@ internal class IecCarrierSink(
|
||||
*/
|
||||
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
|
||||
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
|
||||
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"
|
||||
"TrueHD via MAT/IEC 61937 carrier at ${TrueHdMatPacker.CARRIER_SAMPLE_RATE}Hz/" +
|
||||
"${TrueHdMatPacker.CARRIER_CHANNEL_COUNT}ch"
|
||||
} else {
|
||||
"Leaving the IEC 61937 carrier; audio returns to the normal sink"
|
||||
"Leaving the MAT 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)
|
||||
carrierSink.configure(carrierFormat(), specifiedBufferSize, null)
|
||||
} else {
|
||||
defaultSink.configure(audioSinkConfig)
|
||||
defaultSink.configure(inputFormat, specifiedBufferSize, outputChannels)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -239,30 +213,37 @@ internal class IecCarrierSink(
|
||||
|
||||
var offset = 0
|
||||
while (offset < remaining.size) {
|
||||
val length = activePacker.accessUnitLength(remaining, offset, remaining.size)
|
||||
if (activePacker.unsupportedStream) return latchMismatch()
|
||||
val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size)
|
||||
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()
|
||||
val burst = packer.packAccessUnit(remaining, offset, length)
|
||||
if (packer.unsupportedRateFamily) {
|
||||
// Selection is made from Format.sampleRate, so the bitstream disagrees with its container.
|
||||
// The packer emits nothing in that state; consuming here would turn the stream into
|
||||
// silence. Leave this unit in the buffer, latch the carrier off, and ask for reselection so
|
||||
// the decoder takes over and receives it.
|
||||
if (mismatchGeneration != configuredGeneration) {
|
||||
mismatchGeneration = configuredGeneration
|
||||
log?.invoke(
|
||||
"warn",
|
||||
"audio",
|
||||
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
|
||||
"leaving the carrier so it decodes"
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
return false
|
||||
}
|
||||
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
|
||||
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
|
||||
burstsSinceAnchor++
|
||||
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
|
||||
pendingBurst = burst
|
||||
pendingBurstTimeUs = burstTimeUs
|
||||
@@ -272,35 +253,10 @@ internal class IecCarrierSink(
|
||||
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.
|
||||
// A partially filled MAT frame cannot be emitted; up to 20ms is dropped at the end of a stream.
|
||||
active.playToEndOfStream()
|
||||
}
|
||||
|
||||
@@ -325,12 +281,11 @@ internal class IecCarrierSink(
|
||||
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
|
||||
|
||||
private fun discardCarrierState() {
|
||||
matPacker.reset()
|
||||
dtsHdPacker.reset()
|
||||
packer.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
|
||||
burstsSinceAnchor = 0
|
||||
}
|
||||
|
||||
// --- Persistent state: mirrored, so either delegate can be activated later ---
|
||||
@@ -362,23 +317,23 @@ internal class IecCarrierSink(
|
||||
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.
|
||||
// Crossing 1x changes whether TrueHD 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 TrueHD 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"
|
||||
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the TrueHD carrier"
|
||||
)
|
||||
sinkListener?.onAudioCapabilitiesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the configured stream is currently riding the carrier. */
|
||||
/** Whether TrueHD is currently riding the carrier. Read by the core when speed changes. */
|
||||
val isCarrierActive: Boolean
|
||||
get() = carrierActive
|
||||
|
||||
@@ -386,7 +341,7 @@ internal class IecCarrierSink(
|
||||
* 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
|
||||
* reported instead; the reselection triggered above then moves TrueHD onto the decoder, which
|
||||
* really can apply them.
|
||||
*/
|
||||
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
|
||||
@@ -448,7 +403,7 @@ internal class IecCarrierSink(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* "this stream lied about its rate" stops being true and the carrier can be offered again.
|
||||
*/
|
||||
fun beginMediaItem() {
|
||||
mediaGeneration.incrementAndGet()
|
||||
@@ -24,7 +24,7 @@ import java.nio.ByteBuffer
|
||||
*
|
||||
* Not thread safe; the sink drives it from the playback thread only.
|
||||
*/
|
||||
internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
internal class TrueHdMatPacker {
|
||||
|
||||
internal companion object {
|
||||
/** Payload bytes in one MAT frame. */
|
||||
@@ -33,6 +33,11 @@ internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
/** Bytes from the start of one burst to the next, including preamble and trailing gap. */
|
||||
const val MAT_PKT_OFFSET = 61440
|
||||
|
||||
/** Carrier the packed stream must be played at. */
|
||||
const val CARRIER_SAMPLE_RATE = 192_000
|
||||
const val CARRIER_CHANNEL_COUNT = 8
|
||||
const val CARRIER_BYTES_PER_FRAME = CARRIER_CHANNEL_COUNT * 2
|
||||
|
||||
private const val BURST_HEADER_SIZE = 8
|
||||
private const val SYNCWORD1 = 0xF872
|
||||
private const val SYNCWORD2 = 0x4E1F
|
||||
@@ -94,7 +99,7 @@ internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
private var samplesPerFrame = 0
|
||||
|
||||
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
|
||||
override fun reset() {
|
||||
fun reset() {
|
||||
matBufferIndex = 0
|
||||
matBufferFilled = 0
|
||||
previousTiming = 0
|
||||
@@ -102,7 +107,7 @@ internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
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
|
||||
unsupportedRateFamily = false
|
||||
java.util.Arrays.fill(matBuffers[0], 0)
|
||||
java.util.Arrays.fill(matBuffers[1], 0)
|
||||
}
|
||||
@@ -114,11 +119,9 @@ internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
* 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
|
||||
var unsupportedRateFamily: 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.
|
||||
*
|
||||
@@ -134,7 +137,7 @@ internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
* 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? {
|
||||
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
|
||||
if (length < MIN_ACCESS_UNIT_LENGTH) return null
|
||||
|
||||
if (hasMajorSync(data, offset, offset + length)) {
|
||||
@@ -145,11 +148,11 @@ internal class TrueHdMatPacker : IecCarrierPacker {
|
||||
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
|
||||
unsupportedRateFamily = (rateBits and 8) != 0
|
||||
if (unsupportedRateFamily) return null
|
||||
samplesPerFrame = 40 shl (rateBits and 3)
|
||||
}
|
||||
if (unsupportedStream || samplesPerFrame == 0) return null
|
||||
if (unsupportedRateFamily || samplesPerFrame == 0) return null
|
||||
|
||||
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
|
||||
var paddingRemaining = 0
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
@@ -11,9 +8,6 @@ import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
|
||||
import java.io.EOFException
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Inflater
|
||||
|
||||
/**
|
||||
* Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
|
||||
@@ -22,19 +16,8 @@ import java.util.zip.Inflater
|
||||
* stripping). This subclass intercepts the compression algorithm during track
|
||||
* header parsing:
|
||||
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
|
||||
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
|
||||
* - Skips ContentCompSettings for zlib tracks (not applicable)
|
||||
* - For text subtitle tracks, inflates the block payload *before* the parent
|
||||
* parses it (see below). For every other zlib track, wraps TrackOutputs with
|
||||
* ZlibInflatingTrackOutput to decompress per-sample data.
|
||||
*
|
||||
* Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput
|
||||
* level: MatroskaExtractor rewrites their samples in-place before any TrackOutput
|
||||
* runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to
|
||||
* the still-compressed payload and truncates the sample at the first NUL byte,
|
||||
* which deflate streams routinely contain (#2023). AssTrackOutput additionally
|
||||
* feeds that same internal buffer straight to libass. So for zlib text tracks the
|
||||
* frame payload is inflated at the block level, before the parent's sample
|
||||
* assembly, and the TrackOutput wrapper is left inactive.
|
||||
*
|
||||
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
|
||||
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
|
||||
@@ -53,43 +36,15 @@ class ZlibMatroskaExtractor(
|
||||
private const val ID_TRACK_ENTRY = 0xAE
|
||||
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
|
||||
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
|
||||
private const val ID_CONTENT_COMPRESSION = 0x5034
|
||||
private const val ID_SIMPLE_BLOCK = 0xA3
|
||||
private const val ID_BLOCK = 0xA1
|
||||
|
||||
/**
|
||||
* Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix
|
||||
* plus NUL truncation) before any TrackOutput runs — the authority is the
|
||||
* prefix list in MatroskaExtractor.writeSampleData.
|
||||
*/
|
||||
private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT")
|
||||
|
||||
/** Subtitle blocks are tiny; anything above this passes through untouched. */
|
||||
private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024
|
||||
}
|
||||
|
||||
private var zlibOutput: ZlibExtractorOutputWrapper? = null
|
||||
private var latmOutput: LatmExtractorOutputWrapper? = null
|
||||
private var currentTrackUsesZlib = false
|
||||
|
||||
/** Track numbers whose blocks are inflated before the parent parses them. */
|
||||
private val zlibTextTrackNumbers = mutableSetOf<Int>()
|
||||
private val blockInflater = Inflater()
|
||||
private var blockBuf = ByteArray(0)
|
||||
private val peekBuf = ByteArray(8)
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
|
||||
// ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for
|
||||
// zlib tracks entirely — the mere presence of ContentCompression means zlib
|
||||
// until an explicit ContentCompAlgo says otherwise. media3 ignores an empty
|
||||
// ContentCompression and would silently emit compressed samples.
|
||||
if (id == ID_CONTENT_COMPRESSION) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise")
|
||||
}
|
||||
|
||||
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
|
||||
// LATM layers (zlib outermost so inflation runs before LATM parsing).
|
||||
if (id == ID_SEGMENT && zlibOutput == null) {
|
||||
@@ -104,17 +59,12 @@ class ZlibMatroskaExtractor(
|
||||
}
|
||||
|
||||
override fun integerElement(id: Int, value: Long) {
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM) {
|
||||
if (value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
}
|
||||
// Explicit non-zlib algorithm: header stripping (3) is handled by the
|
||||
// parent; anything else makes the parent throw, matching stock behavior.
|
||||
currentTrackUsesZlib = false
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
}
|
||||
super.integerElement(id, value)
|
||||
}
|
||||
@@ -126,47 +76,27 @@ class ZlibMatroskaExtractor(
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) &&
|
||||
zlibTextTrackNumbers.isNotEmpty() &&
|
||||
contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES &&
|
||||
peekBlockTrackNumber(input) in zlibTextTrackNumbers
|
||||
) {
|
||||
inflateTextBlock(id, contentSize, input)
|
||||
return
|
||||
}
|
||||
super.binaryElement(id, contentSize, input)
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
var zlibTextTrackNumber: Int? = null
|
||||
if (id == ID_TRACK_ENTRY) {
|
||||
// Must inspect before super — the track output is created inside super's
|
||||
// endMasterElement, and the current track is cleared afterwards.
|
||||
// Must mark before super — the track output is created inside super's
|
||||
// endMasterElement, and the x-unknown format must never reach the queue.
|
||||
val track = getCurrentTrack(id)
|
||||
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
|
||||
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
|
||||
latmOutput?.markNextTrackLatm()
|
||||
}
|
||||
if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) {
|
||||
zlibTextTrackNumber = track.number
|
||||
}
|
||||
}
|
||||
|
||||
val wasZlib = currentTrackUsesZlib
|
||||
super.endMasterElement(id)
|
||||
|
||||
if (id == ID_TRACK_ENTRY && wasZlib) {
|
||||
zlibOutput?.activateLast()
|
||||
currentTrackUsesZlib = false
|
||||
if (zlibTextTrackNumber != null) {
|
||||
// Text subtitle samples are rewritten inside the parent before any
|
||||
// TrackOutput runs, so the TrackOutput wrapper stays inactive and the
|
||||
// block payload is inflated in binaryElement instead.
|
||||
zlibTextTrackNumbers.add(zlibTextTrackNumber)
|
||||
Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level")
|
||||
} else {
|
||||
zlibOutput?.activateLast()
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
}
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,64 +106,6 @@ class ZlibMatroskaExtractor(
|
||||
super.seek(position, timeUs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Peeks the EBML varint at the block start — the block's track number — without
|
||||
* consuming input. Returns null for malformed varints or truncated input; the
|
||||
* parent then produces the canonical failure for the untouched stream.
|
||||
*/
|
||||
private fun peekBlockTrackNumber(input: ExtractorInput): Int? {
|
||||
try {
|
||||
input.peekFully(peekBuf, 0, 1)
|
||||
val first = peekBuf[0].toInt() and 0xFF
|
||||
if (first == 0) return null
|
||||
val length = Integer.numberOfLeadingZeros(first) - 23
|
||||
var value = (first and (0xFF ushr length)).toLong()
|
||||
if (length > 1) {
|
||||
input.peekFully(peekBuf, 1, length - 1)
|
||||
for (i in 1 until length) {
|
||||
value = (value shl 8) or (peekBuf[i].toLong() and 0xFF)
|
||||
}
|
||||
}
|
||||
return if (value <= Int.MAX_VALUE) value.toInt() else null
|
||||
} catch (_: EOFException) {
|
||||
return null
|
||||
} finally {
|
||||
input.resetPeekPosition()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers one text-subtitle block, inflates its frame payload, and hands the
|
||||
* parent a block whose payload is plaintext. A block that cannot be rewritten
|
||||
* (laced, corrupt, or over-bound) passes through byte-identical.
|
||||
*/
|
||||
private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
val basePosition = input.position
|
||||
if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2))
|
||||
input.readFully(blockBuf, 0, contentSize)
|
||||
val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater)
|
||||
if (rewritten == null) {
|
||||
Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)")
|
||||
super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition))
|
||||
} else {
|
||||
super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition))
|
||||
}
|
||||
}
|
||||
|
||||
private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit)
|
||||
|
||||
private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader {
|
||||
private var position = 0
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position == limit) return C.RESULT_END_OF_INPUT
|
||||
val count = minOf(length, limit - position)
|
||||
System.arraycopy(data, position, buffer, offset, count)
|
||||
position += count
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
|
||||
* Tracks are created inactive; activateLast() enables inflation for the most recently
|
||||
@@ -266,50 +138,3 @@ class ZlibMatroskaExtractor(
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */
|
||||
internal const val MIN_BLOCK_BYTES = 5
|
||||
|
||||
/**
|
||||
* Rewrites one unlaced Matroska block whose frame payload is zlib-compressed:
|
||||
* returns the unchanged block header followed by the inflated payload, or null
|
||||
* when the block must pass through unchanged — lacing (never produced for text
|
||||
* subtitle tracks), a truncated or corrupt deflate stream, or an inflated size
|
||||
* beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening.
|
||||
*/
|
||||
internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? {
|
||||
if (limit < MIN_BLOCK_BYTES) return null
|
||||
val first = data[0].toInt() and 0xFF
|
||||
if (first == 0) return null // track-number varint longer than 8 bytes
|
||||
val varintLength = Integer.numberOfLeadingZeros(first) - 23
|
||||
val headerLength = varintLength + 3 // varint + 2-byte timecode + flags
|
||||
if (limit <= headerLength) return null
|
||||
if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced
|
||||
|
||||
val payloadLength = limit - headerLength
|
||||
val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024)
|
||||
val maxInflatedBytes = 16 * 1024 * 1024
|
||||
inflater.reset()
|
||||
inflater.setInput(data, headerLength, payloadLength)
|
||||
var buf = ByteArray(maxOf(4096, payloadLength * 4))
|
||||
var written = 0
|
||||
try {
|
||||
while (true) {
|
||||
if (written == buf.size) {
|
||||
if (buf.size >= maxInflatedBytes) return null
|
||||
buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2))
|
||||
}
|
||||
val count = inflater.inflate(buf, written, buf.size - written)
|
||||
written += count
|
||||
if (written > ratioBound) return null
|
||||
if (inflater.finished()) break
|
||||
if (count == 0) return null // truncated stream or preset-dictionary request
|
||||
}
|
||||
} catch (_: DataFormatException) {
|
||||
return null
|
||||
}
|
||||
return ByteArray(headerLength + written).also {
|
||||
System.arraycopy(data, 0, it, 0, headerLength)
|
||||
System.arraycopy(buf, 0, it, headerLength, written)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
/**
|
||||
* Demuxer cache budget derived from the device heap class.
|
||||
*
|
||||
* mpv has no device-memory awareness: `demuxer-max-bytes` defaults to a fixed
|
||||
* 150 MiB forward (+50 MiB back) on every device and the demuxer fills
|
||||
* whatever it is allowed, which crowds a 1 GB TV box until Android's
|
||||
* low-memory killer takes the whole app. Tiered off
|
||||
* [android.app.ActivityManager.getLargeMemoryClass], the same signal
|
||||
* `stream_buffer_sizing.dart` and ExoPlayer's `LoadControlPolicy` use.
|
||||
*
|
||||
* Applied as pre-init *options* in [MpvPlayerCore], so a `demuxer-max-bytes`
|
||||
* line in the user's mpv.conf still wins.
|
||||
*/
|
||||
data class DemuxerBudget(val aheadBytes: Long, val backBytes: Long) {
|
||||
companion object {
|
||||
private const val MIB = 1024L * 1024L
|
||||
|
||||
/** Null for an unknown class (<= 0): callers keep mpv's own defaults. */
|
||||
fun forHeapClassMB(largeMemoryClassMB: Int): DemuxerBudget? = when {
|
||||
largeMemoryClassMB <= 0 -> null
|
||||
largeMemoryClassMB <= 256 -> DemuxerBudget(aheadBytes = 32 * MIB, backBytes = 16 * MIB)
|
||||
largeMemoryClassMB <= 512 -> DemuxerBudget(aheadBytes = 64 * MIB, backBytes = 32 * MIB)
|
||||
else -> DemuxerBudget(aheadBytes = 100 * MIB, backBytes = 48 * MIB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.opengl.EGL14
|
||||
|
||||
/**
|
||||
* Which mpv `egl-output-format` this device can pair with a BT.2020 PQ window
|
||||
* surface, or null when HDR GL output is unavailable. 10-bit fixed point is
|
||||
* preferred; fp16 is the fallback because some drivers (Tegra among them)
|
||||
* expose the PQ colorspace but no 1010102 window config.
|
||||
*
|
||||
* Both halves are required by the mpv side: the fork's android GL context asks
|
||||
* for `EGL_EXT_gl_colorspace_bt2020_pq` on the window surface, and the app
|
||||
* pairs it with an `egl-output-format` that makes mpv's EGL config selection
|
||||
* fail outright when no matching config exists - so this probe must be
|
||||
* consulted before those options are ever set.
|
||||
*/
|
||||
internal object EglHdrCaps {
|
||||
private const val EGL_COLOR_COMPONENT_TYPE_EXT = 0x3339
|
||||
private const val EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT = 0x333B
|
||||
|
||||
private object Unprobed
|
||||
|
||||
@Volatile private var cached: Any? = Unprobed
|
||||
|
||||
fun pqOutputFormat(): String? {
|
||||
val value = cached
|
||||
if (value !== Unprobed) return value as String?
|
||||
return probe().also { cached = it }
|
||||
}
|
||||
|
||||
private fun probe(): String? {
|
||||
val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (display == EGL14.EGL_NO_DISPLAY) return null
|
||||
val version = IntArray(2)
|
||||
// Deliberately no eglTerminate: the default display is process-global and
|
||||
// Flutter's renderer shares it; terminating would invalidate its state.
|
||||
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
|
||||
val extensions = EGL14.eglQueryString(display, EGL14.EGL_EXTENSIONS) ?: return null
|
||||
if (!extensions.contains("EGL_EXT_gl_colorspace_bt2020_pq")) return null
|
||||
if (hasWindowConfig(display, intArrayOf(EGL14.EGL_RED_SIZE, 10, EGL14.EGL_GREEN_SIZE, 10, EGL14.EGL_BLUE_SIZE, 10, EGL14.EGL_ALPHA_SIZE, 2))) {
|
||||
return "rgb10_a2"
|
||||
}
|
||||
if (extensions.contains("EGL_EXT_pixel_format_float") &&
|
||||
hasWindowConfig(
|
||||
display,
|
||||
intArrayOf(
|
||||
EGL14.EGL_RED_SIZE,
|
||||
16,
|
||||
EGL14.EGL_GREEN_SIZE,
|
||||
16,
|
||||
EGL14.EGL_BLUE_SIZE,
|
||||
16,
|
||||
EGL_COLOR_COMPONENT_TYPE_EXT,
|
||||
EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "rgba16f"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasWindowConfig(display: android.opengl.EGLDisplay, extra: IntArray): Boolean {
|
||||
val attribs = intArrayOf(
|
||||
EGL14.EGL_SURFACE_TYPE,
|
||||
EGL14.EGL_WINDOW_BIT,
|
||||
EGL14.EGL_RENDERABLE_TYPE,
|
||||
EGL14.EGL_OPENGL_ES2_BIT
|
||||
) + extra + intArrayOf(EGL14.EGL_NONE)
|
||||
val numConfigs = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(display, attribs, 0, null, 0, 0, numConfigs, 0)) return false
|
||||
return numConfigs[0] > 0
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
/**
|
||||
* Pure policy for when an mpv session must leave the video plane
|
||||
* (vo=mediacodec) for a GL video output, and which one. Kept free of player
|
||||
* and platform state so the routing matrix is unit-testable.
|
||||
*/
|
||||
internal object GpuVoPolicy {
|
||||
/**
|
||||
* Single-layer Dolby Vision Profile 5 (IPT-PQ-c2) has no compatible base
|
||||
* layer: on a device without native DV support it decodes as plain HEVC
|
||||
* with garbage colors, and the video plane applies no reshaping. gpu-next
|
||||
* (libplacebo) under software decode is the only Android path that
|
||||
* composites the RPU metadata (#1902). [dvProfile] comes from mpv's
|
||||
* track-list — the bitstream's DOVI configuration record — never from
|
||||
* server metadata, which mis-tags DV routinely. Only `auto` routes; the
|
||||
* other modes are explicit user choices.
|
||||
*/
|
||||
fun needsDvReshaping(dvProfile: Long?, conversionMode: String, canPlayP5Natively: Boolean): Boolean = dvProfile == 5L && conversionMode == "auto" && !canPlayP5Natively
|
||||
|
||||
/**
|
||||
* Whether an HDR signal has nowhere to tone-map: the video plane hands
|
||||
* PQ/HLG straight to a display pipeline that advertises no HDR output, so
|
||||
* it renders washed out (#2121). The GL vo tone-maps in the render chain.
|
||||
*/
|
||||
fun needsHdrToneMapping(gamma: String?, displaySupportsHdr: Boolean): Boolean = (gamma == "pq" || gamma == "hlg") && !displaySupportsHdr
|
||||
|
||||
/**
|
||||
* Whether the decoder is handing mpv software frames, from `hwdec-current`.
|
||||
*
|
||||
* The plane refuses every format but MediaCodec buffers, so a per-file
|
||||
* decode fallback (AV1 on Tegra, Hi10 without a profile match) has to move
|
||||
* to a GL vo. Routing on this gets there before mpv fails the chain and
|
||||
* [REASON_CHAIN_FAILURE] has to catch it.
|
||||
*/
|
||||
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
|
||||
|
||||
/**
|
||||
* The vo a session with these active requirements should run, or null for
|
||||
* the video plane.
|
||||
*
|
||||
* dv-reshape is the only reason that needs gpu-next, since libplacebo is
|
||||
* what composites the RPU. Everything else takes gpu, the battle-tested
|
||||
* GLES renderer on the Android device zoo. (The magenta field gpu-next
|
||||
* used to render for 10-bit software frames on Tegra was its AV1 film
|
||||
* grain shader overrunning the driver's uniform register budget; grain is
|
||||
* decoder-applied now, but gpu-next buys this path nothing over gpu.)
|
||||
*/
|
||||
fun targetFor(reasons: Set<String>): String? = when {
|
||||
reasons.isEmpty() -> null
|
||||
REASON_DV_RESHAPE in reasons -> "gpu-next"
|
||||
else -> "gpu"
|
||||
}
|
||||
|
||||
const val REASON_DV_RESHAPE = "dv-reshape"
|
||||
const val REASON_SHADERS = "shaders"
|
||||
const val REASON_CHAIN_FAILURE = "chain-failure"
|
||||
const val REASON_HDR_SDR = "hdr-sdr"
|
||||
const val REASON_SW_DECODE = "sw-decode"
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.AudioAttributes
|
||||
@@ -16,7 +15,6 @@ import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import com.edde746.plezy.exoplayer.DoviBridge
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
@@ -41,65 +39,20 @@ import kotlinx.coroutines.sync.withLock
|
||||
class MpvPlayerCore private constructor(
|
||||
private val context: Context,
|
||||
private val audioOnly: Boolean,
|
||||
private val hardwareDecoding: Boolean,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback,
|
||||
SurfacePlayerCore {
|
||||
constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean = false,
|
||||
hardwareDecoding: Boolean = true
|
||||
) : this(context, audioOnly, hardwareDecoding, null, false)
|
||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?
|
||||
) : this(context, audioOnly, true, propertyWriter, true)
|
||||
) : this(context, audioOnly, propertyWriter, true)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerCore"
|
||||
|
||||
/**
|
||||
* The initial `vo` chain, decided by whether this session will hardware-
|
||||
* decode.
|
||||
*
|
||||
* Hardware sessions use the fork vo=mediacodec: decoded buffers go from
|
||||
* MediaCodec straight to the compositor with per-frame presentation
|
||||
* timestamps - no GLES pass, 10-bit and the decoder's dataspace
|
||||
* (HDR10/HLG) intact - and subtitles/OSD render on the sibling OSD
|
||||
* surface. The plane takes decoder buffers only and refuses the rest, so
|
||||
* a per-file decode fallback moves to a GL vo
|
||||
* ([GpuVoPolicy.needsSoftwareRender], with the chain-failure watchdog as
|
||||
* the backstop). gpu stays in the chain for preinit failure.
|
||||
*
|
||||
* Software sessions run gpu,gpu-next: gpu is the battle-tested GLES
|
||||
* renderer on the Android device zoo, and with film grain applied by the
|
||||
* decoder nothing else on this path needs libplacebo. Dolby Vision RPU
|
||||
* reshaping (#1902) is the one exception - it needs gpu-next, and the
|
||||
* [GpuVoPolicy.REASON_DV_RESHAPE] observer moves the session there when a
|
||||
* DV profile that needs reshaping appears. gpu-next under *hardware*
|
||||
* decode is broken on Tegra (samplerExternalOES double declaration
|
||||
* rejected by the GLES linker, blue screen on the Shield, #2010);
|
||||
* vo=mediacodec sidesteps that entire class by never touching GLES.
|
||||
*/
|
||||
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "mediacodec,gpu" else "gpu,gpu-next"
|
||||
|
||||
/**
|
||||
* The `-append` list-option suffixes are not exposed through the property
|
||||
* interface, so the app's decoder options replace the whole list. FFmpeg
|
||||
* keeps the last duplicate key, so any user mpv.conf entries go first.
|
||||
*/
|
||||
internal fun mergeDecoderOptions(current: String?, ours: String): String = if (current.isNullOrBlank()) ours else "$current,$ours"
|
||||
|
||||
/**
|
||||
* Whether content with this transfer is worth an HDR (BT.2020 PQ) GL
|
||||
* surface. PQ and HLG both render into a PQ target; everything else -
|
||||
* including unknown - stays on the default sRGB surface, which renders
|
||||
* every content correctly (HDR arrives tone-mapped, as before).
|
||||
*/
|
||||
internal fun wantsHdrSurface(transfer: String?): Boolean = transfer == "smpte2084" || transfer == "arib-std-b67"
|
||||
}
|
||||
|
||||
/** Video-only paths. The plugin always constructs video cores with the
|
||||
@@ -108,57 +61,7 @@ class MpvPlayerCore private constructor(
|
||||
get() = context as Activity
|
||||
|
||||
private var surfaceView: SurfaceView? = null
|
||||
private var osdSurfaceView: SurfaceView? = null
|
||||
private var surfaceContainer: android.widget.FrameLayout? = null
|
||||
|
||||
@Volatile private var pendingOsdSurface: Surface? = null
|
||||
|
||||
@Volatile private var attachedOsdSurface: Surface? = null
|
||||
|
||||
/** Active reasons the session must render off the plane. */
|
||||
private val gpuVoReasons = LinkedHashSet<String>()
|
||||
|
||||
/** The GL vo this session is running, or null for the video plane. Non-null
|
||||
* gates off the plane-only machinery: OSD attach, aspect-fitted layout,
|
||||
* chain-failure watchdog. Written under [gpuVoReasons]. */
|
||||
@Volatile private var activeGpuVoTarget: String? = null
|
||||
|
||||
/** Whether the per-file DV policy is holding hwdec at `no`; the session's
|
||||
* own hwdec value is parked in [hwdecBeforeDvReshape] meanwhile. */
|
||||
@Volatile private var dvReshapeActive: Boolean = false
|
||||
|
||||
private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference<String?>()
|
||||
|
||||
/** Last `dv-conversion-mode` Dart applied; input to the per-file DV
|
||||
* routing policy. */
|
||||
@Volatile private var currentDvConversionMode: String = "auto"
|
||||
|
||||
/** Whether this core already decided its GL surface colorspace; set by the
|
||||
* first `content-color-transfer` announcement ([applyContentColorTransfer]). */
|
||||
@Volatile private var hdrSurfaceDecided: Boolean = false
|
||||
|
||||
/** Whether this session outputs HDR to an HDR-capable display — via the PQ
|
||||
* GL surface or the MediaCodec plane's decoder dataspace. Gates the
|
||||
* deferred display-mode restore on teardown (see
|
||||
* [FrameRateManager.clearVideoFrameRate]). */
|
||||
@Volatile private var hdrDisplayActive: Boolean = false
|
||||
|
||||
@Volatile private var videoDisplayWidth: Int = 0
|
||||
|
||||
@Volatile private var videoDisplayHeight: Int = 0
|
||||
|
||||
/** Latest `panscan` (0..1) and `video-zoom` (log2) the app applied. The
|
||||
* plane owns scaling, so these are view geometry here; see
|
||||
* [applyVideoRectLayout]. */
|
||||
@Volatile private var videoPanscan: Float = 0f
|
||||
|
||||
@Volatile private var videoZoomLog2: Float = 0f
|
||||
|
||||
/** Hardware sessions render through the fork vo=mediacodec (see
|
||||
* [initialVideoOutput]); the OSD surface and video-rect layout exist only
|
||||
* there. */
|
||||
private val usesMediaCodecVo: Boolean
|
||||
get() = !audioOnly && hardwareDecoding
|
||||
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
|
||||
@Volatile private var disposing: Boolean = false
|
||||
@@ -329,22 +232,6 @@ class MpvPlayerCore private constructor(
|
||||
lastAppliedSurfaceSize = null
|
||||
lastKnownSurfaceWidth = 0
|
||||
lastKnownSurfaceHeight = 0
|
||||
// Video-output state, so a re-initialized core does not start stranded
|
||||
// off the plane with a stale reason set.
|
||||
synchronized(gpuVoReasons) {
|
||||
gpuVoReasons.clear()
|
||||
activeGpuVoTarget = null
|
||||
}
|
||||
hwdecBeforeDvReshape.set(null)
|
||||
dvReshapeActive = false
|
||||
attachedOsdSurface = null
|
||||
videoDisplayWidth = 0
|
||||
videoDisplayHeight = 0
|
||||
videoPanscan = 0f
|
||||
videoZoomLog2 = 0f
|
||||
currentDvConversionMode = "auto"
|
||||
hdrSurfaceDecided = false
|
||||
hdrDisplayActive = false
|
||||
if (!audioOnly) ensurePlaceholderSurface()
|
||||
|
||||
// Initialize audio focus handling. mpv has none built in, so both modes
|
||||
@@ -372,10 +259,6 @@ class MpvPlayerCore private constructor(
|
||||
surfaceContainer = PlayerSurfaceHost.createContainer(activity)
|
||||
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
|
||||
surfaceContainer!!.addView(surfaceView)
|
||||
if (usesMediaCodecVo) {
|
||||
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
|
||||
surfaceContainer!!.addView(osdSurfaceView)
|
||||
}
|
||||
|
||||
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
|
||||
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
|
||||
@@ -384,7 +267,6 @@ class MpvPlayerCore private constructor(
|
||||
ensureFlutterOverlayOnTop()
|
||||
val sv = surfaceView
|
||||
if (sv != null) applySurfaceSize(sv.width, sv.height)
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
|
||||
|
||||
@@ -398,11 +280,6 @@ class MpvPlayerCore private constructor(
|
||||
return@launch
|
||||
}
|
||||
val displayFpsOverride = currentDisplayFpsOverride()
|
||||
// Both core kinds cap their demuxer cache off the device heap class;
|
||||
// rationale on DemuxerBudget. Null (unknown class) keeps mpv defaults.
|
||||
val demuxerBudget = DemuxerBudget.forHeapClassMB(
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
|
||||
)
|
||||
val p = MpvPlayer.create(context.applicationContext) {
|
||||
if (audioOnly) {
|
||||
// Pure audio core (all set before mpv_initialize, mirroring the
|
||||
@@ -416,26 +293,14 @@ class MpvPlayerCore private constructor(
|
||||
setOption("audio-display", "no")
|
||||
setOption("gapless-audio", "weak")
|
||||
} else {
|
||||
// vo choice is decode-path-dependent; rationale on
|
||||
// initialVideoOutput.
|
||||
setOption("vo", initialVideoOutput(hardwareDecoding))
|
||||
setOption("vo", "gpu")
|
||||
setOption("gpu-context", "android")
|
||||
setOption("opengl-es", "yes")
|
||||
// Keep AV1 film grain inside the decoder (dav1d). `auto` hands it
|
||||
// to any vo claiming VO_CAP_FILM_GRAIN, and gpu-next claims it on
|
||||
// GLES where libplacebo's raster grain fallback fetches luma by
|
||||
// fragcoord (bottom-up) but chroma by uv: the luma renders
|
||||
// upside-down (measured on a Shield Pro; desktop GL is unaffected
|
||||
// because grain runs as a compute pass there).
|
||||
setOption("vd-lavc-film-grain", "cpu")
|
||||
if (displayFpsOverride != null) {
|
||||
setOption("display-fps-override", displayFpsOverride)
|
||||
}
|
||||
}
|
||||
if (demuxerBudget != null) {
|
||||
setOption("demuxer-max-bytes", demuxerBudget.aheadBytes.toString())
|
||||
setOption("demuxer-max-back-bytes", demuxerBudget.backBytes.toString())
|
||||
}
|
||||
setOption("ao", "audiotrack,opensles")
|
||||
// Pause on the last frame at EOF instead of unloading the file, so a
|
||||
// seek after the video ends still works (matches Linux/Windows).
|
||||
@@ -447,13 +312,6 @@ class MpvPlayerCore private constructor(
|
||||
// builtin script during mpv_initialize, hence an option here.
|
||||
setOption("ytdl", "no")
|
||||
}
|
||||
if (demuxerBudget != null) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Demuxer budget: ${demuxerBudget.aheadBytes / (1024 * 1024)}MB ahead, " +
|
||||
"${demuxerBudget.backBytes / (1024 * 1024)}MB back"
|
||||
)
|
||||
}
|
||||
if (displayFpsOverride != null) {
|
||||
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
|
||||
}
|
||||
@@ -473,12 +331,6 @@ class MpvPlayerCore private constructor(
|
||||
collectEvents(p)
|
||||
collectPropertyChanges(p)
|
||||
collectLogMessages(p)
|
||||
if (usesMediaCodecVo) {
|
||||
collectVideoDimensions(p)
|
||||
collectShaderState(p)
|
||||
collectHdrToneMapState(p)
|
||||
collectDecoderState(p)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Initialized successfully")
|
||||
onResult(true)
|
||||
@@ -515,27 +367,9 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
is MpvEvent.StartFile -> {
|
||||
endFileDiagnostics.onStartFile()
|
||||
// The trigger is per-file (an exotic pixel format, a gralloc
|
||||
// refusal for that stream), so give the plane back to the next
|
||||
// file. A genuine failure re-arms it, costing one switch per bad
|
||||
// file instead of the whole session's HDR/10-bit scanout.
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, false)
|
||||
delegate?.onEvent("start-file", null)
|
||||
}
|
||||
is MpvEvent.FileLoaded -> {
|
||||
if (usesMediaCodecVo) {
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
try {
|
||||
applyDvReshapePolicy(p)
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled DV routing policy")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DV routing policy failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate?.onEvent("file-loaded", null)
|
||||
}
|
||||
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
|
||||
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
|
||||
else -> {}
|
||||
}
|
||||
@@ -569,18 +403,6 @@ class MpvPlayerCore private constructor(
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.logFlow.collect { msg ->
|
||||
endFileDiagnostics.onLogMessage(msg)
|
||||
// A chain-init failure is the one runtime signal that frames cannot
|
||||
// reach the video plane at all (exotic pixel formats, gralloc
|
||||
// refusal). mpv is pinned in the fork, so the log line is a stable
|
||||
// contract.
|
||||
if (usesMediaCodecVo &&
|
||||
activeGpuVoTarget == null &&
|
||||
msg.prefix.startsWith("cplayer") &&
|
||||
msg.text.contains("Could not initialize video chain")
|
||||
) {
|
||||
Log.w(TAG, "Video chain init failed under vo=mediacodec; leaving the video plane")
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, true)
|
||||
}
|
||||
emitLog(msg.level.name.lowercase(), msg.prefix, msg.text)
|
||||
}
|
||||
}
|
||||
@@ -632,300 +454,6 @@ class MpvPlayerCore private constructor(
|
||||
detachSurfaceInternal(reason = "surfaceDestroyed")
|
||||
}
|
||||
|
||||
// OSD surface (the vo=mediacodec subtitle/OSD plane)
|
||||
|
||||
private val osdSurfaceCallback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
if (disposing) return
|
||||
pendingOsdSurface = holder.surface.takeIf { it.isValid }
|
||||
Log.d(TAG, "OSD surface created")
|
||||
if (player != null && hasAttachedRealSurface()) {
|
||||
refreshVideoOutput("osdSurfaceCreated")
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
Log.d(TAG, "OSD surface destroyed")
|
||||
pendingOsdSurface = null
|
||||
val wasAttached = attachedOsdSurface != null
|
||||
attachedOsdSurface = null
|
||||
val p = player ?: return
|
||||
if (disposing || !wasAttached) return
|
||||
// Ordered against every other mpv write: a detach that overtook the
|
||||
// re-attach of a recreated surface would clear the option the attach
|
||||
// just set, and nothing re-arms it — subtitles would stay dead for the
|
||||
// rest of the session.
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
try {
|
||||
p.detachOsdSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to detach OSD surface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the OSD Surface to the vo. The vo reads the option when it is
|
||||
* created, so this has to run before the `vo` or `wid` write that creates
|
||||
* it, never after.
|
||||
*/
|
||||
private fun attachOsdSurfaceIfNeeded(p: MpvPlayer) {
|
||||
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
|
||||
val osd = pendingOsdSurface?.takeIf { it.isValid } ?: return
|
||||
if (osd === attachedOsdSurface) return
|
||||
p.attachOsdSurface(osd)
|
||||
attachedOsdSurface = osd
|
||||
Log.d(TAG, "Attached OSD surface for vo=mediacodec")
|
||||
}
|
||||
|
||||
private fun collectVideoDimensions(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeInt("dwidth").collect { value ->
|
||||
val w = value.toInt()
|
||||
if (w > 0 && w != videoDisplayWidth) {
|
||||
videoDisplayWidth = w
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeInt("dheight").collect { value ->
|
||||
val h = value.toInt()
|
||||
if (h > 0 && h != videoDisplayHeight) {
|
||||
videoDisplayHeight = h
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbiter for this session's video output: the active [GpuVoPolicy] reasons
|
||||
* decide whether the session runs on the video plane or on a GL vo.
|
||||
*/
|
||||
private fun setGpuVoRequirement(reason: String, active: Boolean) {
|
||||
if (!usesMediaCodecVo || disposing) return
|
||||
val transition = synchronized(gpuVoReasons) {
|
||||
val changed = if (active) gpuVoReasons.add(reason) else gpuVoReasons.remove(reason)
|
||||
if (!changed) return
|
||||
val desired = GpuVoPolicy.targetFor(gpuVoReasons)
|
||||
if (desired == activeGpuVoTarget) return
|
||||
val line = "${activeGpuVoTarget ?: "mediacodec"} -> ${desired ?: "mediacodec"} " +
|
||||
"(reasons=[${gpuVoReasons.joinToString(",")}])"
|
||||
activeGpuVoTarget = desired
|
||||
line
|
||||
}
|
||||
Log.i(TAG, "Video output: $transition")
|
||||
applyGpuVoTarget()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the session to whatever the arbiter last decided.
|
||||
*
|
||||
* The decision is atomic under [gpuVoReasons], but the write cannot be:
|
||||
* it has to leave the lock to reach mpv. Reasons are raised from different
|
||||
* threads — per-file DV routing runs on [mpvWriteDispatcher], the gamma,
|
||||
* shader and chain-failure observers on the main thread — so the order
|
||||
* writes are *enqueued* is not the order decisions were *made*. Rather
|
||||
* than trust the target its caller saw, every transition re-reads the
|
||||
* current one here, which makes the last write the right one under any
|
||||
* interleaving. Serialized on [mpvWriteDispatcher], so the paired main
|
||||
* thread work stays in the same order too.
|
||||
*/
|
||||
private fun applyGpuVoTarget() {
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
|
||||
runOnMain {
|
||||
if (disposing) return@runOnMain
|
||||
if (target == null) {
|
||||
osdSurfaceView?.visibility = View.VISIBLE
|
||||
} else {
|
||||
// The gpu VOs draw their own OSD; a stale subtitle frame must not
|
||||
// linger on the overlay plane.
|
||||
osdSurfaceView?.visibility = View.GONE
|
||||
resetVideoSurfaceToFullContainer()
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Before the vo write, which recreates the VO: it reads the OSD
|
||||
// surface option at creation.
|
||||
val p = player
|
||||
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
|
||||
writeProperty("vo", target ?: "mediacodec")
|
||||
if (p == null) return@launch
|
||||
if (target != null) {
|
||||
// A failed conversion chain makes mpv deselect the video track
|
||||
// ("Could not initialize video chain" -> vid=no) before the VO
|
||||
// switch lands. Re-select the explicit track id: mid-file "auto"
|
||||
// resolves to no selection rather than re-running selection.
|
||||
if (p.getString("vid").let { it == null || it == "no" }) {
|
||||
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
|
||||
if (videoTrackId != null) {
|
||||
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
|
||||
writeProperty("vid", videoTrackId.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
applySurfaceSizeInternal(p, force = true)
|
||||
if (target == null) {
|
||||
// Refit the surfaces to the aspect rectangle now that the plane
|
||||
// owns scaling again (the gpu VOs letterboxed within full
|
||||
// containers).
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled vo transition write")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to move the video output to ${target ?: "mediacodec"}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
|
||||
* the DOVI configuration record's profile on the track list (never trust
|
||||
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
|
||||
* every file-loaded, so a following non-P5 file restores hardware decode
|
||||
* and returns to the video plane.
|
||||
*/
|
||||
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
|
||||
val profile = selectedVideoDvProfile(p)
|
||||
val needs = GpuVoPolicy.needsDvReshaping(
|
||||
dvProfile = profile,
|
||||
conversionMode = currentDvConversionMode,
|
||||
canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context)
|
||||
)
|
||||
if (needs == dvReshapeActive) return
|
||||
dvReshapeActive = needs
|
||||
if (needs) {
|
||||
Log.i(TAG, "DV P5 (bitstream) without native support: software decode + gpu-next reshaping")
|
||||
val current = p.getString("hwdec")
|
||||
hwdecBeforeDvReshape.set(current ?: "no")
|
||||
writeProperty("hwdec", "no")
|
||||
} else {
|
||||
val restore = hwdecBeforeDvReshape.getAndSet(null)
|
||||
if (restore != null && restore != "no") writeProperty("hwdec", restore)
|
||||
}
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_DV_RESHAPE, needs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected video track's Dolby Vision profile, or null for non-DV content
|
||||
* (mpv omits the field when the bitstream carries no DOVI configuration
|
||||
* record).
|
||||
*/
|
||||
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
|
||||
?.takeIf { it.has("dolby-vision-profile") }
|
||||
?.getLong("dolby-vision-profile")
|
||||
|
||||
/**
|
||||
* Observed rather than derived from the hardware-decoding setting because
|
||||
* the fallback is decided per file, inside mpv. Why it matters:
|
||||
* [GpuVoPolicy.needsSoftwareRender].
|
||||
*/
|
||||
private fun collectDecoderState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("hwdec-current").collect { value ->
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_SW_DECODE, GpuVoPolicy.needsSoftwareRender(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User shaders need a GL vo; the video plane renders none. Observed
|
||||
* natively so Dart's `glsl-shaders` change-list writes (ShaderService,
|
||||
* ambient lighting) switch the session live, without a channel contract.
|
||||
*/
|
||||
private fun collectShaderState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("glsl-shaders").collect { value ->
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_SHADERS, value.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observed from video-params so the reason follows per-file transfer
|
||||
* changes, and the display is re-queried per change so an HDMI mode switch
|
||||
* mid-session is honoured on the next file. Why it matters:
|
||||
* [GpuVoPolicy.needsHdrToneMapping].
|
||||
*/
|
||||
private fun collectHdrToneMapState(p: MpvPlayer) {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
p.observeString("video-params/gamma").collect { value ->
|
||||
val needsToneMap = GpuVoPolicy.needsHdrToneMapping(
|
||||
gamma = value,
|
||||
displaySupportsHdr = DoviBridge.displaySupportsHdr(context)
|
||||
)
|
||||
setGpuVoRequirement(GpuVoPolicy.REASON_HDR_SDR, needsToneMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Video tracks from mpv's track list, selected first; empty on any parse failure. */
|
||||
private suspend fun videoTracks(p: MpvPlayer): List<org.json.JSONObject> {
|
||||
val json = p.getString("track-list") ?: return emptyList()
|
||||
return try {
|
||||
val tracks = org.json.JSONArray(json)
|
||||
(0 until tracks.length())
|
||||
.map { tracks.getJSONObject(it) }
|
||||
.filter { it.optString("type") == "video" }
|
||||
.sortedByDescending { it.optBoolean("selected") }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse track-list", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sizes the video surface to the rectangle the image should occupy, per
|
||||
* [VideoRectPolicy], and lets the container clip the overflow.
|
||||
*
|
||||
* The OSD surface is left full-container: the vo builds its `mp_osd_res`
|
||||
* from the OSD window's own size, so libass keeps the whole window as its
|
||||
* canvas — subtitles sit in the letterbox bars as they did under vo=gpu,
|
||||
* and stay on screen when a zoomed image runs past the container.
|
||||
*/
|
||||
private fun applyVideoRectLayout() {
|
||||
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
|
||||
runOnMain {
|
||||
if (disposing) return@runOnMain
|
||||
val container = surfaceContainer ?: return@runOnMain
|
||||
val size = VideoRectPolicy.sizeFor(
|
||||
containerWidth = container.width,
|
||||
containerHeight = container.height,
|
||||
videoWidth = videoDisplayWidth,
|
||||
videoHeight = videoDisplayHeight,
|
||||
panscan = videoPanscan,
|
||||
videoZoomLog2 = videoZoomLog2
|
||||
) ?: return@runOnMain
|
||||
// The guard matters: this runs from an OnGlobalLayoutListener, so an
|
||||
// unconditional write would re-trigger layout forever.
|
||||
surfaceView?.let { view ->
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
if (lp.width != size.width || lp.height != size.height || lp.gravity != android.view.Gravity.CENTER) {
|
||||
lp.width = size.width
|
||||
lp.height = size.height
|
||||
lp.gravity = android.view.Gravity.CENTER
|
||||
view.layoutParams = lp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetVideoSurfaceToFullContainer() {
|
||||
val view = surfaceView ?: return
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
if (lp.width == android.widget.FrameLayout.LayoutParams.MATCH_PARENT) return
|
||||
lp.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
lp.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
||||
lp.gravity = android.view.Gravity.NO_GRAVITY
|
||||
view.layoutParams = lp
|
||||
}
|
||||
|
||||
private fun rememberSurfaceSize(width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) return
|
||||
lastKnownSurfaceWidth = width
|
||||
@@ -1001,15 +529,10 @@ class MpvPlayerCore private constructor(
|
||||
return@withLock
|
||||
}
|
||||
|
||||
// An unattached OSD Surface forces a wid re-attach: the VO reads the
|
||||
// OSD surface option at creation, and setting wid recreates the VO.
|
||||
// Suppressed off the plane, where the GL vo draws its own OSD.
|
||||
val osdNeedsAttach = activeGpuVoTarget == null && pendingOsdSurface !== attachedOsdSurface
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osdNeedsAttach
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
|
||||
val wasAttachedToPlaceholder = attachedToPlaceholder
|
||||
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
|
||||
if (needsAttach) {
|
||||
attachOsdSurfaceIfNeeded(p)
|
||||
p.attachSurface(surface)
|
||||
attachedSurface = surface
|
||||
hasAttachedSurface = true
|
||||
@@ -1327,136 +850,12 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "Load pause intent updated: paused=$paused")
|
||||
}
|
||||
|
||||
/**
|
||||
* `dv-conversion-mode` is an app-level property shared with the ExoPlayer
|
||||
* and Apple cores, not an mpv one. It maps onto the fork FFmpeg
|
||||
* hevc_mediacodec decoder options, mirroring the ExoPlayer DoviBridge
|
||||
* decision tree. Single-layer profiles (5/8) use the Dolby Vision decoder
|
||||
* whenever the path is enabled and the decoder advertises the profile.
|
||||
*/
|
||||
private fun applyDvConversionMode(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
val displayDv = DoviBridge.displaySupportsDolbyVision(context)
|
||||
val (dolbyVision, p7Mode) = when (value.trim().lowercase()) {
|
||||
"auto" -> if (displayDv) "1" to "auto" else "0" to "strip"
|
||||
"disabled", "native" -> "1" to "native"
|
||||
"dv81" -> "1" to "convert"
|
||||
"hevc", "hevc_strip" -> "1" to "strip"
|
||||
else -> {
|
||||
onComplete?.invoke(Result.failure(IllegalArgumentException("Invalid DV conversion mode: $value")))
|
||||
return
|
||||
}
|
||||
}
|
||||
currentDvConversionMode = value.trim().lowercase()
|
||||
Log.i(TAG, "DV conversion mode '$value' (displayDV=$displayDv) -> dolby_vision=$dolbyVision dv_p7_mode=$p7Mode")
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val completion = try {
|
||||
val ours = "dolby_vision=$dolbyVision,dv_p7_mode=$p7Mode"
|
||||
val merged = mergeDecoderOptions(player?.getString("vd-lavc-o"), ours)
|
||||
writeProperty("vd-lavc-o", merged)
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "DV conversion mode write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `content-color-transfer` is an app-level property: Dart announces the
|
||||
* selected stream's transfer (server metadata) before playback so an HDR
|
||||
* session can get a BT.2020 PQ 10-bit GL surface instead of tone-mapped
|
||||
* SDR. Consumed by whichever android GL context the session ever creates -
|
||||
* up front for a software session, or at the fallback transition when a
|
||||
* plane session leaves vo=mediacodec (the plane itself carries HDR via the
|
||||
* decoder's dataspace and ignores all of this).
|
||||
*
|
||||
* The first announcement decides for the whole core: the surface colorspace
|
||||
* is fixed at EGL-surface creation, and both latched states stay correct
|
||||
* for later files (a PQ target renders SDR content correctly, an sRGB
|
||||
* surface tone-maps HDR as before) - re-deciding mid-session could pair a
|
||||
* live sRGB surface with a PQ render target, which is wrong everywhere.
|
||||
*/
|
||||
private fun applyContentColorTransfer(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
|
||||
val transfer = value.trim().lowercase()
|
||||
if (hdrSurfaceDecided) {
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
hdrSurfaceDecided = true
|
||||
val wants = wantsHdrSurface(transfer)
|
||||
val displayHdr = wants && DoviBridge.displaySupportsHdr(context)
|
||||
// Independent of the GL surface outcome: on the MediaCodec plane the
|
||||
// decoder's dataspace carries HDR to the display without a PQ GL surface.
|
||||
hdrDisplayActive = displayHdr
|
||||
val outputFormat = if (wants) EglHdrCaps.pqOutputFormat() else null
|
||||
if (!wants || !displayHdr || outputFormat == null) {
|
||||
if (wants) {
|
||||
Log.i(TAG, "HDR GL surface unavailable (transfer=$transfer displayHdr=$displayHdr eglFormat=$outputFormat)")
|
||||
}
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "HDR GL surface engaged: BT.2020 PQ / $outputFormat for transfer=$transfer")
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val completion = try {
|
||||
writeProperty("android-surface-colorspace", "bt2020-pq")
|
||||
writeProperty("egl-output-format", outputFormat)
|
||||
writeProperty("target-trc", "pq")
|
||||
writeProperty("target-prim", "bt.2020")
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "HDR surface property write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return
|
||||
}
|
||||
|
||||
if (name == "dv-conversion-mode") {
|
||||
applyDvConversionMode(value, onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
if (name == "content-color-transfer") {
|
||||
applyContentColorTransfer(value, onComplete)
|
||||
return
|
||||
}
|
||||
|
||||
// View geometry on the plane (see VideoRectPolicy), but both still fall
|
||||
// through to mpv, which is what makes them work unchanged on the GL vos.
|
||||
if (name == "panscan" || name == "video-zoom") {
|
||||
val parsed = value.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
if (name == "panscan") videoPanscan = parsed else videoZoomLog2 = parsed
|
||||
applyVideoRectLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// While the per-file DV policy holds hwdec at `no`, park writes instead
|
||||
// of applying them: a hardware value under gpu-next would lose the RPU
|
||||
// side data (and blue-screen the Tegra class, #2010). The parked value
|
||||
// is restored when a non-P5 file drops the requirement.
|
||||
if (name == "hwdec" && dvReshapeActive) {
|
||||
hwdecBeforeDvReshape.set(value)
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
return
|
||||
}
|
||||
|
||||
val paused = if (name == "pause") normalizePauseValue(value) else null
|
||||
val pauseIntent = paused?.let {
|
||||
synchronized(publicPauseIntentLock) {
|
||||
@@ -1639,13 +1038,9 @@ class MpvPlayerCore private constructor(
|
||||
"estimated-vf-fps" to getProperty("estimated-vf-fps"),
|
||||
"video-bitrate" to getProperty("video-bitrate"),
|
||||
"hwdec-current" to getProperty("hwdec-current"),
|
||||
"current-vo" to getProperty("current-vo"),
|
||||
"audio-codec-name" to getProperty("audio-codec-name"),
|
||||
"audio-params/samplerate" to getProperty("audio-params/samplerate"),
|
||||
"audio-params/hr-channels" to getProperty("audio-params/hr-channels"),
|
||||
"audio-params/format" to getProperty("audio-params/format"),
|
||||
"current-tracks/audio/demux-samplerate" to getProperty("current-tracks/audio/demux-samplerate"),
|
||||
"current-tracks/audio/demux-channel-count" to getProperty("current-tracks/audio/demux-channel-count"),
|
||||
"audio-bitrate" to getProperty("audio-bitrate"),
|
||||
"total-avsync-change" to getProperty("total-avsync-change"),
|
||||
"cache-used" to getProperty("cache-used"),
|
||||
@@ -1776,7 +1171,6 @@ class MpvPlayerCore private constructor(
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
val mgr = frameRateManager
|
||||
@@ -1784,7 +1178,7 @@ class MpvPlayerCore private constructor(
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
player?.let {
|
||||
updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") {
|
||||
onComplete(switched)
|
||||
@@ -1794,7 +1188,7 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate(hdrActive = hdrDisplayActive)
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
@@ -1845,15 +1239,11 @@ class MpvPlayerCore private constructor(
|
||||
|
||||
// Capture locals for deferred cleanup (audio-only has no views)
|
||||
val sv = surfaceView
|
||||
val osdSv = osdSurfaceView
|
||||
val container = surfaceContainer
|
||||
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
|
||||
surfaceContainer = null
|
||||
surfaceView = null
|
||||
osdSurfaceView = null
|
||||
pendingOsdSurface = null
|
||||
attachedOsdSurface = null
|
||||
|
||||
// Remove layout listener synchronously
|
||||
overlayLayoutListener?.let { listener ->
|
||||
@@ -1905,7 +1295,6 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "Disposed (native)")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
sv?.holder?.removeCallback(this)
|
||||
osdSv?.holder?.removeCallback(osdSurfaceCallback)
|
||||
if (container?.parent != null) {
|
||||
contentView?.removeView(container)
|
||||
}
|
||||
@@ -1916,7 +1305,6 @@ class MpvPlayerCore private constructor(
|
||||
// No player — safe to remove views immediately
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
sv?.holder?.removeCallback(this)
|
||||
osdSv?.holder?.removeCallback(osdSurfaceCallback)
|
||||
if (container?.parent != null) {
|
||||
contentView?.removeView(container)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
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
|
||||
@@ -15,7 +13,6 @@ import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal fun completeMpvPropertyResult(
|
||||
result: MethodChannel.Result,
|
||||
@@ -64,17 +61,6 @@ open class MpvPlayerPlugin(
|
||||
private val nameToId = mutableMapOf<String, Int>()
|
||||
private var sessionGeneration = 0
|
||||
|
||||
// The Dart instanceId that created the current core. A `dispose` carrying a
|
||||
// different token lost the ownership race to a successor and must not tear
|
||||
// down that successor's core; it is acknowledged without touching anything.
|
||||
private var coreInstanceId: Long? = null
|
||||
|
||||
// How long a Dart `dispose` waits for the native teardown before being
|
||||
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
|
||||
// session's surface/audio release); small against the alternative, which
|
||||
// is wedging every subsequent playback session behind a hung teardown.
|
||||
private val disposeWatchdogMs = 6_000L
|
||||
|
||||
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
|
||||
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||
|
||||
@@ -88,6 +74,7 @@ open class MpvPlayerPlugin(
|
||||
private var initAttemptCounter = 0
|
||||
private var activeInitAttempt: Int? = null
|
||||
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
channels.attach(binding)
|
||||
@@ -108,7 +95,6 @@ open class MpvPlayerPlugin(
|
||||
++sessionGeneration
|
||||
val core = playerCore
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
cancelPendingInits()
|
||||
return core
|
||||
}
|
||||
@@ -117,6 +103,7 @@ open class MpvPlayerPlugin(
|
||||
takeCoreForTeardown()?.dispose()
|
||||
}
|
||||
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
@@ -152,6 +139,7 @@ open class MpvPlayerPlugin(
|
||||
Log.d(tag, "Detached from activity for config changes")
|
||||
}
|
||||
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
channels.listen(events)
|
||||
}
|
||||
@@ -160,10 +148,11 @@ open class MpvPlayerPlugin(
|
||||
channels.cancel()
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"initialize" -> handleInitialize(result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
"getStats" -> handleGetStats(result)
|
||||
@@ -174,38 +163,16 @@ 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)
|
||||
"getHeapSize" -> {
|
||||
// Device heap class for Dart-side memory tiering (stream ring cache).
|
||||
// Lives on the always-registered mpv channel so it survives backends.
|
||||
val context: Context? = activity ?: applicationContext
|
||||
val am = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
result.success(am?.largeMemoryClass ?: 0)
|
||||
}
|
||||
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
|
||||
"setLogLevel" -> handleSetLogLevel(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the `audio-spdif` value for the current audio route. mpv force-passthroughs
|
||||
* every codec named there with no decode fallback, so with no context to inspect the
|
||||
* route the conservative answer is the empty list — decode everything (#1703, #1991).
|
||||
*/
|
||||
private fun handleGetAudioSpdifCodecs(result: MethodChannel.Result) {
|
||||
val context: Context? = activity ?: applicationContext
|
||||
result.success(context?.let(::supportedMpvSpdifCodecs) ?: "")
|
||||
}
|
||||
|
||||
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
|
||||
// Whether the session will hardware-decode; decides the initial vo chain
|
||||
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
|
||||
// from older callers; hardware decode is the setting's default.
|
||||
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
|
||||
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.
|
||||
val coreContext: Context? = if (audioOnly) applicationContext else activity
|
||||
@@ -264,11 +231,10 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
|
||||
core = MpvPlayerCore(coreContext, audioOnly).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to initialize: ${e.message}", e)
|
||||
completePendingInits(attempt, success = false, errorMessage = e.message)
|
||||
@@ -280,10 +246,7 @@ open class MpvPlayerPlugin(
|
||||
playerCore !== core ||
|
||||
!isCurrentInitAttempt(attempt)
|
||||
if (stale || !success) {
|
||||
if (playerCore === core) {
|
||||
playerCore = null
|
||||
coreInstanceId = null
|
||||
}
|
||||
if (playerCore === core) playerCore = null
|
||||
core.dispose()
|
||||
if (stale) {
|
||||
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
@@ -340,37 +303,13 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<Number>("instanceId")?.toLong()
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
runOnMain {
|
||||
val owner = coreInstanceId
|
||||
if (playerCore != null && token != null && owner != null && token != owner) {
|
||||
// This dispose lost the ownership race: a successor already created
|
||||
// the current core. Acknowledge without touching it.
|
||||
Log.d(tag, "Ignoring stale dispose (token=$token, core owner=$owner)")
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
val core = takeCoreForTeardown()
|
||||
if (core == null) {
|
||||
core?.dispose {
|
||||
Log.d(tag, "Disposed")
|
||||
result.success(null)
|
||||
return@runOnMain
|
||||
}
|
||||
// A hung native teardown must not wedge the Dart-side release chain:
|
||||
// answer after the watchdog even if the teardown thread is stuck, so
|
||||
// the next session can start on a fresh core. The stuck core leaks its
|
||||
// resources until the process ends — recoverable, unlike the wedge.
|
||||
val completed = AtomicBoolean(false)
|
||||
fun completeOnce(reason: String) {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
Log.d(tag, reason)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
channels.mainHandler.postDelayed({
|
||||
completeOnce("Dispose watchdog fired after ${disposeWatchdogMs}ms; teardown continues in background")
|
||||
}, disposeWatchdogMs)
|
||||
core.dispose { completeOnce("Disposed") }
|
||||
} ?: result.success(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,19 +452,14 @@ open class MpvPlayerPlugin(
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
|
||||
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
||||
val matchResolution = call.argument<Boolean>("matchResolution") ?: false
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, " +
|
||||
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution"
|
||||
)
|
||||
Log.d(tag, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, matchResolution) { switched ->
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Pure geometry for the video plane's surface rectangle.
|
||||
*
|
||||
* The fork `vo=mediacodec` scales decoded buffers to the whole Surface and
|
||||
* implements none of mpv's src/dst rect math, so aspect, cover and zoom are
|
||||
* view geometry: the video surface is sized to the rectangle the image should
|
||||
* occupy and the container clips whatever falls outside it. That mirrors the
|
||||
* ExoPlayer path, which scales its own surface and keeps the subtitle overlay
|
||||
* on the full-size container.
|
||||
*/
|
||||
internal object VideoRectPolicy {
|
||||
/**
|
||||
* `video-zoom` is reachable from the user's mpv.conf, where an absurd value
|
||||
* is a compositor allocation rather than just arithmetic. Bounds it well
|
||||
* outside the range the player's own zoom control offers.
|
||||
*/
|
||||
private const val MAX_ZOOM_LOG2 = 2f
|
||||
|
||||
data class Size(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* Size for the video surface, or null when a dimension is not known yet.
|
||||
*
|
||||
* [panscan] is mpv's 0..1 property: 0 fits the image inside the container
|
||||
* (letterbox), 1 fills it (crop), values between interpolate the scale.
|
||||
* [videoZoomLog2] is mpv's `video-zoom`, a log2 factor, applied on top.
|
||||
*/
|
||||
fun sizeFor(
|
||||
containerWidth: Int,
|
||||
containerHeight: Int,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
panscan: Float = 0f,
|
||||
videoZoomLog2: Float = 0f
|
||||
): Size? {
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) return null
|
||||
val fit = min(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
|
||||
val cover = max(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
|
||||
val pan = panscan.coerceIn(0f, 1f)
|
||||
val zoom = 2.0f.pow(videoZoomLog2.coerceIn(-MAX_ZOOM_LOG2, MAX_ZOOM_LOG2))
|
||||
val scale = (fit + (cover - fit) * pan) * zoom
|
||||
return Size(
|
||||
width = (videoWidth * scale).toInt().coerceAtLeast(1),
|
||||
height = (videoHeight * scale).toInt().coerceAtLeast(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Pure display-mode selection policy for content-adaptive display switching.
|
||||
* Extracted from [FrameRateManager] so the policy is unit-testable on the
|
||||
* JVM, where android.view.Display.Mode cannot be instantiated.
|
||||
*/
|
||||
object DisplayModeSelector {
|
||||
const val RATE_TOLERANCE = 0.1f
|
||||
|
||||
/** JVM-testable mirror of android.view.Display.Mode. */
|
||||
data class ModeInfo(val modeId: Int, val width: Int, val height: Int, val refreshRate: Float) {
|
||||
val area: Long get() = width.toLong() * height
|
||||
}
|
||||
|
||||
data class RefreshRateMatch(val reason: String, val priority: Int, val error: Float)
|
||||
|
||||
data class Selection(val mode: ModeInfo, val reason: String)
|
||||
|
||||
private data class Candidate(val mode: ModeInfo, val match: RefreshRateMatch)
|
||||
|
||||
/** How well [refreshRate] presents [fps] content: exact, an integer multiple, or not at all. */
|
||||
fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
|
||||
if (refreshRate <= 0f || fps <= 0f) return null
|
||||
|
||||
val exactError = abs(refreshRate - fps)
|
||||
if (exactError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
|
||||
}
|
||||
|
||||
val multiple = (refreshRate / fps).roundToInt()
|
||||
if (multiple > 1) {
|
||||
val multipleError = abs(refreshRate - (fps * multiple))
|
||||
if (multipleError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the display mode for the video, or null when no switch target exists.
|
||||
* The caller compares the result against the current mode to decide whether
|
||||
* an actual switch is needed.
|
||||
*
|
||||
* With [matchResolution] and known video dimensions, resolution wins over
|
||||
* cadence: the target is the smallest mode that still contains the video
|
||||
* (never downscaling it), rate-matched within that resolution when [fps] is
|
||||
* known. Otherwise the cadence-only policy applies and requires [fps] > 0.
|
||||
*/
|
||||
fun findBestMode(
|
||||
fps: Float,
|
||||
currentMode: ModeInfo,
|
||||
supportedModes: List<ModeInfo>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean
|
||||
): Selection? {
|
||||
if (matchResolution && videoWidth > 0 && videoHeight > 0) {
|
||||
resolutionMatch(fps, currentMode, supportedModes, videoWidth, videoHeight)?.let { return it }
|
||||
// No mode can contain the video (source larger than the panel):
|
||||
// fall back to the cadence-only policy below.
|
||||
}
|
||||
return cadenceMatch(fps, currentMode, supportedModes, videoWidth, videoHeight)
|
||||
}
|
||||
|
||||
private fun resolutionMatch(
|
||||
fps: Float,
|
||||
currentMode: ModeInfo,
|
||||
supportedModes: List<ModeInfo>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): Selection? {
|
||||
val candidates = supportedModes.filter { it.width >= videoWidth && it.height >= videoHeight }
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
// Native target: the smallest resolution that still contains the video,
|
||||
// so the display (not the device) performs the upscale.
|
||||
val targetArea = candidates.minOf { it.area }
|
||||
val bucket = candidates.filter { it.area == targetArea }
|
||||
|
||||
// Rate-match within the target resolution when requested. Resolution
|
||||
// wins over cadence: a missing rate match here deliberately does not
|
||||
// widen back out to other resolutions.
|
||||
if (fps > 0f) {
|
||||
bucket
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<Candidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
?.let { return Selection(it.mode, "resolution + ${it.match.reason} rate, error=${it.match.error}") }
|
||||
}
|
||||
|
||||
// Resolution-only request, or no cadence match at the target resolution:
|
||||
// stay as close to the current refresh rate as possible so the switch
|
||||
// renegotiates only what it has to.
|
||||
val fallback = bucket.minWithOrNull(
|
||||
compareBy<ModeInfo> { abs(it.refreshRate - currentMode.refreshRate) }.thenByDescending { it.refreshRate }
|
||||
)
|
||||
return fallback?.let { Selection(it, "resolution only") }
|
||||
}
|
||||
|
||||
private fun cadenceMatch(
|
||||
fps: Float,
|
||||
currentMode: ModeInfo,
|
||||
supportedModes: List<ModeInfo>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): Selection? {
|
||||
// Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only
|
||||
// switch, the least disruptive (no resolution/HDMI renegotiation).
|
||||
supportedModes.asSequence()
|
||||
.filter { it.width == currentMode.width && it.height == currentMode.height }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<Candidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
?.let { return Selection(it.mode, "${it.match.reason}, error=${it.match.error}") }
|
||||
|
||||
// Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a
|
||||
// 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one
|
||||
// that downscales the video below its native size (trading detail for cadence).
|
||||
// Requires known video dimensions; without them keep Tier-1-only behaviour.
|
||||
if (videoWidth <= 0 || videoHeight <= 0) return null
|
||||
return supportedModes.asSequence()
|
||||
.filter { it.width >= videoWidth && it.height >= videoHeight }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { Candidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
// Prefer the resolution closest to the panel's current one (least change,
|
||||
// keeps panel-native res when a high-res match exists), then refresh match.
|
||||
compareBy<Candidate> { abs(it.mode.area - currentMode.area) }
|
||||
.thenBy { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
)
|
||||
?.let { Selection(it.mode, "${it.match.reason}, error=${it.match.error}") }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FrameRateManager(
|
||||
private val activity: Activity,
|
||||
@@ -19,45 +21,38 @@ class FrameRateManager(
|
||||
private const val TAG = "FrameRateManager"
|
||||
private const val DISPLAY_SETTLE_MS = 2000L
|
||||
private const val WATCHDOG_MARGIN_MS = 3000L
|
||||
|
||||
// How long to let the HDR-exit commit land before restoring the refresh
|
||||
// rate. Restoring while the display is still signaling HDR folds the
|
||||
// HDR-exit and the mode change into one HDMI renegotiation, which some
|
||||
// sink chains take 8-30 s to complete (#2172); sequenced, the HDR
|
||||
// infoframe clear is free and the SDR mode switch takes ~1 s. The
|
||||
// player's surface teardown commits the HDR exit within ~50 ms of
|
||||
// dispose, so 400 ms covers it with margin even on a busy main thread.
|
||||
private const val HDR_EXIT_SETTLE_MS = 400L
|
||||
private const val RATE_TOLERANCE = 0.1f
|
||||
}
|
||||
|
||||
private data class RefreshRateMatch(
|
||||
val reason: String,
|
||||
val priority: Int,
|
||||
val error: Float
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private data class DisplayModeCandidate(
|
||||
val mode: Display.Mode,
|
||||
val match: RefreshRateMatch
|
||||
)
|
||||
|
||||
private var currentVideoFps: Float = 0f
|
||||
private var currentVideoWidth: Int = 0
|
||||
private var currentVideoHeight: Int = 0
|
||||
private var currentMatchResolution: Boolean = false
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
private var pendingSettleRunnable: Runnable? = null
|
||||
private var watchdogRunnable: Runnable? = null
|
||||
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
|
||||
|
||||
// Owns the deferred HDR-exit restore. Deliberately NOT the shared player
|
||||
// [handler]: core dispose clears that one wholesale, and the restore must
|
||||
// survive player disposal or the display stays at the content rate.
|
||||
private val restoreHandler = Handler(android.os.Looper.getMainLooper())
|
||||
private var pendingRestoreRunnable: Runnable? = null
|
||||
|
||||
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
|
||||
// Request a display mode switch for the video's frame rate and/or, with
|
||||
// [matchResolution], its native resolution. Invokes [onComplete] once, either:
|
||||
// - immediately with `switched=false` when no switch is needed (no usable
|
||||
// fps/resolution target, no matching mode, or already matching); or
|
||||
// Request a display frame-rate switch. Invokes [onComplete] once, either:
|
||||
// - immediately with `switched=false` when no switch is needed (invalid fps,
|
||||
// no matching mode, or already matching); or
|
||||
// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the caller's
|
||||
// [extraDelayMs], with `switched=true`; or
|
||||
// - via a watchdog if the real event never arrives, so the caller doesn't hang.
|
||||
//
|
||||
// fps <= 0 with [matchResolution] requests a resolution-only switch that
|
||||
// keeps the refresh rate as close to the current one as possible.
|
||||
//
|
||||
// The caller is responsible for pausing playback before calling and resuming
|
||||
// it after [onComplete] fires.
|
||||
fun setVideoFrameRate(
|
||||
@@ -66,27 +61,20 @@ class FrameRateManager(
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int = 0,
|
||||
videoHeight: Int = 0,
|
||||
matchResolution: Boolean = false,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
// A new session's switch must not be clobbered by a still-pending
|
||||
// deferred restore from the previous session's teardown.
|
||||
cancelPendingRestore()
|
||||
currentVideoFps = fps
|
||||
currentVideoWidth = videoWidth
|
||||
currentVideoHeight = videoHeight
|
||||
currentMatchResolution = matchResolution
|
||||
val hasResolutionTarget = matchResolution && videoWidth > 0 && videoHeight > 0
|
||||
if (fps <= 0f && !hasResolutionTarget) {
|
||||
Log.d(TAG, "setVideoFrameRate: no usable target (fps=$fps, video=${videoWidth}x$videoHeight), skipping")
|
||||
if (fps <= 0f) {
|
||||
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
log(
|
||||
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
|
||||
"video=${videoWidth}x$videoHeight, matchResolution=$matchResolution, " +
|
||||
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
|
||||
"video=${videoWidth}x$videoHeight, API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -96,47 +84,21 @@ class FrameRateManager(
|
||||
}
|
||||
}
|
||||
|
||||
// [hdrActive]: the session was outputting HDR. The restore is then deferred
|
||||
// by [HDR_EXIT_SETTLE_MS] so the caller's surface teardown can commit the
|
||||
// HDR exit first — see [HDR_EXIT_SETTLE_MS] for why stacking them is slow.
|
||||
fun clearVideoFrameRate(hdrActive: Boolean = false) {
|
||||
Log.d(TAG, "clearVideoFrameRate(hdrActive=$hdrActive)")
|
||||
fun clearVideoFrameRate() {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
currentVideoFps = 0f
|
||||
// Resolve any pending setVideoFrameRate future as "not switched" so
|
||||
// the Dart caller's await doesn't hang on player dispose.
|
||||
firePendingCompletion("clear", switched = false)
|
||||
cancelPendingRestore()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
// Nothing to restore when no preferred mode was ever applied.
|
||||
if ((activity.window?.attributes?.preferredDisplayModeId ?: 0) == 0) return
|
||||
if (hdrActive) {
|
||||
val restore = Runnable {
|
||||
pendingRestoreRunnable = null
|
||||
// Log.d, not [log]: this fires after core dispose, when the
|
||||
// Flutter-channel logger is already gone.
|
||||
Log.d(TAG, "restoring default display mode after HDR exit")
|
||||
restorePreferredDisplayMode()
|
||||
// Restore default display mode on API M (preferredDisplayModeId persists)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
pendingRestoreRunnable = restore
|
||||
restoreHandler.postDelayed(restore, HDR_EXIT_SETTLE_MS)
|
||||
} else {
|
||||
restorePreferredDisplayMode()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restorePreferredDisplayMode() {
|
||||
// preferredDisplayModeId persists on the window; restore the default.
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelPendingRestore() {
|
||||
pendingRestoreRunnable?.let { restoreHandler.removeCallbacks(it) }
|
||||
pendingRestoreRunnable = null
|
||||
}
|
||||
|
||||
// Release pending callbacks/listener without restoring the display mode.
|
||||
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
|
||||
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
|
||||
@@ -167,12 +129,7 @@ class FrameRateManager(
|
||||
cb(switched)
|
||||
}
|
||||
|
||||
private fun registerDisplayListener(
|
||||
fps: Float,
|
||||
targetModeId: Int,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
// Resolve any previous pending op before starting a new one.
|
||||
firePendingCompletion("superseded", switched = false)
|
||||
pendingCompletion = onComplete
|
||||
@@ -187,9 +144,7 @@ class FrameRateManager(
|
||||
getDisplayManager().unregisterDisplayListener(this)
|
||||
displayListener = null
|
||||
|
||||
val settle = Runnable {
|
||||
firePendingCompletion("display settled", switched = currentMatchesRequest(fps, targetModeId))
|
||||
}
|
||||
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
|
||||
pendingSettleRunnable = settle
|
||||
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
|
||||
}
|
||||
@@ -199,20 +154,37 @@ class FrameRateManager(
|
||||
// Watchdog: if the TV never signals a display change (silently ignoring
|
||||
// the mode request), still complete after a bounded wait so the caller
|
||||
// doesn't hang.
|
||||
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentMatchesRequest(fps, targetModeId)) }
|
||||
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
|
||||
watchdogRunnable = watchdog
|
||||
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
|
||||
}
|
||||
|
||||
// Whether the display landed on the requested mode: the exact target, or —
|
||||
// for a rate request — any mode whose refresh presents [fps] (a TV may pick
|
||||
// a different-but-equivalent mode). A resolution-only request (fps <= 0)
|
||||
// only counts the exact target.
|
||||
private fun currentMatchesRequest(fps: Float, targetModeId: Int): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
|
||||
val current = currentDisplayMode() ?: return false
|
||||
if (current.modeId == targetModeId) return true
|
||||
return DisplayModeSelector.matchRefreshRate(current.refreshRate, fps) != null
|
||||
private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
|
||||
if (refreshRate <= 0f || fps <= 0f) return null
|
||||
|
||||
val exactError = abs(refreshRate - fps)
|
||||
if (exactError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
|
||||
}
|
||||
|
||||
val multiple = (refreshRate / fps).roundToInt()
|
||||
if (multiple > 1) {
|
||||
val multipleError = abs(refreshRate - (fps * multiple))
|
||||
if (multipleError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun currentRateMatch(fps: Float): RefreshRateMatch? {
|
||||
val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
currentDisplayMode()?.refreshRate
|
||||
} else {
|
||||
null
|
||||
} ?: return null
|
||||
return matchRefreshRate(current, fps)
|
||||
}
|
||||
|
||||
private fun currentModeDescription(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -242,11 +214,46 @@ class FrameRateManager(
|
||||
private fun describeSupportedModes(modes: Array<Display.Mode>): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) }
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun Display.Mode.toModeInfo(): DisplayModeSelector.ModeInfo = DisplayModeSelector.ModeInfo(modeId, physicalWidth, physicalHeight, refreshRate)
|
||||
private fun findBestModeMatch(
|
||||
fps: Float,
|
||||
currentMode: Display.Mode,
|
||||
supportedModes: Array<Display.Mode>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): DisplayModeCandidate? {
|
||||
// Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only
|
||||
// switch, the least disruptive (no resolution/HDMI renegotiation).
|
||||
supportedModes.asSequence()
|
||||
.filter { it.physicalHeight == currentMode.physicalHeight && it.physicalWidth == currentMode.physicalWidth }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<DisplayModeCandidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
?.let { return it }
|
||||
|
||||
// Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a
|
||||
// 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one
|
||||
// that downscales the video below its native size (trading detail for cadence).
|
||||
// Requires known video dimensions; without them keep Tier-1-only behaviour.
|
||||
if (videoWidth <= 0 || videoHeight <= 0) return null
|
||||
val currentArea = currentMode.physicalWidth.toLong() * currentMode.physicalHeight
|
||||
return supportedModes.asSequence()
|
||||
.filter { it.physicalWidth >= videoWidth && it.physicalHeight >= videoHeight }
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
// Prefer the resolution closest to the panel's current one (least change,
|
||||
// keeps panel-native res when a high-res match exists), then refresh match.
|
||||
compareBy<DisplayModeCandidate> { abs(it.mode.physicalWidth.toLong() * it.mode.physicalHeight - currentArea) }
|
||||
.thenBy { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
log("setDisplayMode fps=$fps, matchResolution=$currentMatchResolution")
|
||||
log("setDisplayMode fps=$fps")
|
||||
val display = currentDisplay()
|
||||
if (display == null) {
|
||||
log("display unavailable")
|
||||
@@ -263,43 +270,34 @@ class FrameRateManager(
|
||||
val currentMode = display.mode
|
||||
log("supported modes=${describeSupportedModes(supportedModes)}")
|
||||
|
||||
val selection = DisplayModeSelector.findBestMode(
|
||||
fps,
|
||||
currentMode.toModeInfo(),
|
||||
supportedModes.map { it.toModeInfo() },
|
||||
currentVideoWidth,
|
||||
currentVideoHeight,
|
||||
currentMatchResolution
|
||||
)
|
||||
if (selection == null) {
|
||||
val modeMatch = findBestModeMatch(fps, currentMode, supportedModes, currentVideoWidth, currentVideoHeight)
|
||||
if (modeMatch == null) {
|
||||
log(
|
||||
"no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight} " +
|
||||
"(video=${currentVideoWidth}x$currentVideoHeight, matchResolution=$currentMatchResolution)"
|
||||
"(video=${currentVideoWidth}x$currentVideoHeight)"
|
||||
)
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
val modeToUse = supportedModes.firstOrNull { it.modeId == selection.mode.modeId }
|
||||
if (modeToUse == null) {
|
||||
log("selected mode #${selection.mode.modeId} disappeared from supported modes")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
val modeToUse = modeMatch.mode
|
||||
if (modeToUse.modeId == currentMode.modeId) {
|
||||
log("current mode already matches ${fps}fps (${selection.reason}), no switch needed")
|
||||
log("current mode already matches ${fps}fps (${modeMatch.match.reason}), no switch needed")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
log("switching to ${describeMode(modeToUse)} for ${fps}fps (${selection.reason})")
|
||||
log(
|
||||
"switching to ${describeMode(modeToUse)} for ${fps}fps " +
|
||||
"(${modeMatch.match.reason}, error=${modeMatch.match.error})"
|
||||
)
|
||||
val window = activity.window
|
||||
if (window == null) {
|
||||
log("window unavailable")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
registerDisplayListener(fps, modeToUse.modeId, extraDelayMs, onComplete)
|
||||
registerDisplayListener(fps, extraDelayMs, onComplete)
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = modeToUse.modeId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,34 +7,6 @@ import java.util.Locale
|
||||
|
||||
/** Canonical decoder lookup and hardware classification for native playback. */
|
||||
internal object MediaCodecQuery {
|
||||
/**
|
||||
* Codecs whose advertised support is decided by hardware: both have a
|
||||
* software decoder behind them, but a software HEVC or AV1 decode on
|
||||
* phone/TV-class hardware drops frames.
|
||||
*/
|
||||
private val HARDWARE_GATED_VIDEO_MIME_TYPES = mapOf(
|
||||
"hevc" to "video/hevc",
|
||||
"av1" to "video/av01"
|
||||
)
|
||||
|
||||
/** Codec name -> whether this device has a hardware decoder for it. */
|
||||
fun hardwareVideoDecodeSupport(
|
||||
hardwareMimeTypes: Set<String> = hardwareDecoderMimeTypes()
|
||||
): Map<String, Boolean> = HARDWARE_GATED_VIDEO_MIME_TYPES.mapValues { (_, mimeType) -> mimeType in hardwareMimeTypes }
|
||||
|
||||
/**
|
||||
* Every MIME type served by a hardware decoder, lowercased. One walk answers
|
||||
* for all codecs, unlike [findHardwareDecoder], which rescans per lookup.
|
||||
*/
|
||||
private fun hardwareDecoderMimeTypes(): Set<String> {
|
||||
val mimeTypes = HashSet<String>()
|
||||
for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) {
|
||||
if (info.isEncoder || !isHardwareAccelerated(info)) continue
|
||||
for (type in info.supportedTypes) mimeTypes.add(type.lowercase(Locale.ROOT))
|
||||
}
|
||||
return mimeTypes
|
||||
}
|
||||
|
||||
fun findHardwareDecoder(
|
||||
mimeType: String,
|
||||
codecKind: Int = MediaCodecList.REGULAR_CODECS,
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
@@ -15,37 +14,8 @@ internal object PlayerSurfaceHost {
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Fallback fill for the frames before the punch surface below is placed.
|
||||
setBackgroundColor(Color.BLACK)
|
||||
this.clipChildren = clipChildren
|
||||
addView(createLetterboxPunchSurface(activity))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen, buffer-less SurfaceView kept beneath the video surface.
|
||||
*
|
||||
* Its only job is taking the letterbox area off the app-window (graphics)
|
||||
* plane: like any below-window SurfaceView it punches the parent canvas and
|
||||
* registers its rect as a window transparent region, so the pixels around
|
||||
* the video rect scan out as the SurfaceFlinger backdrop instead of
|
||||
* window-plane black. Several TV compositors (Fire TV Stick 4K Max, some
|
||||
* Sony/Philips models) raise SDR graphics-plane black while the display is
|
||||
* in HDR/Dolby Vision mode, which shows as gray letterbox bars on OLED
|
||||
* panels (issue #2163; same mechanism as ExoPlayer #8803 and Kodi #25300).
|
||||
*
|
||||
* No buffer is ever posted to it: SurfaceFlinger skips buffer-less layers,
|
||||
* and drawing black into it would put the bars back onto an SDR layer —
|
||||
* exactly the plane being avoided. Views drawn on the parent canvas after
|
||||
* the punch (Media3 subtitle cues) still re-claim their own bounds.
|
||||
*/
|
||||
private fun createLetterboxPunchSurface(activity: Activity): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(false)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -2)
|
||||
}
|
||||
|
||||
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
@@ -59,23 +29,6 @@ internal object PlayerSurfaceHost {
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent plane directly above the video surface for the mpv
|
||||
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
|
||||
* the video SurfaceView but still beneath the Flutter window content.
|
||||
*/
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
holder.addCallback(callback)
|
||||
holder.setFormat(PixelFormat.TRANSLUCENT)
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(true)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -1)
|
||||
}
|
||||
|
||||
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
|
||||
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
contentView.addView(container, 0)
|
||||
|
||||
@@ -22,7 +22,6 @@ interface SurfacePlayerCore {
|
||||
extraDelayMs: Long,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
matchResolution: Boolean,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,225 +48,83 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
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 spdifListNamesEveryCodecARouteWithAllShapesCanCarry() {
|
||||
// libmpv v1.1.0's audiotrack AO opens each burst at its own rate and channel mask, so a route
|
||||
// that takes all three shapes and advertises everything bitstreams the lossless codecs too.
|
||||
// `dts-hd` supersedes plain `dts`: ad_spdif picks the core burst per file for non-HD tracks.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListOnAStereo48kOnlyRouteNamesTheCoreCodecsOnly() {
|
||||
// E-AC3 (192kHz), TrueHD MAT and DTS-HD MA (192kHz/8ch) have no track to ride here.
|
||||
assertEquals("ac3,dts", spdifCodecs(allEncodings, setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdFallsBackToThePlainCoreWithoutTheCarrierShape() {
|
||||
// Advertising ENCODING_DTS_HD says the receiver decodes it, not that the route takes the
|
||||
// 192kHz/8ch track its burst needs (#1988); naming `dts-hd` anyway strands playback.
|
||||
assertEquals(
|
||||
"dts",
|
||||
spdifCodecs(setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD), setOf(MpvIecShape.STEREO_48K))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eac3IsNotNamedWithoutThe192kStereoShape() {
|
||||
assertEquals("", spdifCodecs(setOf(C.ENCODING_E_AC3), setOf(MpvIecShape.STEREO_48K)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trueHdIsNotNamedWithoutTheCarrierShape() {
|
||||
assertEquals(
|
||||
"",
|
||||
spdifCodecs(
|
||||
setOf(C.ENCODING_DOLBY_TRUEHD),
|
||||
setOf(MpvIecShape.STEREO_48K, MpvIecShape.STEREO_192K)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainDtsSurvivesWhenTheRouteDoesNotAdvertiseDtsHd() {
|
||||
// The core burst is stereo/48k, so it rides a full-shape route unchanged when only the
|
||||
// lossless encoding is missing.
|
||||
assertEquals("dts", spdifCodecs(setOf(C.ENCODING_DTS), allShapes))
|
||||
fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() {
|
||||
assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListDropsCodecsTheRouteCannotBitstream() {
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, DTS does not (#1703).
|
||||
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream,
|
||||
// TrueHD and DTS do not (#1703).
|
||||
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
|
||||
|
||||
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
|
||||
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 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs({ false }, { false }))
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
|
||||
// Every encoding advertised, but no raw track and no IEC 61937 shape opens: mpv has no
|
||||
// decode fallback for a named codec, so nothing may be named (#1991).
|
||||
assertEquals("", spdifCodecs(allEncodings, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawCapableRouteBitstreamsTheCoreCodecsWithoutAnyIecShape() {
|
||||
// #2177's Shield: every mpv IEC track opens and drains into silence, while raw
|
||||
// ENCODING_AC3/E_AC3/DTS tracks (the ExoPlayer transport) play. The AO opens raw first,
|
||||
// so raw support alone must qualify the core codecs.
|
||||
assertEquals(
|
||||
"ac3,eac3,dts",
|
||||
spdifCodecs(allEncodings, emptySet(), raw = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3, C.ENCODING_DTS))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawSupportNeverQualifiesTheLosslessCodecs() {
|
||||
// TrueHD and DTS-HD MA have no raw transport in the AO; they ride the 192kHz/7.1 IEC
|
||||
// carrier or decode. A route that takes every raw track but no carrier must not name them.
|
||||
assertEquals("ac3,eac3,dts", spdifCodecs(allEncodings, emptySet(), raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawProbeIsOnlyConsultedForRawCandidates() {
|
||||
// The raw probe costs real route calls; the carrier-only codecs must never trigger it.
|
||||
val codecs = mpvSpdifCodecs(
|
||||
{ true },
|
||||
{ true },
|
||||
{ encoding ->
|
||||
if (encoding == C.ENCODING_DOLBY_TRUEHD || encoding == C.ENCODING_DTS_HD) {
|
||||
throw AssertionError("raw probe consulted for a carrier-only codec")
|
||||
}
|
||||
true
|
||||
}
|
||||
)
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", codecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdStillSupersedesPlainDtsWhenDtsBitstreamsRaw() {
|
||||
// dts-hd selects the lossless spdif decoder for the whole dts codec; the raw core track
|
||||
// must not resurrect the plain name beside it.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes, raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteIsNeverOfferedBelowApi24() {
|
||||
// ENCODING_IEC61937 does not exist there.
|
||||
fun carrierIsNeverOfferedBelowApi29() {
|
||||
// No direct-playback oracle exists there, and getMinBufferSize alone is known to lie
|
||||
// (a Shield sizes the tuple, then the AudioTrack fails to initialise).
|
||||
assertFalse(
|
||||
iecRouteSupported(
|
||||
sdkInt = 23,
|
||||
canSizeBuffer = { true },
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = 28,
|
||||
canSizeCarrierBuffer = { true },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true },
|
||||
hdmiRouteAdvertised = { true }
|
||||
directPlaybackSupported = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteRequiresASizableBufferOnEveryTier() {
|
||||
for (sdkInt in intArrayOf(25, 28, 29, 30, 32, 33, 34)) {
|
||||
fun carrierRequiresASizableBufferOnEveryTier() {
|
||||
for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) {
|
||||
assertFalse(
|
||||
"api $sdkInt",
|
||||
iecRouteSupported(
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeBuffer = { false },
|
||||
canSizeCarrierBuffer = { false },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true },
|
||||
hdmiRouteAdvertised = { true }
|
||||
directPlaybackSupported = { 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() {
|
||||
fun carrierOnApi29To32FollowsTheDirectPlaybackProbe() {
|
||||
// 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(
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeBuffer = { true },
|
||||
canSizeCarrierBuffer = { 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") }
|
||||
directPlaybackSupported = { supported }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -274,35 +132,20 @@ class AudioOutputPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routeOnApi33UsesTheBitstreamProbe() {
|
||||
fun carrierOnApi33UsesTheBitstreamProbe() {
|
||||
// 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(
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = 33,
|
||||
canSizeBuffer = { true },
|
||||
canSizeCarrierBuffer = { 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") }
|
||||
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every encoding the spdif table can ask for, i.e. a receiver that decodes all of them. */
|
||||
private val allEncodings = setOf(
|
||||
C.ENCODING_AC3,
|
||||
C.ENCODING_E_AC3,
|
||||
C.ENCODING_DOLBY_TRUEHD,
|
||||
C.ENCODING_DTS,
|
||||
C.ENCODING_DTS_HD
|
||||
)
|
||||
|
||||
private val allShapes = MpvIecShape.values().toSet()
|
||||
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String =
|
||||
mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
}
|
||||
|
||||
-11
@@ -8,23 +8,12 @@ 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() {
|
||||
|
||||
@@ -28,6 +28,7 @@ class BufferingStallPolicyTest {
|
||||
loading = loading
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
|
||||
// The #1790 shape on a high bitrate stream: the byte cap is full at a couple of seconds, the
|
||||
@@ -49,6 +50,7 @@ class BufferingStallPolicyTest {
|
||||
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
|
||||
@@ -79,6 +81,7 @@ class BufferingStallPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
|
||||
// Enough media to start at 1x, but the load control measures its bar in playout time, so at 2x
|
||||
@@ -101,6 +104,7 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun advancingPositionIsHealthy() {
|
||||
assertEquals(
|
||||
@@ -130,6 +134,7 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun frozenPositionWaitsOutTheTimeout() {
|
||||
assertEquals(Verdict.WAITING, evaluate(elapsedMs = BufferingStallPolicy.STALL_TIMEOUT_MS - 1))
|
||||
@@ -141,6 +146,7 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun emptyBufferIsStarved() {
|
||||
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 0))
|
||||
@@ -191,6 +197,7 @@ class BufferingStallPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun starvationAndProgressBothRestartTheStallClock() {
|
||||
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.STARVED))
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
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,6 +10,7 @@ class DvBitstreamSanitizerTest {
|
||||
|
||||
private val sanitizer = DvBitstreamSanitizer()
|
||||
|
||||
|
||||
@Test
|
||||
fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
|
||||
val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02))
|
||||
@@ -83,6 +84,7 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(original, remainingBytes(buffer))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
|
||||
val vcl = annexBNal(1, byteArrayOf(0x01))
|
||||
@@ -107,6 +109,7 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun respectsPositionAndRestoresIt() {
|
||||
val prefix = byteArrayOf(0xAA.toByte(), 0xBB.toByte())
|
||||
@@ -239,6 +242,7 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||
}
|
||||
|
||||
|
||||
/** 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,6 +24,7 @@ class EndOfStreamPolicyTest {
|
||||
frameStallMs = frameStallMs
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
|
||||
// The reported failure: the clock ran a minute past the last frame.
|
||||
@@ -63,6 +64,7 @@ class EndOfStreamPolicyTest {
|
||||
assertFalse(isFinishedFile(hasPlaybackOutput = false))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun fallbackResumesStrictlyInsideTheMedia() {
|
||||
// The observed hand-off asked MPV to start 59_816ms past the end.
|
||||
|
||||
@@ -9,6 +9,7 @@ private const val MIB = 1024 * 1024
|
||||
|
||||
class LoadControlPolicyTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
|
||||
assertEquals(
|
||||
@@ -62,6 +63,7 @@ class LoadControlPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun readAheadReportsSecondsAtAKnownBitrate() {
|
||||
// 64MiB of the #1618 stream (103_341 kbps) is ~5.2s — under the 15s minBufferMs, so the
|
||||
@@ -82,6 +84,7 @@ class LoadControlPolicyTest {
|
||||
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.
|
||||
@@ -166,6 +169,7 @@ class LoadControlPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun theWireNamesMatchTheDartNativeValues() {
|
||||
assertEquals(LoadControlPolicy.BufferTier.LARGE, LoadControlPolicy.BufferTier.fromWire("large"))
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class RawPositionAudioOutputTest {
|
||||
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
|
||||
@@ -114,6 +115,7 @@ class RawPositionAudioOutputTest {
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
|
||||
val delegate = FakeOutputProvider()
|
||||
@@ -174,6 +176,7 @@ class RawPositionAudioOutputTest {
|
||||
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
|
||||
@@ -215,6 +218,7 @@ class RawPositionAudioOutputTest {
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
|
||||
private class RecordingListener : AudioOutput.Listener {
|
||||
var releasedCount = 0
|
||||
var underrunCount = 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.junit.Test
|
||||
|
||||
class ResumeStallPolicyTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun windowFloorsAtDefaultForNormalFrameRates() {
|
||||
// 24fps → 4 × 42ms = 168ms, floored to 1000ms
|
||||
@@ -37,6 +38,7 @@ class ResumeStallPolicyTest {
|
||||
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun advancingFramesAreHealthy() {
|
||||
assertEquals(
|
||||
|
||||
@@ -9,39 +9,16 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun letterboxUsesVisibleVideoRectForTextAndBitmapSubtitles() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
val bitmap = bitmapDimensions()
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
|
||||
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()
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
@@ -51,7 +28,7 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun manualZoomKeepsBitmapSubtitlesFullyVisible() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, zoomScale = 1.5f)
|
||||
val bitmap = bitmapDimensions()
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, zoomScale = 1.5f)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
@@ -61,7 +38,7 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun stretchDoesNotStretchBitmapSubtitlesToScreenAspect() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL)
|
||||
val bitmap = bitmapDimensions()
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
@@ -71,7 +48,7 @@ class SubtitleViewLayoutTest {
|
||||
@Test
|
||||
fun anchorToScreenSizesTextToContainerAndKeepsBitmapOnVideoRect() {
|
||||
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, anchorToScreen = true)
|
||||
val bitmap = bitmapDimensions()
|
||||
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
|
||||
assertEquals(SubtitleViewDimensions(2424, 1080), text)
|
||||
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
|
||||
@@ -122,13 +99,17 @@ class SubtitleViewLayoutTest {
|
||||
anchorToScreen = anchorToScreen
|
||||
)
|
||||
|
||||
private fun bitmapDimensions(): SubtitleViewDimensions? = SubtitleViewLayout.bitmapDimensions(
|
||||
private fun bitmapDimensions(
|
||||
resizeMode: Int,
|
||||
zoomScale: Float = 1f
|
||||
): SubtitleViewDimensions? = SubtitleViewLayout.bitmapDimensions(
|
||||
containerWidth = 2424,
|
||||
containerHeight = 1080,
|
||||
videoWidth = 1920,
|
||||
videoHeight = 1080,
|
||||
pixelRatio = 1f,
|
||||
planeAspect = null
|
||||
resizeMode = resizeMode,
|
||||
zoomScale = zoomScale
|
||||
)
|
||||
|
||||
private fun assertAspectCloseTo16By9(dimensions: SubtitleViewDimensions) {
|
||||
|
||||
+41
-211
@@ -8,12 +8,10 @@ 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
|
||||
@@ -24,14 +22,13 @@ 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).
|
||||
* Routing and back-pressure contract for the TrueHD MAT carrier (#1804).
|
||||
*
|
||||
* 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.
|
||||
* the wrong sink handle TrueHD.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class IecCarrierSinkTest {
|
||||
class TrueHdCarrierSinkTest {
|
||||
|
||||
private val accessUnits: ByteArray =
|
||||
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
|
||||
@@ -41,36 +38,19 @@ class IecCarrierSinkTest {
|
||||
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 })
|
||||
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
||||
|
||||
|
||||
/**
|
||||
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
|
||||
@@ -92,54 +72,6 @@ class IecCarrierSinkTest {
|
||||
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() {
|
||||
@@ -148,8 +80,8 @@ class IecCarrierSinkTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 44.1kHz-family TrueHD rides a 176.4kHz carrier this path does not build. It has to be decided
|
||||
* from the format: the packer only learns the rate from a major sync, long after selection, and
|
||||
* selecting the carrier for it would produce silence.
|
||||
*/
|
||||
@Test
|
||||
@@ -157,8 +89,6 @@ class IecCarrierSinkTest {
|
||||
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. */
|
||||
@@ -167,7 +97,6 @@ class IecCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
|
||||
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat()))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +110,7 @@ class IecCarrierSinkTest {
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
@@ -202,7 +131,7 @@ class IecCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
|
||||
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
|
||||
@@ -217,13 +146,14 @@ class IecCarrierSinkTest {
|
||||
val carrierSink = sink()
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
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
|
||||
@@ -235,7 +165,7 @@ class IecCarrierSinkTest {
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
val listener = RecordingSinkListener()
|
||||
carrierSink.setListener(listener)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
val buffer = ByteBuffer.wrap(fortyFourFamilyAccessUnits())
|
||||
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
|
||||
@@ -251,33 +181,6 @@ class IecCarrierSinkTest {
|
||||
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
|
||||
@@ -287,7 +190,7 @@ class IecCarrierSinkTest {
|
||||
fun theMismatchLatchSurvivesTheResetThatAccompaniesTheHandover() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
carrierSink.flush()
|
||||
@@ -305,7 +208,7 @@ class IecCarrierSinkTest {
|
||||
fun releasingTheSinkClearsTheMismatchLatch() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
|
||||
carrierSink.release()
|
||||
@@ -335,7 +238,7 @@ class IecCarrierSinkTest {
|
||||
fun aNewMediaItemClearsTheMismatchLatch() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
|
||||
assertEquals(
|
||||
AudioSink.SINK_FORMAT_UNSUPPORTED,
|
||||
@@ -360,7 +263,7 @@ class IecCarrierSinkTest {
|
||||
fun aLateMismatchFromThePreviousItemDoesNotPoisonTheNewOne() {
|
||||
val carrierSink = sink()
|
||||
carrierSink.setListener(RecordingSinkListener())
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
// The new item starts before the outgoing stream's last buffer is handled.
|
||||
carrierSink.beginMediaItem()
|
||||
@@ -373,7 +276,7 @@ class IecCarrierSinkTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything that is not a carrier codec keeps going to the existing processed sink. */
|
||||
/** Everything that is not TrueHD keeps going to the existing processed sink. */
|
||||
@Test
|
||||
fun otherFormatsAreLeftToTheNormalSink() {
|
||||
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
|
||||
@@ -384,6 +287,7 @@ class IecCarrierSinkTest {
|
||||
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,
|
||||
@@ -393,12 +297,12 @@ class IecCarrierSinkTest {
|
||||
fun aRefusedBurstMidSampleLosesNoAccessUnits() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
// Refuse every third burst once, so rejections land inside samples rather than between them.
|
||||
carrier.refuseEveryNth = 3
|
||||
|
||||
val produced = feedWholeStream(carrierSink, accessUnits)
|
||||
val produced = feedWholeStream(carrierSink)
|
||||
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
|
||||
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
|
||||
}
|
||||
@@ -408,55 +312,9 @@ class IecCarrierSinkTest {
|
||||
fun theCarrierOutputMatchesTheGoldenStream() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
assertArrayEquals(golden, feedWholeStream(carrierSink))
|
||||
}
|
||||
|
||||
/** A refused burst must be re-offered before any further input is taken. */
|
||||
@@ -464,7 +322,7 @@ class IecCarrierSinkTest {
|
||||
fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() {
|
||||
val carrier = FakeSink()
|
||||
val carrierSink = sink(carrier = carrier)
|
||||
carrierSink.configure(audioSinkConfig())
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
carrier.refuseNext = true
|
||||
|
||||
val buffer = ByteBuffer.wrap(accessUnits)
|
||||
@@ -475,6 +333,7 @@ class IecCarrierSinkTest {
|
||||
assertTrue(carrierSink.hasPendingData())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun persistentControlsReachBothDelegates() {
|
||||
val carrier = FakeSink()
|
||||
@@ -507,37 +366,14 @@ class IecCarrierSinkTest {
|
||||
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)
|
||||
carrierSink.configure(trueHdFormat(), 0, null)
|
||||
|
||||
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)
|
||||
val configured = checkNotNull(carrier.configuredFormat)
|
||||
assertEquals(MimeTypes.AUDIO_RAW, configured.sampleMimeType)
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.pcmEncoding)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.sampleRate)
|
||||
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.channelCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -546,29 +382,25 @@ class IecCarrierSinkTest {
|
||||
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)
|
||||
carrierSink.configure(ac3, 0, null)
|
||||
|
||||
assertSame(config, normal.configuredConfig)
|
||||
assertSame(ac3, normal.configuredConfig?.format)
|
||||
assertEquals(null, carrier.configuredConfig)
|
||||
assertSame(ac3, normal.configuredFormat)
|
||||
assertEquals(null, carrier.configuredFormat)
|
||||
}
|
||||
|
||||
private fun feedWholeStream(carrierSink: IecCarrierSink, units: ByteArray): ByteArray {
|
||||
val buffer = ByteBuffer.wrap(units)
|
||||
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
|
||||
val buffer = ByteBuffer.wrap(accessUnits)
|
||||
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) {
|
||||
while (buffer.hasRemaining() && 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")
|
||||
private fun carrierSinkDelegate(sink: TrueHdCarrierSink): FakeSink {
|
||||
val field = TrueHdCarrierSink::class.java.getDeclaredField("carrierSink")
|
||||
field.isAccessible = true
|
||||
return field.get(sink) as FakeSink
|
||||
}
|
||||
@@ -588,8 +420,7 @@ class IecCarrierSinkTest {
|
||||
/** 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 configuredFormat: Format? = null
|
||||
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
var lastVolume: Float = -1f
|
||||
var sessionId: Int = -1
|
||||
@@ -613,13 +444,12 @@ class IecCarrierSinkTest {
|
||||
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 configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
|
||||
configuredFormat = inputFormat
|
||||
}
|
||||
|
||||
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
|
||||
@@ -67,8 +67,8 @@ class TrueHdMatPackerTest {
|
||||
/** 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
|
||||
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME
|
||||
val durationUs = framesPerBurst * 1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
|
||||
assertEquals(20_000L, durationUs)
|
||||
}
|
||||
|
||||
@@ -166,11 +166,11 @@ class TrueHdMatPackerTest {
|
||||
|
||||
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)
|
||||
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedRateFamily)
|
||||
|
||||
packer.reset()
|
||||
|
||||
assertFalse("reset must clear the flag", packer.unsupportedStream)
|
||||
assertFalse("reset must clear the flag", packer.unsupportedRateFamily)
|
||||
assertArrayEquals(
|
||||
"a clean packer must reproduce the stream after a poisoned one",
|
||||
golden,
|
||||
|
||||
-11
@@ -9,24 +9,13 @@ 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() {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.DeflaterOutputStream
|
||||
import java.util.zip.Inflater
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint,
|
||||
* timecode, flags) must survive byte-identically, the payload must inflate, and
|
||||
* every block the rewriter cannot prove safe must pass through unchanged (null).
|
||||
*/
|
||||
class ZlibTextBlockTest {
|
||||
|
||||
private val inflater = Inflater()
|
||||
|
||||
private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload
|
||||
|
||||
private fun deflate(bytes: ByteArray): ByteArray {
|
||||
val target = ByteArrayOutputStream()
|
||||
DeflaterOutputStream(target, Deflater()).use { it.write(bytes) }
|
||||
return target.toByteArray()
|
||||
}
|
||||
|
||||
// Track 3 (varint 0x83), relative timecode 0x0102, no lacing.
|
||||
private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00)
|
||||
|
||||
@Test
|
||||
fun inflatesUnlacedBlockPayloadAndPreservesHeader() {
|
||||
val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray()
|
||||
val data = block(unlacedHeader, deflate(dialogue))
|
||||
|
||||
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
|
||||
|
||||
assertArrayEquals(unlacedHeader + dialogue, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoByteTrackNumberVarintIsPreserved() {
|
||||
val dialogue = "1,,Default,,0,0,0,,hello".toByteArray()
|
||||
val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298
|
||||
val data = block(header, deflate(dialogue))
|
||||
|
||||
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
|
||||
|
||||
assertArrayEquals(header + dialogue, rewritten)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lacedBlockPassesThrough() {
|
||||
val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set
|
||||
val data = block(laced, deflate("payload".toByteArray()))
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptStreamPassesThrough() {
|
||||
val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun truncatedStreamPassesThrough() {
|
||||
val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray())
|
||||
val truncated = compressed.copyOf(compressed.size - 4)
|
||||
val data = block(unlacedHeader, truncated)
|
||||
|
||||
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerOnlyBlockPassesThrough() {
|
||||
assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater))
|
||||
}
|
||||
}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.DefaultExtractorInput
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.CueDecoder
|
||||
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with
|
||||
* `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that
|
||||
* broke #2023) and an identical uncompressed mux — and verifies the zlib block
|
||||
* payloads are inflated *before* MatroskaExtractor's subtitle sample assembly.
|
||||
*
|
||||
* Regenerate with:
|
||||
* mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass
|
||||
* mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass
|
||||
* where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events.
|
||||
*
|
||||
* Robolectric provides real android.util/android.os implementations —
|
||||
* MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which
|
||||
* are no-op stubs on plain JVM.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ZlibTextSubtitleExtractionTest {
|
||||
|
||||
private class CapturedSample(val timeUs: Long, val data: ByteArray)
|
||||
|
||||
private class FakeTrackOutput : TrackOutput {
|
||||
val formats = mutableListOf<androidx.media3.common.Format>()
|
||||
val samples = mutableListOf<CapturedSample>()
|
||||
private var buf = ByteArray(64 * 1024)
|
||||
private var bufLen = 0
|
||||
|
||||
override fun format(format: androidx.media3.common.Format) {
|
||||
formats.add(format)
|
||||
}
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
|
||||
ensureCapacity(bufLen + length)
|
||||
val read = input.read(buf, bufLen, length)
|
||||
if (read > 0) bufLen += read
|
||||
return read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
ensureCapacity(bufLen + length)
|
||||
data.readBytes(buf, bufLen, length)
|
||||
bufLen += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
|
||||
val start = bufLen - offset - size
|
||||
samples.add(CapturedSample(timeUs, buf.copyOfRange(start, start + size)))
|
||||
if (offset == 0) bufLen = 0
|
||||
}
|
||||
|
||||
private fun ensureCapacity(needed: Int) {
|
||||
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeExtractorOutput : ExtractorOutput {
|
||||
val tracks = mutableMapOf<Int, FakeTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
|
||||
override fun endTracks() {}
|
||||
override fun seekMap(seekMap: SeekMap) {}
|
||||
}
|
||||
|
||||
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
|
||||
var position = 0L
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (position >= data.size) return C.RESULT_END_OF_INPUT
|
||||
val toRead = minOf(length, data.size - position.toInt())
|
||||
System.arraycopy(data, position.toInt(), buffer, offset, toRead)
|
||||
position += toRead
|
||||
return toRead
|
||||
}
|
||||
}
|
||||
|
||||
private fun extract(resource: String): FakeExtractorOutput {
|
||||
val data = checkNotNull(javaClass.getResourceAsStream(resource)) {
|
||||
"fixture $resource missing from test resources"
|
||||
}.use { it.readBytes() }
|
||||
|
||||
// DefaultSubtitleParserFactory (not AssSubtitleParserFactory) so the SSA track
|
||||
// transcodes to decodable media3 cues instead of loading native libass.
|
||||
val extractor = ZlibMatroskaExtractor(DefaultSubtitleParserFactory(), AssHandler())
|
||||
val output = FakeExtractorOutput()
|
||||
extractor.init(output)
|
||||
|
||||
val reader = ByteArrayDataReader(data)
|
||||
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
|
||||
val seekPosition = PositionHolder()
|
||||
while (true) {
|
||||
when (extractor.read(input, seekPosition)) {
|
||||
Extractor.RESULT_END_OF_INPUT -> return output
|
||||
Extractor.RESULT_SEEK -> {
|
||||
reader.position = seekPosition.position
|
||||
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DecodedCue(val startTimeUs: Long, val durationUs: Long, val text: String)
|
||||
|
||||
private fun decodeCues(output: FakeExtractorOutput): List<DecodedCue> {
|
||||
val track = output.tracks.values.single()
|
||||
val format = track.formats.last()
|
||||
assertEquals(MimeTypes.APPLICATION_MEDIA3_CUES, format.sampleMimeType)
|
||||
assertEquals(MimeTypes.TEXT_SSA, format.codecs)
|
||||
val decoder = CueDecoder()
|
||||
return track.samples.map { sample ->
|
||||
val cues = decoder.decode(sample.timeUs, sample.data, 0, sample.data.size)
|
||||
DecodedCue(
|
||||
cues.startTimeUs,
|
||||
cues.durationUs,
|
||||
cues.cues.joinToString("\n") { it.text.toString() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibCompressedAssDialogueIsInflatedToParseableCues() {
|
||||
val cues = decodeCues(extract("/zlib_ssa.mkv"))
|
||||
|
||||
assertEquals(45, cues.size)
|
||||
assertEquals("SUBTITLE LINE 1 (0:00:00.00)", cues.first().text)
|
||||
assertEquals(0L, cues.first().startTimeUs)
|
||||
assertEquals(2_000_000L, cues.first().durationUs)
|
||||
assertEquals("SUBTITLE LINE 45 (0:01:28.00)", cues.last().text)
|
||||
assertEquals(88_000_000L, cues.last().startTimeUs)
|
||||
cues.forEachIndexed { index, cue ->
|
||||
assertEquals(index * 2_000_000L, cue.startTimeUs)
|
||||
assertTrue("cue $index text: ${cue.text}", cue.text.startsWith("SUBTITLE LINE ${index + 1} "))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zlibAndPlainMuxesProduceIdenticalCues() {
|
||||
val zlib = decodeCues(extract("/zlib_ssa.mkv"))
|
||||
val plain = decodeCues(extract("/plain_ssa.mkv"))
|
||||
|
||||
assertEquals(plain.size, zlib.size)
|
||||
plain.zip(zlib).forEachIndexed { index, (expected, actual) ->
|
||||
assertEquals("startTimeUs of cue $index", expected.startTimeUs, actual.startTimeUs)
|
||||
assertEquals("durationUs of cue $index", expected.durationUs, actual.durationUs)
|
||||
assertEquals("text of cue $index", expected.text, actual.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Boundaries are the contract; rationale on [DemuxerBudget]. */
|
||||
class DemuxerBudgetTest {
|
||||
private val mib = 1024L * 1024L
|
||||
|
||||
@Test
|
||||
fun `unknown heap class keeps mpv defaults`() {
|
||||
assertNull(DemuxerBudget.forHeapClassMB(0))
|
||||
assertNull(DemuxerBudget.forHeapClassMB(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small heap class gets the tight tier`() {
|
||||
val budget = DemuxerBudget.forHeapClassMB(256)!!
|
||||
assertEquals(32 * mib, budget.aheadBytes)
|
||||
assertEquals(16 * mib, budget.backBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid heap class gets the middle tier`() {
|
||||
assertEquals(64 * mib, DemuxerBudget.forHeapClassMB(257)!!.aheadBytes)
|
||||
val budget = DemuxerBudget.forHeapClassMB(512)!!
|
||||
assertEquals(64 * mib, budget.aheadBytes)
|
||||
assertEquals(32 * mib, budget.backBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large heap class gets the full tier`() {
|
||||
val budget = DemuxerBudget.forHeapClassMB(513)!!
|
||||
assertEquals(100 * mib, budget.aheadBytes)
|
||||
assertEquals(48 * mib, budget.backBytes)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* P5 has no compatible base layer: on a device without native DV it must
|
||||
* leave the video plane for gpu-next software reshaping, and nothing else
|
||||
* may be routed (wrong-colors class verified on a Pixel 7). The vo target
|
||||
* matrix keeps gpu-next away from hardware sessions (#2010) while giving
|
||||
* the reshaping path the only vo that composites RPU metadata (#1902).
|
||||
*/
|
||||
class GpuVoPolicyTest {
|
||||
@Test
|
||||
fun `P5 without native support is routed`() {
|
||||
assertTrue(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `P5 with native support stays on the plane`() {
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base-layer-compatible profiles are never routed`() {
|
||||
// P7/P8 strip to an HDR10/HLG base layer; non-DV content has no profile.
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(7L, "auto", canPlayP5Natively = false))
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(8L, "auto", canPlayP5Natively = false))
|
||||
assertFalse(GpuVoPolicy.needsDvReshaping(null, "auto", canPlayP5Natively = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit conversion modes are user overrides and stay native`() {
|
||||
for (mode in listOf("disabled", "native", "dv81", "hevc", "hevc_strip")) {
|
||||
assertFalse(mode, GpuVoPolicy.needsDvReshaping(5L, mode, canPlayP5Natively = false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hdr tone-mapping is needed only for a PQ or HLG signal on a non-HDR display`() {
|
||||
assertTrue(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = false))
|
||||
assertTrue(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = false))
|
||||
// An HDR display scans the signal out itself.
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = true))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = true))
|
||||
// SDR transfers need no mapping, and mpv reports none before the first
|
||||
// frame of a file.
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("bt.1886", displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("srgb", displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping(null, displaySupportsHdr = false))
|
||||
assertFalse(GpuVoPolicy.needsHdrToneMapping("", displaySupportsHdr = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only direct mediacodec output can stay on the plane`() {
|
||||
// -copy also reads frames back into system memory, so it leaves too.
|
||||
assertTrue(GpuVoPolicy.needsSoftwareRender("no"))
|
||||
assertTrue(GpuVoPolicy.needsSoftwareRender("mediacodec-copy"))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender("mediacodec"))
|
||||
// Unreported until the decoder initializes: stay on the plane.
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender(null))
|
||||
assertFalse(GpuVoPolicy.needsSoftwareRender(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a software-decoding session targets gpu, not gpu-next`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SW_DECODE)))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_SW_DECODE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no reasons keeps the video plane`() {
|
||||
assertNull(GpuVoPolicy.targetFor(emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dv reshaping targets gpu-next even alongside other reasons`() {
|
||||
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
|
||||
assertEquals(
|
||||
"gpu-next",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_DV_RESHAPE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shaders and chain failure target the hardware-safe gpu vo`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CHAIN_FAILURE)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_CHAIN_FAILURE))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hdr tone-mapping targets gpu but yields to dv reshaping`() {
|
||||
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR)))
|
||||
assertEquals(
|
||||
"gpu",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_SHADERS))
|
||||
)
|
||||
assertEquals(
|
||||
"gpu-next",
|
||||
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_DV_RESHAPE))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -49,40 +48,6 @@ 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 hardwareDecodeSessionsUseTheMediaCodecVoWithGpuFallback() {
|
||||
// Rationale on MpvPlayerCore.initialVideoOutput.
|
||||
assertEquals("mediacodec,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
|
||||
assertEquals("gpu,gpu-next", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrSurfaceIsWantedOnlyForPqAndHlgTransfers() {
|
||||
// Rationale on MpvPlayerCore.wantsHdrSurface: both render into a PQ
|
||||
// target; anything else stays on the sRGB surface, which renders every
|
||||
// content correctly.
|
||||
assertTrue(MpvPlayerCore.wantsHdrSurface("smpte2084"))
|
||||
assertTrue(MpvPlayerCore.wantsHdrSurface("arib-std-b67"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("bt709"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("bt1886"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface("unknown"))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface(""))
|
||||
assertFalse(MpvPlayerCore.wantsHdrSurface(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
@@ -633,53 +598,6 @@ class MpvPlayerPluginTest {
|
||||
assertEquals(0, pending.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleDisposeIsAcknowledgedWithoutTearingDownTheCore() {
|
||||
// A dispose whose instanceId is not the core creator's lost the ownership
|
||||
// race to a successor; tearing the core down anyway would kill that
|
||||
// successor's session. It must be acknowledged as a no-op instead.
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 2L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 1)), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNotNull(getPluginField(plugin, "playerCore"))
|
||||
assertEquals(2L, getPluginField(plugin, "coreInstanceId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchingDisposeTearsDownTheCoreAndClearsTheToken() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 7L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 7)), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNull(getPluginField(plugin, "playerCore"))
|
||||
assertNull(getPluginField(plugin, "coreInstanceId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenlessDisposeKeepsLegacySemanticsAndTearsDownTheCore() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore { _, _ -> })
|
||||
setPluginField(plugin, "coreInstanceId", 7L)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("dispose", null), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertNull(result.errorCode)
|
||||
assertNull(getPluginField(plugin, "playerCore"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
@@ -949,97 +867,6 @@ class MpvPlayerPluginTest {
|
||||
getBoolean(core)
|
||||
}
|
||||
|
||||
private fun invokeSetGpuVoRequirement(core: MpvPlayerCore, reason: String, active: Boolean) {
|
||||
MpvPlayerCore::class.java
|
||||
.getDeclaredMethod("setGpuVoRequirement", String::class.java, Boolean::class.javaPrimitiveType)
|
||||
.apply {
|
||||
isAccessible = true
|
||||
invoke(core, reason, active)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voTargetFollowsTheActiveReasonSet() {
|
||||
// Reason precedence through the real property-write path: DV reshaping
|
||||
// outranks HDR-on-SDR, dropping the winner falls back to the reason still
|
||||
// active, and dropping the last one returns the session to the plane.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
setBoolean(core, "isInitialized", true)
|
||||
|
||||
fun lastVo(): String? = writes.toList().lastOrNull { it.first == "vo" }?.second
|
||||
|
||||
// HDR-on-SDR raises first, then the DV router wins the arbitration.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, true)
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, true)
|
||||
awaitCondition { lastVo() == "gpu-next" }
|
||||
assertEquals("gpu-next", lastVo())
|
||||
|
||||
// Dropping the winning reason must fall back to the one still active,
|
||||
// not to the plane.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, false)
|
||||
awaitCondition { lastVo() == "gpu" }
|
||||
assertEquals("gpu", lastVo())
|
||||
|
||||
// Last reason dropping returns the session to the video plane.
|
||||
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, false)
|
||||
awaitCondition { lastVo() == "mediacodec" }
|
||||
assertEquals("mediacodec", lastVo())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dvConversionModeMapsOntoForkDecoderOptions() {
|
||||
// The app-level `dv-conversion-mode` property must translate to the fork
|
||||
// FFmpeg hevc_mediacodec options, mirroring the ExoPlayer DoviBridge
|
||||
// modes. Robolectric reports no Dolby Vision display, so `auto` takes the
|
||||
// no-DV branch deterministically.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
|
||||
fun apply(mode: String): Result<Unit> {
|
||||
writes.clear()
|
||||
var outcome: Result<Unit>? = null
|
||||
core.setProperty("dv-conversion-mode", mode) { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
return outcome!!
|
||||
}
|
||||
|
||||
assertTrue(apply("auto").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=0,dv_p7_mode=strip"), writes.toList())
|
||||
|
||||
assertTrue(apply("disabled").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=native"), writes.toList())
|
||||
|
||||
assertTrue(apply("dv81").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=convert"), writes.toList())
|
||||
|
||||
assertTrue(apply("hevc_strip").isSuccess)
|
||||
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=strip"), writes.toList())
|
||||
|
||||
val invalid = apply("bogus")
|
||||
assertTrue(invalid.isFailure)
|
||||
assertTrue(writes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderOptionsMergeKeepsUserEntriesFirst() {
|
||||
// The property interface cannot append to a list option, so the write
|
||||
// replaces it wholesale: a user's own mpv.conf `vd-lavc-o` entries must
|
||||
// survive, with the app's keys last (FFmpeg keeps the last duplicate).
|
||||
assertEquals(
|
||||
"threads=4,dolby_vision=1",
|
||||
MpvPlayerCore.mergeDecoderOptions("threads=4", "dolby_vision=1")
|
||||
)
|
||||
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(null, "dolby_vision=1"))
|
||||
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(" ", "dolby_vision=1"))
|
||||
}
|
||||
|
||||
private fun awaitQueueEntry(
|
||||
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||
expected: Pair<String, String>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VideoRectPolicyTest {
|
||||
@Test
|
||||
fun `contain letterboxes wide content inside the container`() {
|
||||
// 2.39:1 content on a 16:9 container: full width, bars top and bottom.
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
|
||||
assertEquals(1920, size.width)
|
||||
assertEquals(803, size.height)
|
||||
assertTrue(size.height < 1080)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contain pillarboxes tall content inside the container`() {
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 1000, 2390)!!
|
||||
assertEquals(1080, size.height)
|
||||
assertTrue(size.width < 1920)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panscan 1 fills the container so the image is cropped, not letterboxed`() {
|
||||
// The regression this exists for: cover used to be a no-op on the plane.
|
||||
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue("cover must reach the container height", size.height >= 1080)
|
||||
assertTrue("width must overflow and be clipped", size.width > 1920)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panscan interpolates between contain and cover`() {
|
||||
val contain = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
|
||||
val half = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 0.5f)!!
|
||||
val cover = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue(half.height > contain.height)
|
||||
assertTrue(half.height < cover.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `video-zoom is a log2 factor on top of the fit`() {
|
||||
val base = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
|
||||
val doubled = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 1f)!!
|
||||
val halved = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = -1f)!!
|
||||
assertEquals(base.width * 2, doubled.width)
|
||||
assertEquals(base.width / 2, halved.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cover fills a container whose aspect is wildly mismatched`() {
|
||||
// Scope content in a portrait container: cover/fit exceeds 4, which a
|
||||
// fit-relative clamp would truncate back into letterboxing.
|
||||
val size = VideoRectPolicy.sizeFor(1080, 2400, 2390, 1000, panscan = 1f)!!
|
||||
assertTrue("height must reach the container", size.height >= 2400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absurd zoom is bounded rather than allocating an unbounded surface`() {
|
||||
val fit = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
|
||||
val absurd = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 12f)!!
|
||||
assertEquals(fit.width * 4, absurd.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown dimensions yield no size instead of a degenerate one`() {
|
||||
assertNull(VideoRectPolicy.sizeFor(0, 1080, 1920, 1080))
|
||||
assertNull(VideoRectPolicy.sizeFor(1920, 1080, 0, 0))
|
||||
assertNull(VideoRectPolicy.sizeFor(1920, 0, 1920, 1080))
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import com.edde746.plezy.shared.DisplayModeSelector.ModeInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DisplayModeSelectorTest {
|
||||
|
||||
// A typical 4K TV: panel-native modes plus lower-resolution HDMI modes.
|
||||
private val uhd60 = ModeInfo(1, 3840, 2160, 60f)
|
||||
private val uhd50 = ModeInfo(2, 3840, 2160, 50f)
|
||||
private val uhd24 = ModeInfo(3, 3840, 2160, 23.976f)
|
||||
private val fhd60 = ModeInfo(4, 1920, 1080, 60f)
|
||||
private val fhd50 = ModeInfo(5, 1920, 1080, 50f)
|
||||
private val fhd24 = ModeInfo(6, 1920, 1080, 23.976f)
|
||||
private val hd60 = ModeInfo(7, 1280, 720, 60f)
|
||||
private val sd60 = ModeInfo(8, 720, 480, 60f)
|
||||
private val allModes = listOf(uhd60, uhd50, uhd24, fhd60, fhd50, fhd24, hd60, sd60)
|
||||
|
||||
private fun select(
|
||||
fps: Float,
|
||||
current: ModeInfo = uhd60,
|
||||
modes: List<ModeInfo> = allModes,
|
||||
videoWidth: Int = 0,
|
||||
videoHeight: Int = 0,
|
||||
matchResolution: Boolean = false
|
||||
) = DisplayModeSelector.findBestMode(fps, current, modes, videoWidth, videoHeight, matchResolution)
|
||||
|
||||
// --- Resolution matching ---
|
||||
|
||||
@Test
|
||||
fun resolutionMatchingPicksNativeResolutionAndRate() {
|
||||
val selection = select(23.976f, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
|
||||
assertEquals(fhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionOnlyRequestKeepsCurrentRefreshRate() {
|
||||
val selection = select(0f, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
|
||||
assertEquals(fhd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionMatchingNeverDownscalesTheVideo() {
|
||||
// 1080p-class anamorphic content is wider than 720p even though shorter:
|
||||
// the smallest containing mode is 1080p, not 720p.
|
||||
val selection = select(0f, videoWidth = 1920, videoHeight = 800, matchResolution = true)
|
||||
assertEquals(fhd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionWinsOverCadenceWhenNativeResolutionHasNoMatchingRate() {
|
||||
// No 720p24 mode exists; the 720p video still lands on 720p (TV upscales)
|
||||
// instead of widening back out to a 24 Hz mode at another resolution.
|
||||
val selection = select(23.976f, videoWidth = 1280, videoHeight = 720, matchResolution = true)
|
||||
assertEquals(hd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelNativeContentStaysAtPanelResolution() {
|
||||
val selection = select(23.976f, videoWidth = 3840, videoHeight = 2160, matchResolution = true)
|
||||
assertEquals(uhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelNativeResolutionOnlyRequestNeedsNoSwitch() {
|
||||
val selection = select(0f, videoWidth = 3840, videoHeight = 2160, matchResolution = true)
|
||||
assertEquals(uhd60, selection?.mode) // caller sees modeId == current and skips
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceLargerThanPanelFallsBackToCadencePolicy() {
|
||||
val selection = select(23.976f, videoWidth = 7680, videoHeight = 4320, matchResolution = true)
|
||||
assertEquals(uhd24, selection?.mode) // Tier-1 refresh-only switch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceLargerThanPanelWithoutFpsHasNoTarget() {
|
||||
assertNull(select(0f, videoWidth = 7680, videoHeight = 4320, matchResolution = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionMatchingWithoutDimensionsBehavesLikeCadenceOnly() {
|
||||
val selection = select(23.976f, matchResolution = true)
|
||||
assertEquals(uhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolutionOnlyPrefersRateClosestToCurrent() {
|
||||
// From a 50 Hz current mode, a resolution-only switch keeps 50 Hz.
|
||||
val selection = select(0f, current = uhd50, videoWidth = 1920, videoHeight = 1080, matchResolution = true)
|
||||
assertEquals(fhd50, selection?.mode)
|
||||
}
|
||||
|
||||
// --- Cadence-only policy (matchResolution off): pre-existing behaviour ---
|
||||
|
||||
@Test
|
||||
fun cadenceMatchingStaysAtCurrentResolution() {
|
||||
val selection = select(23.976f, videoWidth = 1920, videoHeight = 1080)
|
||||
assertEquals(uhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cadenceTierTwoAllowsResolutionChangeButNeverBelowVideo() {
|
||||
// Panel has no 4K@24; only 1080p@24 remains for 1080p content.
|
||||
val modes = listOf(uhd60, uhd50, fhd60, fhd24, hd60)
|
||||
val selection = select(23.976f, modes = modes, videoWidth = 1920, videoHeight = 1080)
|
||||
assertEquals(fhd24, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cadenceTierTwoRequiresKnownDimensions() {
|
||||
val modes = listOf(uhd60, uhd50, fhd60, fhd24, hd60)
|
||||
assertNull(select(23.976f, modes = modes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidFpsWithoutResolutionRequestHasNoTarget() {
|
||||
assertNull(select(0f, videoWidth = 1920, videoHeight = 1080))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleRateCountsAsCadenceMatch() {
|
||||
// 30 fps on a 60 Hz mode is a clean 2x pulldown; current 60 Hz mode wins.
|
||||
val selection = select(29.97f, current = uhd60, modes = listOf(uhd60, uhd50))
|
||||
assertNotNull(selection)
|
||||
assertEquals(uhd60, selection?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactRateBeatsMultipleRate() {
|
||||
val uhd30 = ModeInfo(9, 3840, 2160, 29.97f)
|
||||
val selection = select(29.97f, modes = allModes + uhd30)
|
||||
assertEquals(uhd30, selection?.mode)
|
||||
}
|
||||
|
||||
// --- matchRefreshRate ---
|
||||
|
||||
@Test
|
||||
fun refreshRateMatchClassifiesExactMultipleAndMiss() {
|
||||
assertEquals(0, DisplayModeSelector.matchRefreshRate(23.976f, 23.976f)?.priority)
|
||||
assertEquals(1, DisplayModeSelector.matchRefreshRate(59.94f, 29.97f)?.priority)
|
||||
assertNull(DisplayModeSelector.matchRefreshRate(60f, 23.976f))
|
||||
assertNull(DisplayModeSelector.matchRefreshRate(60f, 0f))
|
||||
assertNull(DisplayModeSelector.matchRefreshRate(0f, 24f))
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Teardown ordering for frame-rate matching (#2172): restoring the display
|
||||
* mode while the display is still signaling HDR folds the HDR exit and the
|
||||
* mode change into one slow HDMI renegotiation. An HDR session therefore
|
||||
* defers the restore; an SDR session restores immediately.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FrameRateManagerRestoreTest {
|
||||
|
||||
private fun buildManager(activity: Activity): FrameRateManager =
|
||||
FrameRateManager(activity, Handler(Looper.getMainLooper()))
|
||||
|
||||
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
|
||||
|
||||
private fun applyPreferredMode(activity: Activity, modeId: Int) {
|
||||
val attrs = activity.window.attributes
|
||||
attrs.preferredDisplayModeId = modeId
|
||||
activity.window.attributes = attrs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sdrClearRestoresTheDefaultModeImmediately() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = false)
|
||||
|
||||
assertEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrClearDefersTheRestoreUntilTheHdrExitSettles() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
|
||||
// Still at the content mode: the surface teardown must commit the HDR
|
||||
// exit before the mode change renegotiates the link.
|
||||
assertEquals(4, preferredModeId(activity))
|
||||
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
assertEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newSwitchRequestCancelsAPendingDeferredRestore() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 4)
|
||||
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
// A new session's request arrives before the deferred restore fires.
|
||||
manager.setVideoFrameRate(fps = 23.976f, videoDurationMs = 0L, extraDelayMs = 0L) { }
|
||||
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
// The stale restore must not clobber whatever the new request applied.
|
||||
// (Robolectric's display has no matching mode, so the request itself
|
||||
// leaves the window untouched; only the cancelled restore could zero it.)
|
||||
assertNotEquals(0, preferredModeId(activity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hdrClearWithNoAppliedModePostsNoRestore() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val manager = buildManager(activity)
|
||||
applyPreferredMode(activity, 7)
|
||||
|
||||
// Simulate "nothing applied by us": manager sees modeId 0.
|
||||
applyPreferredMode(activity, 0)
|
||||
manager.clearVideoFrameRate(hdrActive = true)
|
||||
|
||||
// A later, externally applied mode must not be clobbered by a stale
|
||||
// deferred restore from a session that never switched.
|
||||
applyPreferredMode(activity, 7)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
|
||||
assertEquals(7, preferredModeId(activity))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -61,18 +60,4 @@ class MediaCodecQueryTest {
|
||||
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
|
||||
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsGatedCodecsToTheMimeTypesDecodersAdvertise() {
|
||||
assertEquals(
|
||||
mapOf("hevc" to true, "av1" to true),
|
||||
MediaCodecQuery.hardwareVideoDecodeSupport(
|
||||
setOf("video/avc", "video/hevc", "video/av01", "audio/mp4a-latm")
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
mapOf("hevc" to true, "av1" to false),
|
||||
MediaCodecQuery.hardwareVideoDecodeSupport(setOf("video/avc", "video/hevc"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -42,27 +41,4 @@ class PlayerSurfaceHostTest {
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun letterboxAreaIsCoveredByABufferlessPunchSurfaceBeneathTheVideo() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
activity.setContentView(FrameLayout(activity))
|
||||
val callback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = Unit
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
|
||||
}
|
||||
|
||||
val container = PlayerSurfaceHost.createContainer(activity)
|
||||
val punch = container.getChildAt(0)
|
||||
assertTrue(punch is SurfaceView)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.width)
|
||||
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.height)
|
||||
|
||||
// Cores append their surfaces after createContainer, so video (and the mpv
|
||||
// OSD plane) always land above the punch layer in drawing order.
|
||||
val video = PlayerSurfaceHost.createVideoSurface(activity, callback)
|
||||
container.addView(video)
|
||||
assertTrue(container.indexOfChild(video) > container.indexOfChild(punch))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/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.
@@ -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.11.0")
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.10.1")
|
||||
implementation("androidx.media3:media3-ui:1.10.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
@@ -89,9 +89,6 @@ 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) }
|
||||
|
||||
+3
-49
@@ -313,9 +313,8 @@ internal class AssAtlasPipeline(
|
||||
|
||||
/**
|
||||
* Re-renders the last requested position — for renderer state changes (margins,
|
||||
* 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).
|
||||
* use-margins) 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()
|
||||
@@ -583,12 +582,6 @@ 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
|
||||
@@ -767,22 +760,7 @@ 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.
|
||||
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
|
||||
if (assHandler.render == null) return
|
||||
val slots = acquireSlots()
|
||||
val generation = stateGeneration()
|
||||
val engine = ensureEngine(slots, generation)
|
||||
@@ -812,7 +790,6 @@ private class AtlasLibassThread(
|
||||
payload.requestSeq = request.sequence
|
||||
payload.stateGeneration = generation
|
||||
onFrameReady(payload)
|
||||
hasEverPosted = true
|
||||
if (AssAtlasPipelineConfig.TIMING_LOGS) {
|
||||
Log.d(
|
||||
TAG,
|
||||
@@ -851,29 +828,6 @@ 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
|
||||
|
||||
|
||||
+1
-2
@@ -45,8 +45,7 @@ class AssSubtitleSurfaceView(
|
||||
pipeline?.requestRender(presentationTimeUs, releaseTimeNs)
|
||||
}
|
||||
|
||||
/** Re-renders the last position — margin changes while paused, or clearing a
|
||||
* still-visible cue right after subtitles were disabled (#1884). */
|
||||
/** Re-renders the last position, e.g. after margin changes while paused. */
|
||||
fun invalidateSubtitles() {
|
||||
pipeline?.invalidate()
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,10 +19,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/universal_gamepad/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
|
||||
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
|
||||
|
||||
PODFILE CHECKSUM: 0adb115f74a3cde46cf0e15649428c9b4e6975a0
|
||||
PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559
|
||||
|
||||
COCOAPODS: 1.17.0
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
|
||||
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
|
||||
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
|
||||
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -81,7 +80,6 @@
|
||||
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
|
||||
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
|
||||
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
|
||||
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecodeCapabilitiesPlugin.swift; sourceTree = "<group>"; };
|
||||
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -199,7 +197,6 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -433,7 +430,6 @@
|
||||
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
|
||||
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -801,8 +797,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/edde746/MPVKit";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
|
||||
kind = exactVersion;
|
||||
version = 1.0.20;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"state" : {
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
"revision" : "20f85eab51842b0167550ce5e65d0a59acd8aa7c",
|
||||
"version" : "1.0.20"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"state" : {
|
||||
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
|
||||
"revision" : "20f85eab51842b0167550ce5e65d0a59acd8aa7c",
|
||||
"version" : "1.0.20"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,10 +38,6 @@ import MediaPlayer
|
||||
MpvAudioPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "VideoDecodeCapabilitiesPlugin") {
|
||||
VideoDecodeCapabilitiesPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeviceAdjustmentChannel") {
|
||||
registerDeviceAdjustmentChannel(messenger: registrar.messenger())
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import CoreMedia
|
||||
import Flutter
|
||||
import VideoToolbox
|
||||
|
||||
/// Answers `com.plezy/device`'s `getVideoDecodeCapabilities` on iOS and tvOS.
|
||||
///
|
||||
/// Compiled into the iOS and tvOS Runners only — macOS keeps its own sources
|
||||
/// and is deliberately left unprobed, because desktop CPUs software-decode
|
||||
/// both codecs in real time and narrowing the profile there would force
|
||||
/// transcodes for nothing.
|
||||
class VideoDecodeCapabilitiesPlugin: NSObject, FlutterPlugin {
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.plezy/device",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
registrar.addMethodCallDelegate(VideoDecodeCapabilitiesPlugin(), channel: channel)
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "getVideoDecodeCapabilities":
|
||||
result([
|
||||
"hevc": VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC),
|
||||
"av1": VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1),
|
||||
])
|
||||
default:
|
||||
// The rest of `com.plezy/device` is Android-only.
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,50 @@ import '../services/plex_auth_service.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import '../utils/url_utils.dart';
|
||||
|
||||
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
|
||||
/// [MediaBackend] for places that only care about persistence/auth shape
|
||||
/// (e.g. database column values).
|
||||
enum ConnectionKind {
|
||||
plex,
|
||||
jellyfin,
|
||||
emby;
|
||||
|
||||
String get id => switch (this) {
|
||||
ConnectionKind.plex => 'plex',
|
||||
ConnectionKind.jellyfin => 'jellyfin',
|
||||
ConnectionKind.emby => 'emby',
|
||||
};
|
||||
|
||||
static ConnectionKind fromId(String id) => switch (id) {
|
||||
'plex' => ConnectionKind.plex,
|
||||
'jellyfin' => ConnectionKind.jellyfin,
|
||||
'emby' => ConnectionKind.emby,
|
||||
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
ConnectionKind.plex => MediaBackend.plex,
|
||||
ConnectionKind.jellyfin => MediaBackend.jellyfin,
|
||||
ConnectionKind.emby => MediaBackend.emby,
|
||||
};
|
||||
|
||||
/// The MediaBrowser dialect this kind speaks, or `null` for Plex.
|
||||
MediaBrowserDialect? get dialect => switch (this) {
|
||||
ConnectionKind.plex => null,
|
||||
ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin,
|
||||
ConnectionKind.emby => MediaBrowserDialect.emby,
|
||||
};
|
||||
|
||||
static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) {
|
||||
MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin,
|
||||
MediaBrowserDialect.emby => ConnectionKind.emby,
|
||||
};
|
||||
}
|
||||
|
||||
/// Health snapshot for a connection. Updated by the orchestrator each time a
|
||||
/// session is established or refreshed.
|
||||
enum ConnectionStatus { unknown, online, offline, authError, disabled }
|
||||
|
||||
/// A media server connection — a unit of authentication the user added.
|
||||
///
|
||||
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
|
||||
@@ -12,14 +56,15 @@ import '../utils/url_utils.dart';
|
||||
/// user. Most users only ever add one connection.
|
||||
sealed class Connection {
|
||||
String get id;
|
||||
MediaBackend get kind;
|
||||
ConnectionKind get kind;
|
||||
String get displayName;
|
||||
ConnectionStatus get status;
|
||||
DateTime get createdAt;
|
||||
DateTime? get lastAuthenticatedAt;
|
||||
|
||||
/// Alias for [kind] kept for UI call sites that read "backend"
|
||||
/// (badges, etc.).
|
||||
MediaBackend get backend => kind;
|
||||
/// Backend kind as a [MediaBackend] — for UI that branches on backend
|
||||
/// (badges, etc.). Just a passthrough to [kind.backend].
|
||||
MediaBackend get backend => kind.backend;
|
||||
|
||||
/// Primary label shown in connection-list UIs. Plex shows the active
|
||||
/// profile/account name; Jellyfin shows the server name.
|
||||
@@ -45,6 +90,9 @@ class PlexAccountConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@@ -75,12 +123,13 @@ class PlexAccountConnection extends Connection {
|
||||
required this.accountLabel,
|
||||
this.activeProfile,
|
||||
this.servers = const [],
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
MediaBackend get kind => MediaBackend.plex;
|
||||
ConnectionKind get kind => ConnectionKind.plex;
|
||||
|
||||
@override
|
||||
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
|
||||
@@ -101,6 +150,7 @@ class PlexAccountConnection extends Connection {
|
||||
PlexHomeUser? activeProfile,
|
||||
bool clearActiveProfile = false,
|
||||
List<PlexServer>? servers,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
@@ -111,6 +161,7 @@ class PlexAccountConnection extends Connection {
|
||||
accountLabel: accountLabel ?? this.accountLabel,
|
||||
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
|
||||
servers: servers ?? this.servers,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
@@ -130,6 +181,7 @@ class PlexAccountConnection extends Connection {
|
||||
factory PlexAccountConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
@@ -146,6 +198,7 @@ class PlexAccountConnection extends Connection {
|
||||
accountLabel: json['accountLabel'] as String? ?? 'Plex',
|
||||
activeProfile: activeProfile,
|
||||
servers: servers,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
@@ -159,6 +212,9 @@ class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@@ -220,13 +276,14 @@ class JellyfinConnection extends Connection {
|
||||
this.dialect = MediaBrowserDialect.jellyfin,
|
||||
this.isAdministrator = false,
|
||||
this.primaryImageTag,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
}) : baseUrl = canonicalizeBaseUrl(baseUrl),
|
||||
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
|
||||
|
||||
@override
|
||||
MediaBackend get kind => dialect.backend;
|
||||
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
|
||||
|
||||
@override
|
||||
String get displayName => '$userName · $serverName';
|
||||
@@ -281,6 +338,7 @@ class JellyfinConnection extends Connection {
|
||||
/// a refresh must be able to null the cached value — a bare
|
||||
/// `primaryImageTag: null` is indistinguishable from "unchanged".
|
||||
bool clearPrimaryImageTag = false,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
@@ -298,6 +356,7 @@ class JellyfinConnection extends Connection {
|
||||
dialect: dialect ?? this.dialect,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
@@ -325,6 +384,7 @@ class JellyfinConnection extends Connection {
|
||||
factory JellyfinConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
@@ -348,6 +408,7 @@ class JellyfinConnection extends Connection {
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
isAdministrator: json['isAdministrator'] as bool? ?? false,
|
||||
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
import 'connection_registry.dart';
|
||||
import 'plex_account_setup.dart';
|
||||
|
||||
/// One-shot helpers that bridge between the legacy single-Plex-account
|
||||
/// SharedPreferences state (`StorageService.plexToken` +
|
||||
@@ -98,12 +97,25 @@ class ConnectionBootstrap {
|
||||
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
|
||||
|
||||
try {
|
||||
// The dev seed always keys its row by the device client id and aborts
|
||||
// on an identity-lookup failure — a bad PLEX_TOKEN must not seed a
|
||||
// mislabelled row.
|
||||
final build = await buildPlexAccountConnection(devToken, keyByAccountUuid: false, tolerateUserInfoFailure: false);
|
||||
await connectionRegistry.upsert(build.connection);
|
||||
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${build.connection.id}');
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final info = await auth.getUserInfo(devToken);
|
||||
final servers = await auth.fetchServers(devToken);
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.$clientId',
|
||||
accountToken: devToken,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex',
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}');
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st);
|
||||
}
|
||||
@@ -126,17 +138,31 @@ class ConnectionBootstrap {
|
||||
}
|
||||
|
||||
try {
|
||||
final build = await buildPlexAccountConnection(
|
||||
token,
|
||||
clientIdentifier: await storage.getOrCreateClientIdentifier(),
|
||||
fetchUserInfo: _plexUserInfoFetcher,
|
||||
// The migration wraps the cached legacy server list; a network server
|
||||
// fetch could fail exactly when the migration must succeed offline.
|
||||
fetchServers: (_) => serverRegistry.getServers(),
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final servers = await serverRegistry.getServers();
|
||||
|
||||
String accountLabel = 'Plex';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await _plexUserInfoFetcher(token);
|
||||
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
appLogger.d('Plex migration: account label lookup failed (using fallback): $e');
|
||||
}
|
||||
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
|
||||
accountToken: token,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: accountLabel,
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(build.connection);
|
||||
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${build.connection.id}');
|
||||
return build.connection;
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}');
|
||||
return conn;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Plex account migration failed', error: e, stackTrace: st);
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../services/credential_vault.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
@@ -13,23 +11,13 @@ import 'connection.dart';
|
||||
/// CRUD over the persisted [Connections] table. The registry is the source
|
||||
/// of truth for which connections the user has added; the runtime
|
||||
/// `MultiServerManager` populates per-server clients from these records.
|
||||
///
|
||||
/// Single-connection users get a default automatically — power users with
|
||||
/// multiple connections can override it via [setDefault].
|
||||
class ConnectionRegistry {
|
||||
ConnectionRegistry(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
static const DeepCollectionEquality _configEquality = DeepCollectionEquality();
|
||||
|
||||
final Expando<Map<String, Object?>> _decryptedConfigs = Expando<Map<String, Object?>>(
|
||||
'ConnectionRegistry.decryptedConfigs',
|
||||
);
|
||||
|
||||
/// Compares the decrypted persisted config projections retained while rows
|
||||
/// are decoded. The fallback covers models supplied outside this registry.
|
||||
bool hasSameConfig(Connection a, Connection b) {
|
||||
final aConfig = _decryptedConfigs[a] ??= a.toConfigJson();
|
||||
final bConfig = _decryptedConfigs[b] ??= b.toConfigJson();
|
||||
return _configEquality.equals(aConfig, bConfig);
|
||||
}
|
||||
|
||||
/// Emits the current set of connections after every mutation. Drift's
|
||||
/// `watch()` provides this for free.
|
||||
@@ -52,19 +40,33 @@ class ConnectionRegistry {
|
||||
return _rowToConnection(row);
|
||||
}
|
||||
|
||||
/// Insert or replace [connection]. Re-upserting an existing row keeps the
|
||||
/// row's current `createdAt` (so token/metadata refreshes don't restamp
|
||||
/// creation order).
|
||||
/// Insert or replace [connection]. If this is the first stored connection
|
||||
/// it is automatically marked default; re-upserting an existing row keeps
|
||||
/// the row's current `isDefault` and `createdAt` (so token/metadata
|
||||
/// refreshes don't clear the default flag or restamp creation order).
|
||||
///
|
||||
/// Creation order is behaviour, not bookkeeping: it decides which
|
||||
/// connection lends a profile its picture. Re-authenticating rebuilds the
|
||||
/// model with `DateTime.now()` and reuses the same stable id, so without
|
||||
/// this the originally-first connection would jump to last on every
|
||||
/// re-sign-in.
|
||||
/// connection lends a profile its picture, and `remove` promotes the oldest
|
||||
/// remaining row to default. Re-authenticating rebuilds the model with
|
||||
/// `DateTime.now()` and reuses the same stable id, so without this the
|
||||
/// originally-first connection would jump to last on every re-sign-in.
|
||||
Future<void> upsert(Connection connection) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
|
||||
final createdAt = existing?.createdAt ?? connection.createdAt.millisecondsSinceEpoch;
|
||||
final bool isDefault;
|
||||
final int createdAt;
|
||||
if (existing != null) {
|
||||
isDefault = existing.isDefault;
|
||||
createdAt = existing.createdAt;
|
||||
} else {
|
||||
final any =
|
||||
await (_db.selectOnly(_db.connections)
|
||||
..addColumns([_db.connections.id])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
isDefault = any == null;
|
||||
createdAt = connection.createdAt.millisecondsSinceEpoch;
|
||||
}
|
||||
final protectedConfig = await CredentialVault.protectConnectionConfig(
|
||||
connection.kind.id,
|
||||
connection.toConfigJson(),
|
||||
@@ -74,6 +76,7 @@ class ConnectionRegistry {
|
||||
kind: Value(connection.kind.id),
|
||||
displayName: Value(connection.displayName),
|
||||
configJson: Value(jsonEncode(protectedConfig)),
|
||||
isDefault: Value(isDefault),
|
||||
createdAt: Value(createdAt),
|
||||
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
|
||||
);
|
||||
@@ -82,14 +85,44 @@ class ConnectionRegistry {
|
||||
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
|
||||
}
|
||||
|
||||
/// Remove a stored connection.
|
||||
/// Remove a stored connection. If the removed row was the default, the
|
||||
/// oldest remaining connection (if any) becomes default.
|
||||
Future<void> remove(String id) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
|
||||
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
}
|
||||
});
|
||||
appLogger.d('ConnectionRegistry: removed $id');
|
||||
}
|
||||
|
||||
/// Set [id] as the default connection. Clears the flag on all others.
|
||||
Future<void> setDefault(String id) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await _db.transaction(() async {
|
||||
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Update only the auth-related metadata on an existing row (token,
|
||||
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
|
||||
/// silent refresh without touching the rest of the config.
|
||||
Future<void> recordAuthSuccess(String id, DateTime at) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
|
||||
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
await _db.delete(_db.connections).go();
|
||||
@@ -114,28 +147,29 @@ class ConnectionRegistry {
|
||||
Future<Connection?> _rowToConnection(ConnectionRow row) async {
|
||||
try {
|
||||
final json = jsonDecode(row.configJson) as Map<String, dynamic>;
|
||||
final kind = MediaBackend.fromId(row.kind);
|
||||
final kind = ConnectionKind.fromId(row.kind);
|
||||
final revealed = await CredentialVault.revealConnectionConfig(kind.id, json);
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt);
|
||||
final lastAuth = row.lastAuthenticatedAt == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!);
|
||||
final connection = switch (kind) {
|
||||
MediaBackend.plex => PlexAccountConnection.fromConfigJson(
|
||||
ConnectionKind.plex => PlexAccountConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
MediaBackend.jellyfin || MediaBackend.emby => JellyfinConnection.fromConfigJson(
|
||||
ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
dialect: kind.dialect!,
|
||||
),
|
||||
};
|
||||
_decryptedConfigs[connection] = revealed.config;
|
||||
if (revealed.migrated) {
|
||||
await upsert(connection);
|
||||
}
|
||||
|
||||
@@ -19,77 +19,6 @@ typedef PlexAccountRegistration = ({
|
||||
String email,
|
||||
});
|
||||
|
||||
/// Result of [buildPlexAccountConnection]: the assembled connection plus the
|
||||
/// raw identity fields callers fold into their own records and dedup scans.
|
||||
typedef PlexAccountBuild = ({PlexAccountConnection connection, String username, String email, String accountUuid});
|
||||
|
||||
/// Shared token → [PlexAccountConnection] pipeline: resolve the plex.tv
|
||||
/// account identity, list the account's servers, and assemble a connection
|
||||
/// row stamped "now". [registerPlexAccountFromToken] and both
|
||||
/// [ConnectionBootstrap] entry points (the PLEX_TOKEN dev seed and the
|
||||
/// legacy-token migration) funnel through here so the id/label policy can't
|
||||
/// drift. Each caller keeps its delta via the knobs:
|
||||
///
|
||||
/// - [clientIdentifier] is persisted on the row and keys it when the account
|
||||
/// uuid is unavailable. `null` uses the short-lived [PlexAuthService]'s own
|
||||
/// per-device id (the same [StorageService]-backed value).
|
||||
/// - [fetchUserInfo]/[fetchServers] override the identity/server sources —
|
||||
/// the legacy migration injects a test seam and the cached legacy server
|
||||
/// list. When both are given (and [clientIdentifier] is set) no
|
||||
/// [PlexAuthService] is created at all.
|
||||
/// - [keyByAccountUuid]: the dev seed keys its row by client id even when
|
||||
/// the account uuid is known.
|
||||
/// - [tolerateUserInfoFailure]: sign-in and migration fall back to a
|
||||
/// client-id-keyed row when the identity lookup fails; the dev seed aborts
|
||||
/// instead of seeding a mislabelled row.
|
||||
Future<PlexAccountBuild> buildPlexAccountConnection(
|
||||
String token, {
|
||||
String? clientIdentifier,
|
||||
Future<Map<String, dynamic>> Function(String token)? fetchUserInfo,
|
||||
Future<List<PlexServer>> Function(String token)? fetchServers,
|
||||
bool keyByAccountUuid = true,
|
||||
bool tolerateUserInfoFailure = true,
|
||||
}) async {
|
||||
PlexAuthService? auth;
|
||||
try {
|
||||
if (clientIdentifier == null || fetchUserInfo == null || fetchServers == null) {
|
||||
auth = await PlexAuthService.create();
|
||||
}
|
||||
// Account identity from plex.tv — the uuid is what makes multi-account
|
||||
// work (the clientIdentifier is per-device and identical for every Plex
|
||||
// account on this install). Falls back to the client identifier only
|
||||
// when the user-info call fails outright; a later successful sign-in
|
||||
// migrates that legacy row via [_migrateLegacyClientIdRow].
|
||||
String username = '';
|
||||
String email = '';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await (fetchUserInfo ?? auth!.getUserInfo)(token);
|
||||
username = (info['username'] as String?) ?? '';
|
||||
email = (info['email'] as String?) ?? '';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
if (!tolerateUserInfoFailure) rethrow;
|
||||
appLogger.d('Plex user-info lookup failed (using fallback identity): $e');
|
||||
}
|
||||
|
||||
final servers = await (fetchServers ?? auth!.fetchServers)(token);
|
||||
final clientId = clientIdentifier ?? auth!.clientIdentifier;
|
||||
final connection = PlexAccountConnection(
|
||||
id: 'plex.${keyByAccountUuid && accountUuid.isNotEmpty ? accountUuid : clientId}',
|
||||
accountToken: token,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
return (connection: connection, username: username, email: email, accountUuid: accountUuid);
|
||||
} finally {
|
||||
auth?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared post-auth pipeline for a fresh plex.tv [token]: resolve the
|
||||
/// account identity, persist the [PlexAccountConnection] (folding in a
|
||||
/// legacy client-id-keyed row from an earlier failed identity lookup), and
|
||||
@@ -102,36 +31,66 @@ Future<PlexAccountRegistration> registerPlexAccountFromToken({
|
||||
required StorageService storage,
|
||||
required PlexHomeService plexHome,
|
||||
}) async {
|
||||
final build = await buildPlexAccountConnection(token);
|
||||
final connection = build.connection;
|
||||
final legacyId = 'plex.${connection.clientIdentifier}';
|
||||
final existedBefore =
|
||||
await connections.get(connection.id) != null ||
|
||||
(build.accountUuid.isNotEmpty &&
|
||||
legacyId != connection.id &&
|
||||
await connections.get(legacyId) is PlexAccountConnection);
|
||||
await connections.upsert(connection);
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
// Account identity from plex.tv — the uuid is what makes multi-account
|
||||
// work (the clientIdentifier is per-device and identical for every Plex
|
||||
// account on this install). Falls back to the client identifier only
|
||||
// when the user-info call fails outright; a later successful sign-in
|
||||
// migrates that legacy row below.
|
||||
String username = '';
|
||||
String email = '';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await auth.getUserInfo(token);
|
||||
username = (info['username'] as String?) ?? '';
|
||||
email = (info['email'] as String?) ?? '';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
appLogger.d('getUserInfo after Plex sign-in failed (using fallback identity): $e');
|
||||
}
|
||||
|
||||
if (build.accountUuid.isNotEmpty) {
|
||||
await _migrateLegacyClientIdRow(
|
||||
replacement: connection,
|
||||
clientIdentifier: connection.clientIdentifier,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
final servers = await auth.fetchServers(token);
|
||||
final connection = PlexAccountConnection(
|
||||
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}',
|
||||
accountToken: token,
|
||||
clientIdentifier: auth.clientIdentifier,
|
||||
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
final legacyId = 'plex.${auth.clientIdentifier}';
|
||||
final existedBefore =
|
||||
await connections.get(connection.id) != null ||
|
||||
(accountUuid.isNotEmpty &&
|
||||
legacyId != connection.id &&
|
||||
await connections.get(legacyId) is PlexAccountConnection);
|
||||
await connections.upsert(connection);
|
||||
|
||||
// Fetch home users now so pickers surface the account's virtual
|
||||
// profiles immediately.
|
||||
final homeUsersFetched = await plexHome.refresh(connection);
|
||||
return (
|
||||
connection: connection,
|
||||
homeUsersFetched: homeUsersFetched,
|
||||
existedBefore: existedBefore,
|
||||
username: build.username,
|
||||
email: build.email,
|
||||
);
|
||||
if (accountUuid.isNotEmpty) {
|
||||
await _migrateLegacyClientIdRow(
|
||||
replacement: connection,
|
||||
clientIdentifier: auth.clientIdentifier,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch home users now so pickers surface the account's virtual
|
||||
// profiles immediately.
|
||||
final homeUsersFetched = await plexHome.refresh(connection);
|
||||
return (
|
||||
connection: connection,
|
||||
homeUsersFetched: homeUsersFetched,
|
||||
existedBefore: existedBefore,
|
||||
username: username,
|
||||
email: email,
|
||||
);
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// A row keyed by the per-device client identifier (created when an earlier
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Dense BMP ranks per DUCET (Unicode 13.0) + pinned CLDR CJK radical-stroke.
|
||||
/// Each code unit stores rank + 1; zero means absent.
|
||||
/// Katakana sorts before hiragana (CLDR root tailoring).
|
||||
/// Generated by scripts/codegen/generate_ducet_ranks.dart — do not edit.
|
||||
/// Generated by scripts/generate_ducet_ranks.dart — do not edit.
|
||||
library;
|
||||
|
||||
const String _ducetRanks =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by dart run scripts/codegen/generate_hid_key_labels.dart from scripts/codegen/data/hid_key_labels.json; do not edit by hand.
|
||||
// Generated by dart run scripts/generate_hid_key_labels.dart from scripts/data/hid_key_labels.json; do not edit by hand.
|
||||
|
||||
/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.
|
||||
const hidKeyLabels = <int, String>{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by dart run scripts/codegen/generate_iso_639_data.dart from scripts/codegen/data/iso_639_codes.json; do not edit by hand.
|
||||
// Generated by dart run scripts/generate_iso_639_data.dart from scripts/data/iso_639_codes.json; do not edit by hand.
|
||||
|
||||
class LanguageEntry {
|
||||
final String code1;
|
||||
|
||||
@@ -37,6 +37,15 @@ enum OfflineActionType {
|
||||
OfflineActionType.watched => 'watched',
|
||||
OfflineActionType.unwatched => 'unwatched',
|
||||
};
|
||||
|
||||
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
|
||||
/// silently fall back to the wrong action.
|
||||
static OfflineActionType fromId(String id) => switch (id) {
|
||||
'progress' => OfflineActionType.progress,
|
||||
'watched' => OfflineActionType.watched,
|
||||
'unwatched' => OfflineActionType.unwatched,
|
||||
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
|
||||
};
|
||||
}
|
||||
|
||||
final class AppDatabaseBootstrap {
|
||||
@@ -58,7 +67,6 @@ final class AppDatabaseBootstrap {
|
||||
Connections,
|
||||
Profiles,
|
||||
ProfileConnections,
|
||||
MusicSessions,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -73,6 +81,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase._withRecovery(super.e, this._recoveryStore);
|
||||
|
||||
final TvosDatabaseRecoveryStore? _recoveryStore;
|
||||
final SerialFutureQueue _durabilityQueue = SerialFutureQueue();
|
||||
static final Object _durabilityZoneKey = Object();
|
||||
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
|
||||
|
||||
@@ -147,10 +156,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
final store = _recoveryStore;
|
||||
if (store == null || !store.isTvos) return Future<void>.value();
|
||||
|
||||
return _tvosRecoveryQueue.run(
|
||||
() => store.acknowledgeRecoveryRequired(
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
return _durabilityQueue.run(
|
||||
() => _tvosRecoveryQueue.run(
|
||||
() => store.acknowledgeRecoveryRequired(
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -164,15 +175,17 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (store == null || !store.isTvos) return mutation();
|
||||
if (Zone.current[_durabilityZoneKey] == this) return mutation();
|
||||
|
||||
return _tvosRecoveryQueue.run(
|
||||
() => runZoned(
|
||||
() => store.runDurableMutation(
|
||||
group: group,
|
||||
mutation: mutation,
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
return _durabilityQueue.run(
|
||||
() => _tvosRecoveryQueue.run(
|
||||
() => runZoned(
|
||||
() => store.runDurableMutation(
|
||||
group: group,
|
||||
mutation: mutation,
|
||||
readIdentity: _readProtectedIdentityRecoveryRows,
|
||||
readPending: _readPendingRecoveryRows,
|
||||
),
|
||||
zoneValues: {_durabilityZoneKey: this},
|
||||
),
|
||||
zoneValues: {_durabilityZoneKey: this},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -311,14 +324,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
/// Columns that once existed in a snapshotted table and may still appear in
|
||||
/// committed recovery images written by older builds. They are stripped
|
||||
/// before the strict round-trip check in [_decodeRecoveryRow] so retiring a
|
||||
/// column does not brick restore on devices holding a pre-retirement image.
|
||||
static const Map<String, Set<String>> _retiredRecoveryColumns = {
|
||||
'connections': {'isDefault'},
|
||||
};
|
||||
|
||||
static List<T> _decodeRecoveryRows<T extends DataClass>(
|
||||
Map<String, Object?> group,
|
||||
String key,
|
||||
@@ -326,21 +331,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
) {
|
||||
final value = group[key];
|
||||
if (value is! List) throw _invalidRecoveryImage;
|
||||
final retired = _retiredRecoveryColumns[key];
|
||||
return [
|
||||
for (final row in value)
|
||||
if (row is Map<String, dynamic>)
|
||||
_decodeRecoveryRow(
|
||||
retired == null
|
||||
? row
|
||||
: {
|
||||
for (final entry in row.entries)
|
||||
if (!retired.contains(entry.key)) entry.key: entry.value,
|
||||
},
|
||||
fromJson,
|
||||
)
|
||||
else
|
||||
throw _invalidRecoveryImage,
|
||||
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -366,7 +359,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
|
||||
|
||||
@override
|
||||
int get schemaVersion => 22;
|
||||
int get schemaVersion => 20;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -732,14 +725,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
() => m.create(idxSyncRuleDownloadsProfileKey),
|
||||
);
|
||||
}
|
||||
if (from < 21) {
|
||||
appLogger.i('Dropping unused Connections.isDefault column (v21 migration)');
|
||||
await m.alterTable(TableMigration(connections));
|
||||
}
|
||||
if (from < 22) {
|
||||
appLogger.i('Adding MusicSessions table (v22 migration)');
|
||||
await _ignoreAlreadyExists('MusicSessions table', () => m.createTable(musicSessions));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1072,6 +1057,25 @@ class AppDatabase extends _$AppDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete a specific watch action outside a snapshotted replay.
|
||||
Future<void> deleteWatchAction(int id) {
|
||||
return _runPendingMutation(() async {
|
||||
await (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go();
|
||||
});
|
||||
}
|
||||
|
||||
/// Update retry state outside a snapshotted replay.
|
||||
Future<void> updateSyncAttempt(int id, String? errorMessage) {
|
||||
return _runPendingMutation(() async {
|
||||
final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
if (existing == null) return;
|
||||
|
||||
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
|
||||
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get count of pending sync items
|
||||
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
|
||||
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
|
||||
@@ -1099,38 +1103,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Music session persistence (#2148)
|
||||
// ===========================================================================
|
||||
|
||||
/// Full snapshot write: replaces the profile's persisted music session.
|
||||
Future<void> upsertMusicSession(MusicSessionRow row) {
|
||||
return into(musicSessions).insertOnConflictUpdate(row);
|
||||
}
|
||||
|
||||
/// Cheap write-through for playhead/cursor changes — leaves the (possibly
|
||||
/// large) queue JSON untouched. No-op when no snapshot row exists.
|
||||
Future<void> updateMusicSessionProgress({
|
||||
required String profileId,
|
||||
required int cursor,
|
||||
required int positionMs,
|
||||
required int updatedAt,
|
||||
}) async {
|
||||
await (update(musicSessions)..where((t) => t.profileId.equals(profileId))).write(
|
||||
MusicSessionsCompanion(cursor: Value(cursor), positionMs: Value(positionMs), updatedAt: Value(updatedAt)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<MusicSessionRow?> getMusicSession(String profileId) {
|
||||
return (select(musicSessions)..where((t) => t.profileId.equals(profileId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Drop a profile's persisted music session (user session end or profile
|
||||
/// teardown).
|
||||
Future<void> deleteMusicSessionForProfile(String profileId) async {
|
||||
await (delete(musicSessions)..where((t) => t.profileId.equals(profileId))).go();
|
||||
}
|
||||
|
||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||
final query = select(syncRules);
|
||||
if (profileId != null) {
|
||||
@@ -1303,6 +1275,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
|
||||
|
||||
Future<void> completeSyncRuleExecution(String globalKey) {
|
||||
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(
|
||||
|
||||
+138
-1017
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,10 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getDownloadOwnerCount(String globalKey) async {
|
||||
return (await _validDownloadOwnerRows(globalKey)).length;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
||||
@@ -186,8 +190,8 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// inherit them.
|
||||
///
|
||||
/// Runs on every profile switch — validity context is computed once and
|
||||
/// applied in memory instead of a per-download full-table rescan through
|
||||
/// `_validDownloadOwnerRows`.
|
||||
/// applied in memory instead of the per-download full-table rescan
|
||||
/// `getDownloadOwnerCount` would do.
|
||||
Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
|
||||
if (profileId.isEmpty) return;
|
||||
if (isStillActive != null && !isStillActive()) return;
|
||||
@@ -433,20 +437,14 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
|
||||
/// Get next item from queue (highest priority, oldest first)
|
||||
/// Only returns items that are not paused.
|
||||
///
|
||||
/// [excludedGlobalKeys] filters out heads the caller already tried and could
|
||||
/// not resolve, so one stale row cannot starve the rest of the queue.
|
||||
Future<DownloadQueueItem?> getNextQueueItem({Set<String> excludedGlobalKeys = const {}}) async {
|
||||
/// Only returns items that are not paused
|
||||
Future<DownloadQueueItem?> getNextQueueItem() async {
|
||||
final query = select(
|
||||
downloadQueue,
|
||||
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
||||
|
||||
query.where(downloadedMedia.status.equals(DownloadStatus.queued.index));
|
||||
if (excludedGlobalKeys.isNotEmpty) {
|
||||
query.where(downloadQueue.mediaGlobalKey.isNotIn(excludedGlobalKeys.toList(growable: false)));
|
||||
}
|
||||
query
|
||||
..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
|
||||
..orderBy([
|
||||
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: downloadQueue.addedAt),
|
||||
|
||||
@@ -9,14 +9,12 @@ class ApiCache extends Table {
|
||||
|
||||
TextColumn get data => text()();
|
||||
|
||||
/// When the row was last written ([ApiCacheSingleton.put] stamps it on
|
||||
/// every store). Read by the fresh-cache-first playback metadata gate,
|
||||
/// [ApiCacheSingleton.getIfFresh].
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
/// Whether this item is pinned for offline access
|
||||
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp for cache invalidation (optional future use)
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -152,6 +150,10 @@ class Connections extends Table {
|
||||
/// Backend-specific config payload (token, baseUrl, profile id, …).
|
||||
TextColumn get configJson => text()();
|
||||
|
||||
/// Whether this is the default connection used at app launch when only
|
||||
/// one connection is present.
|
||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp this connection was added (milliseconds since epoch).
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
@@ -282,45 +284,3 @@ class OfflineWatchProgress extends Table {
|
||||
/// Last sync error message
|
||||
TextColumn get lastError => text().nullable()();
|
||||
}
|
||||
|
||||
/// Last music session per profile, restored paused on the next launch (#2148).
|
||||
///
|
||||
/// One row per profile: the serialized queue plus enough arrangement state to
|
||||
/// rebuild it faithfully (canonical order, shuffle permutation, cursor, modes)
|
||||
/// and the playhead. Written through during playback (throttled position
|
||||
/// updates, full rewrites on queue-shape changes) because mobile gives no
|
||||
/// termination hook; cleared when the user visibly ends the session.
|
||||
@DataClassName('MusicSessionRow')
|
||||
class MusicSessions extends Table {
|
||||
/// Active Plezy profile that owns this session snapshot.
|
||||
TextColumn get profileId => text()();
|
||||
|
||||
/// Canonical queue tracks (insertion order) as a JSON array of MediaItem
|
||||
/// JSON — self-contained so restore never depends on volatile cache rows.
|
||||
TextColumn get queueJson => text()();
|
||||
|
||||
/// Playback-order permutation into [queueJson] as a JSON int array
|
||||
/// (identity while unshuffled).
|
||||
TextColumn get orderJson => text()();
|
||||
|
||||
/// Position of the current track within the playback order.
|
||||
IntColumn get cursor => integer()();
|
||||
|
||||
BoolColumn get shuffled => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Stable repeat-mode id ('off' | 'all' | 'one') — not the enum `.name`.
|
||||
TextColumn get repeatMode => text().withDefault(const Constant('off'))();
|
||||
|
||||
/// Play-context provenance ("Playing from …"); kind is a stable id.
|
||||
TextColumn get contextTitle => text().nullable()();
|
||||
TextColumn get contextKind => text().nullable()();
|
||||
|
||||
/// Playhead within the current track in milliseconds.
|
||||
IntColumn get positionMs => integer().withDefault(const Constant(0))();
|
||||
|
||||
/// Timestamp of the last write (milliseconds since epoch).
|
||||
IntColumn get updatedAt => integer()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {profileId};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class MediaServerAuthException extends MediaServerException {
|
||||
/// Auth polling reached a terminal server-side expiry/rejection state before
|
||||
/// the user completed the external sign-in flow.
|
||||
class MediaServerPinExpiredException extends MediaServerAuthException {
|
||||
const MediaServerPinExpiredException({super.display}) : super('PIN expired before sign-in');
|
||||
const MediaServerPinExpiredException({String? display}) : super('PIN expired before sign-in', display: display);
|
||||
}
|
||||
|
||||
/// HTTP transport / non-2xx errors. Carries the status code (when known),
|
||||
@@ -57,11 +57,11 @@ class MediaServerHttpException extends MediaServerException {
|
||||
MediaServerHttpException({
|
||||
required this.type,
|
||||
String? message,
|
||||
super.display,
|
||||
String? display,
|
||||
this.statusCode,
|
||||
this.responseData,
|
||||
this.requestUri,
|
||||
}) : super(message ?? '');
|
||||
}) : super(message ?? '', display: display);
|
||||
|
||||
/// Map a caught exception to a [MediaServerHttpException].
|
||||
factory MediaServerHttpException.from(Object error, {Uri? uri}) {
|
||||
@@ -118,36 +118,3 @@ class MediaServerHttpException extends MediaServerException {
|
||||
return 'MediaServerHttpException(${parts.join(': ')})';
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend already has a recording scheduled for the requested airing.
|
||||
///
|
||||
/// Plex signals duplicates with a bare 409, which UI maps by status code.
|
||||
/// Jellyfin/Emby answer `POST /LiveTv/Timers` duplicates with a 400 that is
|
||||
/// indistinguishable from a malformed request by status alone — only the DVR
|
||||
/// adapter knows that call site's semantics, so it rethrows this type and UI
|
||||
/// maps it to the "already scheduled" outcome without a backend check.
|
||||
class RecordingConflictException extends MediaServerException {
|
||||
const RecordingConflictException(super.message, {super.display});
|
||||
}
|
||||
|
||||
/// The server explicitly terminated the client's playback session (admin
|
||||
/// "stop stream", paused-too-long auto-termination, concurrent-stream limit).
|
||||
///
|
||||
/// Plex signals this with `terminationCode`/`terminationText` on the
|
||||
/// MediaContainer of a timeline report response. The MediaBrowser backends
|
||||
/// deliver admin stops as a WebSocket remote-control command Plezy does not
|
||||
/// subscribe to, so they never throw this — intentionally unsupported there.
|
||||
///
|
||||
/// Progress reporting must stop when this is thrown: continuing the heartbeat
|
||||
/// loop re-registers the session server-side as a zombie row the admin can no
|
||||
/// longer clear (#1916).
|
||||
class PlaybackSessionTerminatedException extends MediaServerException {
|
||||
/// Server-defined termination code (e.g. Plex 2006 for an admin stop).
|
||||
final int code;
|
||||
|
||||
/// Human-readable server-supplied reason; may carry an admin message.
|
||||
final String? reason;
|
||||
|
||||
PlaybackSessionTerminatedException({required this.code, this.reason})
|
||||
: super('Server terminated playback session (code $code)${reason == null ? '' : ': $reason'}');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -71,73 +70,29 @@ extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
|
||||
}
|
||||
|
||||
/// Base class for suppressing key events after a key category triggers an
|
||||
/// action (e.g. opening a sheet). While suppressed, every event of the matched
|
||||
/// key category is consumed — including a [KeyDownEvent] when
|
||||
/// [consumeKeyDowns] is set, because an armer running in the
|
||||
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
|
||||
/// very KeyDown that is then re-dispatched through the focus tree.
|
||||
///
|
||||
/// Suppression ends with the physical press: a global [HardwareKeyboard]
|
||||
/// observer watches for the matching [KeyUpEvent] and clears the armed state
|
||||
/// in a microtask — after that KeyUp's own synchronous focus dispatch, so
|
||||
/// focus-phase consumers still swallow it — which guarantees a stale armed
|
||||
/// state (a KeyUp delivered to a focus chain that never consulted us) can
|
||||
/// never swallow the next press.
|
||||
///
|
||||
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
|
||||
/// matching [KeyDownEvent] as proof the suppressed press already ended without
|
||||
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
|
||||
/// state is cleared and the fresh press passes through unconsumed.
|
||||
/// Base class for suppressing key-up events after a key category triggers an
|
||||
/// action (e.g. opening a sheet). While suppressed, all events for the matched
|
||||
/// key category are consumed; suppression auto-clears on [KeyUpEvent].
|
||||
class _KeyUpSuppressor {
|
||||
final bool Function(LogicalKeyboardKey) _keyMatcher;
|
||||
|
||||
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
|
||||
/// documentation for why armers targeting an in-flight KeyUp must unset it.
|
||||
final bool consumeKeyDowns;
|
||||
|
||||
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
|
||||
_KeyUpSuppressor(this._keyMatcher);
|
||||
|
||||
bool _suppressed = false;
|
||||
|
||||
void suppress() {
|
||||
// Re-registered on every arm because flutter_test's HardwareKeyboard
|
||||
// clearState() drops handlers between tests; remove-then-add keeps exactly
|
||||
// one live registration. The observer only reads state and returns false,
|
||||
// so it can never consume an event, and it is intentionally never removed
|
||||
// outside re-registration.
|
||||
HardwareKeyboard.instance
|
||||
..removeHandler(_observeKeyUp)
|
||||
..addHandler(_observeKeyUp);
|
||||
_suppressed = true;
|
||||
}
|
||||
|
||||
bool _observeKeyUp(KeyEvent event) {
|
||||
if (_suppressed && event is KeyUpEvent && _keyMatcher(event.logicalKey)) {
|
||||
// The hardware phase runs before the same event's focus dispatch;
|
||||
// clearing in a microtask keeps the armed state visible to the KeyUp's
|
||||
// focus-phase consumers while ending it before any later event.
|
||||
scheduleMicrotask(clearSuppression);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void suppress() => _suppressed = true;
|
||||
|
||||
void clearSuppression() => _suppressed = false;
|
||||
|
||||
/// Returns `true` (consumed) when the event belongs to the matched key
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent],
|
||||
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent].
|
||||
bool consumeIfSuppressed(KeyEvent event) {
|
||||
if (!_suppressed) return false;
|
||||
if (!_keyMatcher(event.logicalKey)) return false;
|
||||
if (event is KeyUpEvent) _suppressed = false;
|
||||
if (event is KeyDownEvent && !consumeKeyDowns) {
|
||||
// The in-flight KeyUp never arrived; this is a fresh press, not the
|
||||
// suppressed one. Clear and let it through.
|
||||
_suppressed = false;
|
||||
return false;
|
||||
if (_keyMatcher(event.logicalKey)) {
|
||||
if (event is KeyUpEvent) _suppressed = false;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,13 +108,10 @@ class SelectKeyUpSuppressor {
|
||||
/// Global helper to suppress the next BACK key-up event.
|
||||
///
|
||||
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
|
||||
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
|
||||
/// down-only back handler moves focus before the matching KeyUp is dispatched.
|
||||
/// A matching KeyDown while armed clears the arming instead of consuming: it
|
||||
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
|
||||
/// fresh press must act normally.
|
||||
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — so the
|
||||
/// in-flight key-up doesn't propagate to the underlying screen's back handler.
|
||||
class BackKeyUpSuppressor {
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey);
|
||||
|
||||
static void suppressBackUntilKeyUp() => _instance.suppress();
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ Widget buildFocusChrome(
|
||||
bool useBackgroundFocus = false,
|
||||
bool useFocusGlow = false,
|
||||
bool delegateFocusBorder = false,
|
||||
bool? showGlow,
|
||||
Size? glowSize,
|
||||
required Widget child,
|
||||
}) {
|
||||
@@ -45,12 +44,10 @@ Widget buildFocusChrome(
|
||||
}
|
||||
|
||||
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
|
||||
// symmetric; the in-card decoration only carries the border. [showGlow] lets
|
||||
// callers hold back just the glow (e.g. while a viewport scroll animates)
|
||||
// without touching the border or scale chrome.
|
||||
// symmetric; the in-card decoration only carries the border.
|
||||
if (useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showGlow ?? showFocus,
|
||||
isFocused: showFocus,
|
||||
borderRadius: borderRadius,
|
||||
color: focusColor ?? FocusTheme.getFocusBorderColor(context),
|
||||
glowSize: glowSize,
|
||||
|
||||
@@ -57,12 +57,6 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
/// fades out before the portal is hidden in [_handleFadeEnd].
|
||||
bool _visible = false;
|
||||
|
||||
/// Whether the previous [build] mounted the [OverlayPortal] — i.e. whether
|
||||
/// [_controller] is attached. An attached controller's show()/hide() assert
|
||||
/// when called during build, so [didUpdateWidget] must defer; a detached
|
||||
/// controller's show() merely records pending state and is build-safe.
|
||||
bool _portalInTree = false;
|
||||
|
||||
/// Glow is skipped on the reduced effects tier (blurred shadows + fade
|
||||
/// saveLayer are too expensive on weak GPUs) and when the user turned the
|
||||
/// Focus Glow setting off (#1278). The crisp focus border remains.
|
||||
@@ -83,25 +77,12 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
if (_disabled) return;
|
||||
if (widget.isFocused == oldWidget.isFocused) return;
|
||||
if (widget.isFocused) {
|
||||
_controller.show();
|
||||
// Start hidden, then fade in next frame.
|
||||
_visible = false;
|
||||
if (_portalInTree) {
|
||||
// The portal from a previous glow session is still attached (mid
|
||||
// fade-out, or the rail held the glow back during a vertical scroll):
|
||||
// show() would assert during build, so defer it out of the frame and
|
||||
// fade back in from wherever the fade-out got to.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !widget.isFocused) return;
|
||||
if (!_controller.isShowing) _controller.show();
|
||||
setState(() => _visible = true);
|
||||
});
|
||||
} else {
|
||||
// Detached: a build-time show() only records pending state, applied
|
||||
// when this build mounts the portal. Start hidden, fade in next frame.
|
||||
_controller.show();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isFocused) setState(() => _visible = true);
|
||||
});
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isFocused) setState(() => _visible = true);
|
||||
});
|
||||
} else {
|
||||
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
|
||||
setState(() => _visible = false);
|
||||
@@ -117,18 +98,13 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_disabled) {
|
||||
_portalInTree = false;
|
||||
return widget.child;
|
||||
}
|
||||
if (_disabled) return widget.child;
|
||||
|
||||
// Gate the LeaderLayer to the focused card only: when not focused and not
|
||||
// mid-fade, return the bare child (no OverlayPortal, no leader).
|
||||
if (!widget.isFocused && !_controller.isShowing) {
|
||||
_portalInTree = false;
|
||||
return widget.child;
|
||||
}
|
||||
_portalInTree = true;
|
||||
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
|
||||
@@ -33,26 +33,6 @@ class DirectionalShortcutFocusNode extends FocusNode {
|
||||
node is DirectionalShortcutFocusNode && node.consumesDirectionalKeys(key);
|
||||
}
|
||||
|
||||
/// A [FocusNode] for a locked-focus row: the node spans the whole row while
|
||||
/// its owner steps an internal selection index between the row's items.
|
||||
///
|
||||
/// Swipe-step pricing follows the focused *item's* geometry (see
|
||||
/// `AppleTvRemoteTouchService`), and for a locked-focus row the node's own
|
||||
/// rect is the row — screen-wide — which would price a swipe step at the
|
||||
/// travel cap and make the row feel dead. The owner instead vends the
|
||||
/// selected item's global rect here; a null return (item not built yet,
|
||||
/// unknown geometry) falls back to the fixed step distance.
|
||||
///
|
||||
/// Like [DirectionalShortcutFocusNode], the fact rides on the node because it
|
||||
/// depends on what has focus, not on where a widget sits.
|
||||
class LockedFocusRowNode extends FocusNode {
|
||||
LockedFocusRowNode({required this.focusedItemRect, super.debugLabel, super.skipTraversal});
|
||||
|
||||
/// Global rect of the row's currently selected item, evaluated per swipe
|
||||
/// frame so selection moves need no syncing.
|
||||
final Rect? Function() focusedItemRect;
|
||||
}
|
||||
|
||||
/// Whether [event] is evidence that the viewer wants to navigate by focus.
|
||||
///
|
||||
/// This is the single answer to two questions that must never disagree:
|
||||
|
||||
@@ -11,10 +11,19 @@ import 'owned_focus_node_binding.dart';
|
||||
typedef FocusableActionBuilder = Widget Function(BuildContext context, FocusableActionBuildState state);
|
||||
|
||||
class FocusableActionBuildState {
|
||||
final FocusNode focusNode;
|
||||
final bool isFocused;
|
||||
final bool showFocus;
|
||||
final bool isKeyboardMode;
|
||||
final Duration animationDuration;
|
||||
|
||||
const FocusableActionBuildState({required this.showFocus, required this.animationDuration});
|
||||
const FocusableActionBuildState({
|
||||
required this.focusNode,
|
||||
required this.isFocused,
|
||||
required this.showFocus,
|
||||
required this.isKeyboardMode,
|
||||
required this.animationDuration,
|
||||
});
|
||||
}
|
||||
|
||||
class FocusableAction {
|
||||
@@ -32,12 +41,6 @@ class FocusableAction {
|
||||
final Widget? child;
|
||||
final FocusableActionBuilder? builder;
|
||||
|
||||
/// Row gap between this action and the one before it, overriding the bar's
|
||||
/// uniform [FocusableActionBar.spacing]. Lets visually joined pairs (the
|
||||
/// detail screen's split Play button) sit tighter than the rest of the row.
|
||||
/// Ignored on the first action.
|
||||
final double? spacingBefore;
|
||||
|
||||
const FocusableAction({
|
||||
this.icon = Symbols.circle_rounded,
|
||||
this.iconColor,
|
||||
@@ -50,7 +53,6 @@ class FocusableAction {
|
||||
this.onPressed,
|
||||
this.child,
|
||||
this.builder,
|
||||
this.spacingBefore,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,6 +78,7 @@ class FocusableActionBar extends StatefulWidget {
|
||||
final ValueChanged<bool>? onFocusChange;
|
||||
|
||||
final double spacing;
|
||||
final MainAxisSize mainAxisSize;
|
||||
|
||||
const FocusableActionBar({
|
||||
super.key,
|
||||
@@ -87,6 +90,7 @@ class FocusableActionBar extends StatefulWidget {
|
||||
this.onBack,
|
||||
this.onFocusChange,
|
||||
this.spacing = 0,
|
||||
this.mainAxisSize = MainAxisSize.min,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -130,7 +134,8 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
void didUpdateWidget(FocusableActionBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_shouldRebuildFocusNodes(oldWidget)) {
|
||||
_rebindNodes(oldWidget.actions);
|
||||
_disposeNodes();
|
||||
_initNodes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,118 +148,29 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Stable identity for matching an action across list changes: the supplied
|
||||
/// [FocusableAction.focusNode] first, else [FocusableAction.debugLabel].
|
||||
/// Unlabeled actions have no identity and only ever match positionally.
|
||||
static Object? _actionIdentity(FocusableAction action) => action.focusNode ?? action.debugLabel;
|
||||
|
||||
/// Whether the action-list shape is unchanged: same length and every
|
||||
/// labeled identity present in both lists sits at the index it had. Only
|
||||
/// then does an unlabeled action's position provably still refer to the
|
||||
/// same conceptual action.
|
||||
bool _unlabeledPositionsStable(List<FocusableAction> oldActions) {
|
||||
if (oldActions.length != widget.actions.length) return false;
|
||||
for (var i = 0; i < oldActions.length; i++) {
|
||||
final identity = _actionIdentity(oldActions[i]);
|
||||
if (identity == null) continue;
|
||||
for (var j = 0; j < widget.actions.length; j++) {
|
||||
if (j != i && _actionIdentity(widget.actions[j]) == identity) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
OwnedFocusNodeBinding _createBinding(FocusableAction action, int index) {
|
||||
final binding = OwnedFocusNodeBinding();
|
||||
binding.bind(
|
||||
externalNode: action.focusNode,
|
||||
debugLabel: action.debugLabel ?? 'ActionBar[$index]',
|
||||
listener: () => _handleBindingFocusChange(binding),
|
||||
);
|
||||
return binding;
|
||||
}
|
||||
|
||||
/// Index resolved at call time: a binding survives action-list changes, so
|
||||
/// a listener must not capture the slot it was created for.
|
||||
void _handleBindingFocusChange(OwnedFocusNodeBinding binding) {
|
||||
final index = _focusBindings.indexOf(binding);
|
||||
if (index != -1) {
|
||||
final hasFocus = binding.node.hasFocus;
|
||||
if (_focusStates[index] != hasFocus) {
|
||||
setState(() => _focusStates[index] = hasFocus);
|
||||
}
|
||||
}
|
||||
_notifyRowFocusIfChanged();
|
||||
}
|
||||
|
||||
void _initNodes() {
|
||||
_focusBindings = [for (var i = 0; i < widget.actions.length; i++) _createBinding(widget.actions[i], i)];
|
||||
_focusNodes = [for (final binding in _focusBindings) binding.node];
|
||||
_focusStates = [for (final node in _focusNodes) node.hasFocus];
|
||||
_hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
|
||||
}
|
||||
|
||||
/// Rebind on an action-list change, reusing the binding of every action
|
||||
/// whose identity survives so its focus node is not disposed out from under
|
||||
/// the user (media detail's watchlist action arrives asynchronously and
|
||||
/// used to drop D-pad focus with the wholesale rebuild). A removed focused
|
||||
/// action is disposed with its listener already detached, so the genuine
|
||||
/// row-focus loss is reported through [_notifyRowFocusIfChanged] here.
|
||||
void _rebindNodes(List<FocusableAction> oldActions) {
|
||||
final oldBindings = _focusBindings;
|
||||
final focusedOldIndex = _focusNodes.indexWhere((node) => node.hasFocus);
|
||||
|
||||
final claimed = List<bool>.filled(oldBindings.length, false);
|
||||
final unlabeledPositionsStable = _unlabeledPositionsStable(oldActions);
|
||||
int? matchOldIndex(int newIndex) {
|
||||
final identity = _actionIdentity(widget.actions[newIndex]);
|
||||
if (identity != null) {
|
||||
for (var i = 0; i < oldActions.length; i++) {
|
||||
if (!claimed[i] && _actionIdentity(oldActions[i]) == identity) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Positional reuse is the only option for an unlabeled action, but it
|
||||
// is only provably correct while the list shape is unchanged; across an
|
||||
// insertion/removal its successor is unknowable, so fall through to a
|
||||
// fresh binding — genuinely dropping focus beats silently retargeting
|
||||
// the focused binding (and its next Select) onto a different action.
|
||||
if (unlabeledPositionsStable && !claimed[newIndex] && _actionIdentity(oldActions[newIndex]) == null) {
|
||||
return newIndex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var focusedNewIndex = -1;
|
||||
final newBindings = <OwnedFocusNodeBinding>[];
|
||||
_focusBindings = [];
|
||||
_focusNodes = [];
|
||||
_focusStates = List<bool>.filled(widget.actions.length, false);
|
||||
for (var i = 0; i < widget.actions.length; i++) {
|
||||
final oldIndex = matchOldIndex(i);
|
||||
if (oldIndex == null) {
|
||||
newBindings.add(_createBinding(widget.actions[i], i));
|
||||
continue;
|
||||
}
|
||||
claimed[oldIndex] = true;
|
||||
if (oldIndex == focusedOldIndex) focusedNewIndex = i;
|
||||
newBindings.add(oldBindings[oldIndex]);
|
||||
final index = i;
|
||||
final binding = OwnedFocusNodeBinding();
|
||||
binding.bind(
|
||||
externalNode: widget.actions[i].focusNode,
|
||||
debugLabel: widget.actions[i].debugLabel ?? 'ActionBar[$i]',
|
||||
listener: () {
|
||||
final hasFocus = _focusNodes[index].hasFocus;
|
||||
if (_focusStates[index] != hasFocus) {
|
||||
setState(() => _focusStates[index] = hasFocus);
|
||||
}
|
||||
_notifyRowFocusIfChanged();
|
||||
},
|
||||
);
|
||||
_focusBindings.add(binding);
|
||||
_focusNodes.add(binding.node);
|
||||
_focusStates[i] = binding.node.hasFocus;
|
||||
}
|
||||
|
||||
_focusBindings = newBindings;
|
||||
_focusNodes = [for (final binding in newBindings) binding.node];
|
||||
_focusStates = [for (final node in _focusNodes) node.hasFocus];
|
||||
|
||||
for (var i = 0; i < oldBindings.length; i++) {
|
||||
if (!claimed[i]) oldBindings[i].dispose();
|
||||
}
|
||||
|
||||
// The focused action survived: keep focus on it. The keyed row keeps its
|
||||
// Focus element attached across reorders, so this only re-requests focus
|
||||
// if the framework unfocused the node during widget churn.
|
||||
if (focusedNewIndex != -1 &&
|
||||
widget.actions[focusedNewIndex].onPressed != null &&
|
||||
!_focusNodes[focusedNewIndex].hasFocus) {
|
||||
_focusNodes[focusedNewIndex].requestFocus();
|
||||
}
|
||||
_notifyRowFocusIfChanged();
|
||||
_hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
|
||||
}
|
||||
|
||||
void _notifyRowFocusIfChanged() {
|
||||
@@ -282,10 +198,10 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: widget.mainAxisSize,
|
||||
children: [
|
||||
for (var i = 0; i < widget.actions.length; i++) ...[
|
||||
if (i > 0 && _gapBefore(i) > 0) SizedBox(width: _gapBefore(i)),
|
||||
if (i > 0 && widget.spacing > 0) SizedBox(width: widget.spacing),
|
||||
_buildButton(i, isKeyboard, duration),
|
||||
],
|
||||
],
|
||||
@@ -299,21 +215,22 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
return AnimatedOpacity(opacity: isKeyboard && !_hasAnyFocus ? 0.6 : 1.0, duration: duration, child: row);
|
||||
}
|
||||
|
||||
double _gapBefore(int index) => widget.actions[index].spacingBefore ?? widget.spacing;
|
||||
|
||||
Widget _buildButton(int index, bool isKeyboard, Duration duration) {
|
||||
final action = widget.actions[index];
|
||||
final enabled = action.onPressed != null;
|
||||
final isFocused = _focusStates[index];
|
||||
final showFocus = isFocused && isKeyboard;
|
||||
final opacity = isKeyboard && _hasAnyFocus && !isFocused ? 0.6 : 1.0;
|
||||
final buildState = FocusableActionBuildState(showFocus: showFocus, animationDuration: duration);
|
||||
final buildState = FocusableActionBuildState(
|
||||
focusNode: _focusNodes[index],
|
||||
isFocused: isFocused,
|
||||
showFocus: showFocus,
|
||||
isKeyboardMode: isKeyboard,
|
||||
animationDuration: duration,
|
||||
);
|
||||
final customChild = action.builder?.call(context, buildState);
|
||||
|
||||
return Focus(
|
||||
// Keyed on the binding so an insertion moves the element with its node
|
||||
// instead of detaching (and thereby unfocusing) it slot by slot.
|
||||
key: ObjectKey(_focusBindings[index]),
|
||||
focusNode: _focusNodes[index],
|
||||
canRequestFocus: enabled,
|
||||
autofocus: action.autofocus && enabled,
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/scroll_utils.dart';
|
||||
import 'owned_focus_node_binding.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'dpad_select_long_press_controller.dart';
|
||||
import 'key_event_utils.dart';
|
||||
@@ -93,12 +92,8 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
// Same convention as FocusableTileStateMixin: a chip inside a
|
||||
// scrollable strip (TabChipStrip, filter bars) reveals itself on
|
||||
// focus; a no-op when no ancestor scrollable exists. Keyboard/D-pad
|
||||
// sessions only — pointer-mode focus is programmatic and invisible,
|
||||
// so revealing it would yank the strip (issue #2031).
|
||||
if (focusNode.hasFocus && InputModeTracker.currentMode == InputMode.keyboard) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
// focus; a no-op when no ancestor scrollable exists.
|
||||
if (focusNode.hasFocus) scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user