Compare commits

..
Author SHA1 Message Date
edde746 8dc8fdf7e8 ci: admit the hdr keepalive branch to the trusted build gate
Exact branch name only, no wildcards, so the workflow can produce the
#1874 A/B artifact while the keepalive probe is on the branch. Linux-only
dispatches leave create-release skipped (it requires all four platform
inputs), so nothing publishes. Revert this arm together with the probe.
2026-08-15 01:05:06 +02:00
edde746 f107da32c3 test(linux): allow disabling the player frame keepalive
Diagnostic for #1874, not a fix. The keep-alive widget repaints at 10 Hz
for the whole player lifetime on Linux, which is a periodic compositor
perturbation next to the plane's HDR surface description; whether it
drives the reporter's every-few-seconds colour flip needs an A/B.
Launching with PLEZY_AB_NO_KEEP_ALIVE=1 runs the inert leg of that test
with an otherwise identical build. Revert once the oscillation is
diagnosed.
2026-08-15 01:04:24 +02:00
984 changed files with 17713 additions and 73604 deletions
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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
+3 -3
View File
@@ -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
+7 -27
View File
@@ -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 }}
+7 -8
View File
@@ -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: |
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -2
View File
@@ -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
-7
View File
@@ -4,13 +4,6 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
+11 -24
View File
@@ -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()
-17
View File
@@ -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;
}
@@ -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
}
}
@@ -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()
@@ -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? {
@@ -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 })
}
@@ -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) {
@@ -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,
@@ -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))
}
}
@@ -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.
+2 -2
View File
@@ -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) }
@@ -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
@@ -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
View File
@@ -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
+2 -6
View File
@@ -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"
}
},
{
-4
View File
@@ -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)
}
}
}
+67 -6
View File
@@ -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,
);
+43 -17
View File
@@ -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;
+62 -28
View File
@@ -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);
}
+57 -98
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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;
+50 -75
View File
@@ -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(
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -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),
+7 -47
View File
@@ -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};
}
+3 -36
View File
@@ -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'}');
}
+13 -61
View File
@@ -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();
+2 -5
View File
@@ -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,
+6 -30
View File
@@ -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);
-20
View File
@@ -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:
+43 -126
View File
@@ -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,
+2 -7
View File
@@ -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