diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 685bea07b..9fd7b730d 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -132,7 +132,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
- val libraries = listOf("avcodec", "avformat", "avutil", "swresample")
+ val libraries = listOf("avcodec", "avutil", "swresample")
inputs.file(aar)
inputs.property("ffmpegVersion", mpvFfmpegVersion)
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
@@ -145,7 +145,6 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
outputs.files(
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
- File(mpvFfmpegDevelopmentDir, "include/libavformat/avformat.h"),
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
manifest
)
@@ -183,7 +182,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
} catch (error: Exception) {
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
}
- listOf("libavcodec", "libavformat", "libavutil", "libswresample").forEach { library ->
+ listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
project.copy {
from(File(extractedSource, library)) {
include("*.h")
@@ -207,7 +206,6 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
from(zipTree(aar)) {
include(
"jni/*/libavcodec.so",
- "jni/*/libavformat.so",
"jni/*/libavutil.so",
"jni/*/libswresample.so"
)
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 2a20311e7..fae3c6177 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -19,13 +19,19 @@
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
-# ffmpeg_demuxer_jni.cc resolves the AVIO input proxy's callbacks by name
-# (FindClass on the interface, GetMethodID for position/read/readAt/length).
-# Keeping the interface members pins the names on every implementation,
-# including the anonymous proxy inside FfmpegExtractor.
--keep interface com.edde746.plezy.exoplayer.FfmpegDemuxerJni$Input {
- long position();
- int read(byte[], int);
- int readAt(long, byte[], int);
- long length();
+# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
+# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
+# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
+# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
+# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
+# extractorOutput the same way to wrap LATM tracks.
+#
+# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
+# them in its companion object, so the throw surfaces as ExceptionInInitializerError
+# while constructing the extractor — every MKV direct-play, release builds only. Only
+# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
+# shrinking through the compile-time subclass references.
+-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
+ private androidx.media3.extractor.ExtractorOutput extractorOutput;
+ private androidx.media3.common.util.ParsableByteArray subtitleSample;
}
diff --git a/android/app/src/androidTest/AndroidManifest.xml b/android/app/src/androidTest/AndroidManifest.xml
deleted file mode 100644
index 5bb75390b..000000000
--- a/android/app/src/androidTest/AndroidManifest.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/androidTest/assets/ffmpeg/seek_cued.mkv b/android/app/src/androidTest/assets/ffmpeg/seek_cued.mkv
deleted file mode 100644
index 776cdc3c7..000000000
Binary files a/android/app/src/androidTest/assets/ffmpeg/seek_cued.mkv and /dev/null differ
diff --git a/android/app/src/androidTest/assets/ffmpeg/seek_cueless.mkv b/android/app/src/androidTest/assets/ffmpeg/seek_cueless.mkv
deleted file mode 100644
index 85ce73566..000000000
Binary files a/android/app/src/androidTest/assets/ffmpeg/seek_cueless.mkv and /dev/null differ
diff --git a/android/app/src/androidTest/assets/ffmpeg/stereo_opus.mka b/android/app/src/androidTest/assets/ffmpeg/stereo_opus.mka
deleted file mode 100644
index a868c1006..000000000
Binary files a/android/app/src/androidTest/assets/ffmpeg/stereo_opus.mka and /dev/null differ
diff --git a/android/app/src/androidTest/assets/ffmpeg/stereo_vorbis.mka b/android/app/src/androidTest/assets/ffmpeg/stereo_vorbis.mka
deleted file mode 100644
index c9b243417..000000000
Binary files a/android/app/src/androidTest/assets/ffmpeg/stereo_vorbis.mka and /dev/null differ
diff --git a/android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java b/android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java
deleted file mode 100644
index e0b07474a..000000000
--- a/android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.edde746.plezy.exoplayer;
-
-import android.content.ContentProvider;
-import android.content.ContentValues;
-import android.content.Context;
-import android.database.Cursor;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-/**
- * Serves a test asset over a {@code content://} URI so instrumentation can exercise media3's
- * {@code ContentDataSource} read path.
- *
- *
A download stored through SAF reaches the player as {@code content://} and is read with
- * {@code openAssetFileDescriptor} — a different reopen story than a plain file:
- * {@code FfmpegRandomAccessSource} has to open a second descriptor on the document while
- * the loader still holds one. Nothing else in the app produces that shape on demand, and MediaStore
- * would leave rows on the device.
- *
- *
The provider owns the whole fixture: a test body runs in the app's process and cannot
- * write into this APK's data directory, so handing it a path could never work. It copies the named
- * asset into its own cache and returns a read-only descriptor, which crosses the uid boundary fine.
- *
- *
Java rather than Kotlin on purpose: hosting this provider starts a process for the test APK
- * alone, and the Kotlin stdlib the instrumentation normally borrows from the app is not on that
- * process's classpath.
- */
-public final class FixtureContentProvider extends ContentProvider {
-
- public static final String AUTHORITY = "com.edde746.plezy.test.fixtures";
-
- /** @param asset path inside this APK's assets, e.g. {@code ffmpeg/seek_cued.mkv}. */
- public static Uri uriFor(String asset) {
- return new Uri.Builder().scheme("content").authority(AUTHORITY).path(asset).build();
- }
-
- @Override
- public boolean onCreate() {
- return true;
- }
-
- @Override
- public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
- String path = uri.getPath();
- if (path == null || path.length() <= 1) {
- throw new FileNotFoundException("no asset in " + uri);
- }
- String asset = path.substring(1);
- Context context = getContext();
- if (context == null) {
- throw new FileNotFoundException("provider has no context");
- }
- File cached = new File(context.getCacheDir(), asset.replace('/', '-'));
- if (!cached.isFile()) {
- copyAsset(context, asset, cached);
- }
- return ParcelFileDescriptor.open(cached, ParcelFileDescriptor.MODE_READ_ONLY);
- }
-
- private static void copyAsset(Context context, String asset, File target) throws FileNotFoundException {
- File staging = null;
- try {
- context.getCacheDir().mkdirs();
- staging = File.createTempFile("fixture-", null, context.getCacheDir());
- try (InputStream source = context.getAssets().open(asset);
- OutputStream sink = new FileOutputStream(staging)) {
- byte[] buffer = new byte[64 * 1024];
- for (int read = source.read(buffer); read != -1; read = source.read(buffer)) {
- sink.write(buffer, 0, read);
- }
- }
- // Publish atomically so a concurrent open never sees a short file.
- if (!staging.renameTo(target) && !target.isFile()) {
- throw new IOException("could not publish " + target);
- }
- } catch (IOException e) {
- FileNotFoundException failure = new FileNotFoundException("could not stage asset " + asset);
- failure.initCause(e);
- throw failure;
- } finally {
- if (staging != null && staging.isFile()) {
- staging.delete();
- }
- }
- }
-
- @Override
- public String getType(Uri uri) {
- return "video/x-matroska";
- }
-
- @Override
- public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
- return null;
- }
-
- @Override
- public Uri insert(Uri uri, ContentValues values) {
- return null;
- }
-
- @Override
- public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
- return 0;
- }
-
- @Override
- public int delete(Uri uri, String selection, String[] selectionArgs) {
- return 0;
- }
-}
diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt
deleted file mode 100644
index 88a2180ca..000000000
--- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import java.io.File
-import java.io.RandomAccessFile
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Asserts the demuxer's JNI byte source stays reachable under R8.
- *
- * `ffmpeg_demuxer_jni.cc` resolves [FfmpegDemuxerJni.Input] by name — `FindClass`
- * on the interface, then `GetMethodID` for `position`, `read`, `readAt` and
- * `length`. Nothing in Kotlin calls those methods, so R8 may rename or remove
- * them while every debug check still passes; the AVIO callbacks would then fail
- * on the first read and every container would fall back to media3's extractors.
- * That is the same failure shape as #1703, which shipped.
- *
- * Run against the `minified` build type (`-Pplezy.testBuildType=minified`); on an
- * unminified variant it can only ever pass. Deliberately touches no media3 API,
- * so the only keep rules it depends on are the ones under test.
- */
-@RunWith(AndroidJUnit4::class)
-class FfmpegDemuxerReachabilityTest {
-
- @Test
- fun avioCallbacksResolveAndServeBothSequentialAndIndexReads() {
- assertTrue("ffmpeg demuxer JNI library is unavailable", FfmpegDemuxerJni.available)
-
- val fixture = copyFixture()
- val input = FileInput(fixture)
- try {
- // nativeOpen drives the whole handshake: FindClass on Input, GetMethodID
- // for all four methods, then avio reads served back through them. A
- // shrunk or renamed member makes this fail instead of returning streams.
- val code = FfmpegDemuxerJni.nativeOpen(input)
-
- assertEquals("nativeOpen failed (${input.lastError ?: "no java error"})", 0, code)
- assertTrue("demuxer reported no streams", FfmpegDemuxerJni.nativeStreamCount() > 0)
- assertTrue("position was never called", input.positionCalls > 0)
-
- // Header parsing is sequential, so it only proves read()/position()/length().
- // readAt() is the member nothing in the app calls except this interface's
- // own implementation, and it is what an index seek runs on.
- val durationUs = FfmpegDemuxerJni.nativeDurationUs()
- assertTrue("fixture duration unknown", durationUs > 0)
- val seek = FfmpegDemuxerJni.nativeSeek(durationUs / 2)
-
- assertTrue("nativeSeek failed with $seek (${input.lastError ?: "no java error"})", seek >= 0)
- assertTrue("readAt was never called for the index read", input.readAtCalls > 0)
- } finally {
- FfmpegDemuxerJni.nativeClose()
- input.close()
- fixture.delete()
- }
- }
-
- private fun copyFixture(): File {
- val instrumentation = InstrumentationRegistry.getInstrumentation()
- val cacheDir = instrumentation.targetContext.cacheDir.also { it.mkdirs() }
- val output = File.createTempFile("ffmpeg-reachability-", ".mkv", cacheDir)
- instrumentation.context.assets.open(FIXTURE).use { source ->
- output.outputStream().use(source::copyTo)
- }
- return output
- }
-
- /** Byte source with no media3 involvement, so R8 has nothing else to keep. */
- private class FileInput(file: File) : FfmpegDemuxerJni.Input {
- private val handle = RandomAccessFile(file, "r")
- private val total = file.length()
-
- var readAtCalls = 0
- private set
-
- var positionCalls = 0
- private set
-
- override var lastError: String? = null
-
- override fun position(): Long {
- positionCalls++
- return handle.filePointer
- }
-
- override fun read(buf: ByteArray, length: Int): Int {
- val read = handle.read(buf, 0, length)
- return if (read < 0) 0 else read
- }
-
- override fun readAt(position: Long, buf: ByteArray, length: Int): Int {
- readAtCalls++
- val restore = handle.filePointer
- return try {
- if (position >= total) return 0
- handle.seek(position)
- val read = handle.read(buf, 0, length)
- if (read < 0) 0 else read
- } finally {
- handle.seek(restore)
- }
- }
-
- override fun length(): Long = total
-
- fun close() = handle.close()
- }
-
- private companion object {
- /** The cued fixture from [FfmpegExtractorSeekTest]: a SeekHead plus Cues at the end. */
- const val FIXTURE = "ffmpeg/seek_cued.mkv"
- }
-}
diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt
deleted file mode 100644
index 3acebb25c..000000000
--- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import android.content.Context
-import android.net.Uri
-import android.os.Handler
-import android.os.HandlerThread
-import androidx.media3.common.MediaItem
-import androidx.media3.common.PlaybackException
-import androidx.media3.common.Player
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.DefaultDataSource
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.ProgressiveMediaSource
-import androidx.media3.extractor.ExtractorsFactory
-import androidx.media3.extractor.text.DefaultSubtitleParserFactory
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.edde746.plezy.libass.media.AssHandler
-import java.io.File
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicReference
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * End-to-end regression coverage for #2088: audio demuxed by FfmpegExtractor
- * must reach MediaCodec with the per-codec csd shapes media3's own extractors
- * emit. With raw ffmpeg extradata as the only csd buffer, Android's Opus
- * decoders consume the first two real packets as codec delay / seek pre-roll
- * and then silently discard every decoded sample — the player never leaves
- * STATE_BUFFERING, which this test observes as a playback timeout.
- *
- * The extractors factory contains only FfmpegExtractor, so a fixture that
- * falls back to media3's extractors fails instead of passing vacuously.
- */
-@RunWith(AndroidJUnit4::class)
-class FfmpegExtractorAudioCsdPlaybackTest {
-
- @Test
- fun opusPlaysToEnd() {
- playToEnd("ffmpeg/stereo_opus.mka")
- }
-
- @Test
- fun vorbisPlaysToEnd() {
- playToEnd("ffmpeg/stereo_vorbis.mka")
- }
-
- @Test
- fun flacPlaysToEnd() {
- playToEnd("ffmpeg/stereo.flac")
- }
-
- private fun playToEnd(fixture: String) {
- val instrumentation = InstrumentationRegistry.getInstrumentation()
- val context = instrumentation.targetContext
- val fixtureFile = copyFixture(instrumentation.context, context, fixture)
- val playbackThread = HandlerThread("ffmpeg-csd-test").apply { start() }
- val handler = Handler(playbackThread.looper)
- val completed = CountDownLatch(1)
- val playerReference = AtomicReference()
- val errorReference = AtomicReference()
-
- handler.post {
- try {
- val fixtureUri = Uri.fromFile(fixtureFile)
- val ffmpegOnlyExtractors = ExtractorsFactory {
- val extractor = FfmpegExtractor.create(
- { FfmpegDemuxerPolicy.Preference.FFMPEG },
- DvConversionMode.DISABLED,
- DefaultSubtitleParserFactory(),
- AssHandler(),
- FfmpegRandomAccessSource(DefaultDataSource.Factory(context)) { DataSpec(fixtureUri) }
- )
- assertNotNull("FFmpeg demuxer JNI library is unavailable", extractor)
- arrayOf(extractor!!)
- }
- val player = ExoPlayer.Builder(context, PlezyRenderersFactory(context))
- .setLooper(playbackThread.looper)
- .build()
- playerReference.set(player)
- player.addListener(object : Player.Listener {
- override fun onPlayerError(error: PlaybackException) {
- errorReference.set(error)
- completed.countDown()
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- if (playbackState == Player.STATE_ENDED) {
- completed.countDown()
- }
- }
- })
- val source = ProgressiveMediaSource.Factory(
- DefaultDataSource.Factory(context),
- ffmpegOnlyExtractors
- ).createMediaSource(MediaItem.fromUri(fixtureUri))
- player.setMediaSource(source)
- player.prepare()
- player.play()
- } catch (error: Throwable) {
- errorReference.set(error)
- completed.countDown()
- }
- }
-
- val finished = completed.await(20, TimeUnit.SECONDS)
- val released = CountDownLatch(1)
- handler.post {
- playerReference.get()?.release()
- playbackThread.quitSafely()
- released.countDown()
- }
- val teardownFinished = released.await(5, TimeUnit.SECONDS)
- playbackThread.join(5_000)
- val fixtureDeleted = fixtureFile.delete()
-
- assertTrue("Player teardown timed out for $fixture", teardownFinished)
- assertTrue("Playback timed out for $fixture", finished)
- assertNull("Playback failed for $fixture", errorReference.get())
- assertTrue("Fixture cleanup failed for $fixture", fixtureDeleted)
- }
-
- private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
- val output = File.createTempFile("ffmpeg-csd-fixture-", null, targetContext.cacheDir)
- instrumentationContext.assets.open(fixture).use { input ->
- output.outputStream().use(input::copyTo)
- }
- return output
- }
-}
diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt
deleted file mode 100644
index d1e82ea23..000000000
--- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt
+++ /dev/null
@@ -1,240 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import android.content.Context
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.common.DataReader
-import androidx.media3.common.Format
-import androidx.media3.common.ParserException
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.DefaultDataSource
-import androidx.media3.datasource.TransferListener
-import androidx.media3.extractor.DefaultExtractorInput
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.ExtractorInput
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.PositionHolder
-import androidx.media3.extractor.SeekMap
-import androidx.media3.extractor.TrackOutput
-import androidx.media3.extractor.text.DefaultSubtitleParserFactory
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.edde746.plezy.libass.media.AssHandler
-import java.io.File
-import java.io.IOException
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertThrows
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Regression coverage for #2113: a transient byte-source failure mid-stream
- * must reach media3 as a plain retryable IOException, and the demuxer must
- * deliver samples again once the retried read succeeds.
- *
- * 2.17.1 got both halves wrong. The AVIO callback converted the proxy's
- * stored IOException into a bare AVERROR(EIO), which the extractor classified
- * as "malformed container" — a ParserException that
- * DefaultLoadErrorHandlingPolicy never retries — so one dropped connection
- * sent an otherwise healthy ExoPlayer session to the MPV fallback. And even
- * with the classification fixed, a failed refill latches
- * AVIOContext.error/eof_reached, so the retry would re-fail on the stale
- * error without the native side clearing the latch on re-entry.
- */
-@RunWith(AndroidJUnit4::class)
-class FfmpegExtractorReadRetryTest {
-
- private companion object {
- /** 30 s at 10 fps: video samples are exact multiples of 100 ms. */
- const val FIXTURE = "ffmpeg/seek_cued.mkv"
- const val SAMPLE_INTERVAL_US = 100_000L
- const val TOTAL_SAMPLES = 300
- }
-
- @Test
- fun transientLoaderReadFailureIsRetryableAndDeliveryResumesGaplessly() {
- withHarness(FIXTURE) { harness ->
- assertTrue("no samples before the fault", harness.pumpUntil { harness.sampleTimestamps.size >= 10 })
-
- harness.failNextLoaderRead()
- val thrown = assertThrows(IOException::class.java) {
- harness.pumpUntil { harness.sampleTimestamps.size >= TOTAL_SAMPLES }
- }
- // The regression: a byte-source failure classified as malformed content
- // is terminal to media3, which retries only plain IOExceptions.
- assertFalse("byte-source failure classified as malformed: $thrown", thrown is ParserException)
- assertEquals("injected loader failure", thrown.message)
-
- // What ExtractingLoadable does on a load-error retry: reopen the source
- // where the input stood and call read() again.
- harness.reopenForRetry()
- assertTrue(
- "no samples after the retried read",
- harness.pumpUntil { harness.sampleTimestamps.size >= TOTAL_SAMPLES / 2 }
- )
-
- // Decode order is not presentation order with B-frames; what must hold
- // is that the delivered set stays gapless across the failure point.
- val sorted = harness.sampleTimestamps.sorted()
- assertEquals(
- "sample delivery must resume gaplessly after the transient failure",
- sorted.indices.map { it * SAMPLE_INTERVAL_US },
- sorted
- )
- }
- }
-
- private fun withHarness(fixture: String, block: (Harness) -> Unit) {
- val instrumentation = InstrumentationRegistry.getInstrumentation()
- val context = instrumentation.targetContext
- val cacheDir = context.cacheDir.also { it.mkdirs() }
- val file = File.createTempFile("ffmpeg-retry-fixture-", ".mkv", cacheDir)
- instrumentation.context.assets.open(fixture).use { input ->
- file.outputStream().use(input::copyTo)
- }
- val harness = Harness(context, Uri.fromFile(file))
- try {
- harness.prepare()
- block(harness)
- } finally {
- harness.close()
- file.delete()
- }
- }
-
- /**
- * Stand-in for ProgressiveMediaPeriod's loader, like FfmpegExtractorSeekTest's,
- * plus the piece under test here: the loader's DataSource can be made to fail
- * exactly one read, and [reopenForRetry] performs the load-error retry the
- * way ExtractingLoadable.load() does.
- */
- private class Harness(context: Context, private val uri: Uri) {
- private val factory = DefaultDataSource.Factory(context)
- private val io = FfmpegRandomAccessSource(factory) { DataSpec(uri) }
- private val output = CapturingOutput()
- private val positionHolder = PositionHolder()
- private val extractor = requireNotNull(
- FfmpegExtractor.create(
- { FfmpegDemuxerPolicy.Preference.FFMPEG },
- DvConversionMode.DISABLED,
- DefaultSubtitleParserFactory(),
- AssHandler(),
- io
- )
- ) { "native ffmpeg demuxer unavailable" }
-
- private var loaderSource: FlakySource? = null
- private var input: ExtractorInput? = null
-
- val sampleTimestamps: List
- get() = output.sampleTimestamps
-
- fun prepare() {
- openAt(0)
- assertTrue("fixture should be sniffed by the ffmpeg demuxer", extractor.sniff(input!!))
- input!!.resetPeekPosition()
- extractor.init(output)
- }
-
- /** Arms the current loader handle; only its next read throws. */
- fun failNextLoaderRead() {
- loaderSource!!.failNextRead = true
- }
-
- fun reopenForRetry() {
- openAt(input!!.position)
- }
-
- fun pumpUntil(condition: () -> Boolean): Boolean {
- repeat(200_000) {
- if (condition()) return true
- when (extractor.read(input!!, positionHolder)) {
- Extractor.RESULT_SEEK -> openAt(positionHolder.position)
- Extractor.RESULT_END_OF_INPUT -> return condition()
- else -> Unit
- }
- }
- return condition()
- }
-
- private fun openAt(position: Long) {
- loaderSource?.close()
- val source = FlakySource(factory.createDataSource())
- val remaining = source.open(DataSpec.Builder().setUri(uri).setPosition(position).build())
- loaderSource = source
- input = DefaultExtractorInput(
- source,
- position,
- if (remaining == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else position + remaining
- )
- }
-
- fun close() {
- extractor.release()
- loaderSource?.close()
- }
- }
-
- /** Wraps only the loader's source, so the fault never lands on an index read. */
- private class FlakySource(private val inner: DataSource) : DataSource {
- var failNextRead = false
-
- override fun open(dataSpec: DataSpec): Long = inner.open(dataSpec)
-
- override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
- if (failNextRead) {
- failNextRead = false
- throw IOException("injected loader failure")
- }
- return inner.read(buffer, offset, length)
- }
-
- override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener)
-
- override fun getUri(): Uri? = inner.uri
-
- override fun getResponseHeaders(): Map> = inner.responseHeaders
-
- override fun close() = inner.close()
- }
-
- private class CapturingOutput : ExtractorOutput {
- val sampleTimestamps = mutableListOf()
-
- override fun track(id: Int, type: Int): TrackOutput = CapturingTrack(type, sampleTimestamps)
-
- override fun endTracks() = Unit
-
- override fun seekMap(seekMap: SeekMap) = Unit
- }
-
- private class CapturingTrack(private val type: Int, private val timestamps: MutableList) : TrackOutput {
- private val scratch = ByteArray(64 * 1024)
-
- override fun format(format: Format) = Unit
-
- override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean): Int =
- sampleData(input, length, allowEndOfInput, TrackOutput.SAMPLE_DATA_PART_MAIN)
-
- override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
- val read = input.read(scratch, 0, minOf(length, scratch.size))
- return if (read == C.RESULT_END_OF_INPUT) 0 else read
- }
-
- override fun sampleData(data: ParsableByteArray, length: Int) {
- sampleData(data, length, TrackOutput.SAMPLE_DATA_PART_MAIN)
- }
-
- override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
- data.skipBytes(length)
- }
-
- override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
- if (type == C.TRACK_TYPE_VIDEO) timestamps.add(timeUs)
- }
- }
-}
diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt
deleted file mode 100644
index 596f7503b..000000000
--- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt
+++ /dev/null
@@ -1,357 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import android.content.Context
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.common.DataReader
-import androidx.media3.common.Format
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.DefaultDataSource
-import androidx.media3.datasource.TransferListener
-import androidx.media3.extractor.DefaultExtractorInput
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.ExtractorInput
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.PositionHolder
-import androidx.media3.extractor.SeekMap
-import androidx.media3.extractor.TrackOutput
-import androidx.media3.extractor.text.DefaultSubtitleParserFactory
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.edde746.plezy.libass.media.AssHandler
-import java.io.File
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Regression coverage for #2096 and for the seam that caused it.
- *
- * libavformat resolves seeks through its own index, reading it via
- * [FfmpegRandomAccessSource]. When that read cannot be served, matroskadec
- * burns its one-shot Cues parse and every later seek degrades to libavformat's
- * linear generic scan — which is what buffered a resume for minutes. The cued
- * fixture here has the same geometry as the file from the report (front
- * SeekHead, Cues at 99.5% of the file), so a scan is trivially distinguishable
- * from an index seek: it has to read most of the file.
- */
-@RunWith(AndroidJUnit4::class)
-class FfmpegExtractorSeekTest {
-
- private companion object {
- // Regenerate with:
- // ffmpeg -f lavfi -i "testsrc=size=160x120:rate=10:duration=30" \
- // -c:v libx264 -g 10 -pix_fmt yuv420p -preset veryfast seek_cued.mkv
- // mkvmerge --no-cues -o seek_cueless.mkv seek_cued.mkv
- // The cued fixture keeps ffmpeg's default layout: a SeekHead at byte 52
- // pointing at Cues written after the clusters, at 99.5% of the file.
- const val CUED = "ffmpeg/seek_cued.mkv"
- const val CUELESS = "ffmpeg/seek_cueless.mkv"
-
- /** The fixtures are 30 s at 10 fps with a keyframe every second. */
- const val TARGET_US = 25_000_000L
- const val KEYFRAME_TOLERANCE_US = 1_500_000L
- }
-
- @Test
- fun cuedSeekLandsOnTheTargetWithoutWalkingTheFile() {
- withHarness(CUED) { harness ->
- harness.pumpUntilSampleDelivered()
- assertTrue(
- "playback should start at the beginning, got ${harness.lastSampleUs}us",
- harness.lastSampleUs < 500_000L
- )
-
- harness.resetByteCounter()
- harness.seekTo(TARGET_US)
- val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US)
-
- assertTrue("no sample delivered after seeking to ${TARGET_US}us", landed)
- assertTrue(
- "seek landed at ${harness.lastSampleUs}us, expected within " +
- "${KEYFRAME_TOLERANCE_US}us of ${TARGET_US}us",
- harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US
- )
- // An index seek reads the Cues plus the target cluster. A generic scan
- // has to read everything up to 83% of the file to get here.
- val budget = harness.fixtureSize / 3
- assertTrue(
- "seek read ${harness.bytesRead} bytes of a ${harness.fixtureSize}-byte file; " +
- "expected under $budget (index seek, not a scan)",
- harness.bytesRead < budget
- )
- // Reopening per read would mean one request per 64 KB in steady state.
- assertTrue("random-access opens after seek: ${harness.randomAccessOpens}", harness.randomAccessOpens <= 4)
- }
- }
-
- /**
- * Without Cues libavformat falls back to its generic scan, exactly as media3's
- * own MatroskaExtractor scans clusters. That is slow but it must still land on
- * the target rather than stalling or silently delivering from the wrong place.
- */
- @Test
- fun cuelessSeekStillReachesTheTarget() {
- withHarness(CUELESS) { harness ->
- harness.pumpUntilSampleDelivered()
- harness.seekTo(TARGET_US)
- val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US)
-
- assertTrue("cueless seek to ${TARGET_US}us delivered nothing", landed)
- assertTrue(
- "cueless seek landed at ${harness.lastSampleUs}us",
- harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US
- )
- }
- }
-
- /**
- * A download stored through SAF arrives as `content://`, which media3 reads
- * with `openAssetFileDescriptor`. The demuxer's index reads therefore have to
- * open a second descriptor on the same document while the loader still holds
- * one — the case a `file://` fixture cannot prove.
- */
- @Test
- fun cuedSeekLandsOnTheTargetThroughAContentUri() {
- withHarness(CUED, contentUri = true) { harness ->
- harness.pumpUntilSampleDelivered()
-
- harness.resetByteCounter()
- harness.seekTo(TARGET_US)
- val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US)
-
- assertTrue("no sample delivered after a content:// seek to ${TARGET_US}us", landed)
- assertTrue(
- "content:// seek landed at ${harness.lastSampleUs}us",
- harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US
- )
- assertTrue(
- "content:// index read opened no descriptor of its own",
- harness.randomAccessOpens > 0
- )
- val budget = harness.fixtureSize / 3
- assertTrue(
- "content:// seek read ${harness.bytesRead} bytes of a ${harness.fixtureSize}-byte file; " +
- "expected under $budget (index seek, not a scan)",
- harness.bytesRead < budget
- )
- }
- }
-
- @Test
- fun playbackFromTheStartDeliversEverySampleFromTheBeginning() {
- withHarness(CUED) { harness ->
- val timestamps = harness.pumpSamples(count = 40)
-
- assertTrue("expected samples from the start, got ${timestamps.size}", timestamps.size >= 40)
- assertTrue("first sample at ${timestamps.first()}us", timestamps.first() < 500_000L)
- // Delivery is in decode order, which is not presentation order once the
- // encoder emits B-frames; what must hold is that the set of samples is
- // gapless from zero (this fixture is a steady 10 fps).
- val sorted = timestamps.sorted()
- assertEquals(sorted.indices.map { it * 100_000L }, sorted)
- }
- }
-
- private fun withHarness(fixture: String, contentUri: Boolean = false, block: (Harness) -> Unit) {
- val instrumentation = InstrumentationRegistry.getInstrumentation()
- val context = instrumentation.targetContext
- val file = copyFixture(instrumentation.context, context, fixture)
- // The content:// case reads the same asset back through the provider, which
- // keeps its own copy: this process cannot write into the test APK's dirs.
- val uri = if (contentUri) FixtureContentProvider.uriFor(fixture) else Uri.fromFile(file)
- val harness = Harness(context, file, uri)
- try {
- harness.prepare()
- block(harness)
- } finally {
- harness.close()
- file.delete()
- }
- }
-
- private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
- val cacheDir = targetContext.cacheDir.also { it.mkdirs() }
- val output = File.createTempFile("ffmpeg-seek-fixture-", ".mkv", cacheDir)
- instrumentationContext.assets.open(fixture).use { input ->
- output.outputStream().use(input::copyTo)
- }
- return output
- }
-
- /**
- * Minimal stand-in for ProgressiveMediaPeriod's loader: it opens the data
- * source, feeds an ExtractorInput, and honours RESULT_SEEK by re-opening —
- * the same contract media3 implements in ExtractingLoadable.load().
- */
- private class Harness(context: Context, fixture: File, private val uri: Uri) {
- val fixtureSize = fixture.length()
- private val counting = CountingDataSourceFactory(DefaultDataSource.Factory(context))
- private val io = FfmpegRandomAccessSource(counting) { DataSpec(uri) }
- private val output = CapturingOutput()
- private val positionHolder = PositionHolder()
- private val extractor = requireNotNull(
- FfmpegExtractor.create(
- { FfmpegDemuxerPolicy.Preference.FFMPEG },
- DvConversionMode.DISABLED,
- DefaultSubtitleParserFactory(),
- AssHandler(),
- io
- )
- ) { "native ffmpeg demuxer unavailable" }
-
- private var dataSource: DataSource? = null
- private var input: ExtractorInput? = null
- private var opensBaseline = 0
-
- val lastSampleUs: Long
- get() = output.lastSampleUs
-
- val bytesRead: Long
- get() = counting.bytesRead
-
- val randomAccessOpens: Int
- get() = io.openCount - opensBaseline
-
- fun prepare() {
- openAt(0)
- assertTrue("fixture should be sniffed by the ffmpeg demuxer", extractor.sniff(input!!))
- input!!.resetPeekPosition()
- extractor.init(output)
- }
-
- fun resetByteCounter() {
- counting.reset()
- opensBaseline = io.openCount
- }
-
- fun seekTo(timeUs: Long) {
- // media3 restarts the load at the SeekMap's byte hint, then calls seek()
- // on the loader thread before the next read.
- val hint = output.seekMap?.getSeekPoints(timeUs)?.first?.position ?: 0L
- openAt(hint)
- extractor.seek(hint, timeUs)
- }
-
- fun pumpUntilSampleDelivered(): Boolean {
- val before = output.sampleCount
- return pump { output.sampleCount > before }
- }
-
- fun pumpUntilSampleAtLeast(timeUs: Long): Boolean = pump { output.lastSampleUs >= timeUs }
-
- fun pumpSamples(count: Int): List {
- pump { output.sampleTimestamps.size >= count }
- return output.sampleTimestamps.toList()
- }
-
- private fun pump(until: () -> Boolean): Boolean {
- repeat(200_000) {
- if (until()) return true
- when (extractor.read(input!!, positionHolder)) {
- Extractor.RESULT_SEEK -> openAt(positionHolder.position)
- Extractor.RESULT_END_OF_INPUT -> return until()
- else -> Unit
- }
- }
- return until()
- }
-
- private fun openAt(position: Long) {
- dataSource?.close()
- val source = counting.createDataSource()
- val remaining = source.open(DataSpec.Builder().setUri(uri).setPosition(position).build())
- dataSource = source
- input = DefaultExtractorInput(
- source,
- position,
- if (remaining == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else position + remaining
- )
- }
-
- fun close() {
- extractor.release()
- dataSource?.close()
- }
- }
-
- private class CapturingOutput : ExtractorOutput {
- val sampleTimestamps = mutableListOf()
- var seekMap: SeekMap? = null
- private set
-
- val sampleCount: Int
- get() = sampleTimestamps.size
-
- val lastSampleUs: Long
- get() = sampleTimestamps.lastOrNull() ?: C.TIME_UNSET
-
- override fun track(id: Int, type: Int): TrackOutput = CapturingTrack(type, sampleTimestamps)
-
- override fun endTracks() = Unit
-
- override fun seekMap(seekMap: SeekMap) {
- this.seekMap = seekMap
- }
- }
-
- private class CapturingTrack(private val type: Int, private val timestamps: MutableList) : TrackOutput {
- private val scratch = ByteArray(64 * 1024)
-
- override fun format(format: Format) = Unit
-
- override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean): Int = sampleData(input, length, allowEndOfInput, TrackOutput.SAMPLE_DATA_PART_MAIN)
-
- override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
- val read = input.read(scratch, 0, minOf(length, scratch.size))
- return if (read == C.RESULT_END_OF_INPUT) 0 else read
- }
-
- override fun sampleData(data: ParsableByteArray, length: Int) {
- sampleData(data, length, TrackOutput.SAMPLE_DATA_PART_MAIN)
- }
-
- override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
- data.skipBytes(length)
- }
-
- override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
- if (type == C.TRACK_TYPE_VIDEO) timestamps.add(timeUs)
- }
- }
-
- /** Counts every byte the demuxer pulls, through either read path. */
- private class CountingDataSourceFactory(private val delegate: DataSource.Factory) : DataSource.Factory {
- @Volatile private var counter = 0L
-
- val bytesRead: Long
- get() = counter
-
- fun reset() {
- counter = 0L
- }
-
- override fun createDataSource(): DataSource = Counting(delegate.createDataSource())
-
- private inner class Counting(private val inner: DataSource) : DataSource {
- override fun open(dataSpec: DataSpec): Long = inner.open(dataSpec)
-
- override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
- val read = inner.read(buffer, offset, length)
- if (read > 0) counter += read
- return read
- }
-
- override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener)
-
- override fun getUri(): Uri? = inner.uri
-
- override fun getResponseHeaders(): Map> = inner.responseHeaders
-
- override fun close() = inner.close()
- }
- }
-}
diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt
new file mode 100644
index 000000000..070ad98be
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt
@@ -0,0 +1,171 @@
+package com.edde746.plezy.exoplayer
+
+import android.net.Uri
+import android.os.Handler
+import android.os.HandlerThread
+import android.util.Log
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.ProgressiveMediaSource
+import androidx.media3.extractor.Extractor
+import androidx.media3.extractor.ExtractorsFactory
+import androidx.media3.extractor.mkv.MatroskaExtractor
+import androidx.media3.extractor.text.DefaultSubtitleParserFactory
+import androidx.test.platform.app.InstrumentationRegistry
+import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
+ *
+ * media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
+ * is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
+ * coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
+ * first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
+ * fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
+ * be retired. The second test drives the production wrapper stack and requires the seek to
+ * actually land.
+ */
+class MatroskaLateTracksSeekDeviceTest {
+ private companion object {
+ const val TAG = "MkvLateTracksSeek"
+ const val SEEK_TARGET_MS = 500L
+ const val SETTLE_MS = 2_000L
+ }
+
+ private class Session(
+ val handler: Handler,
+ val thread: HandlerThread,
+ val player: AtomicReference,
+ 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(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 onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
+ val value = AtomicReference()
+ 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
+ }
+}
diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt
index 7c1d96008..a9ef5873a 100644
--- a/android/app/src/main/cpp/CMakeLists.txt
+++ b/android/app/src/main/cpp/CMakeLists.txt
@@ -19,22 +19,20 @@ else()
target_link_libraries(dovi_bridge log)
endif()
-foreach(ffmpeg_library avcodec avformat avutil swresample)
+foreach(ffmpeg_library avcodec avutil swresample)
add_library(${ffmpeg_library} SHARED IMPORTED)
set_target_properties(${ffmpeg_library} PROPERTIES
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
endforeach()
add_library(ffmpegJNI SHARED
- media3_ffmpeg_decoder/ffmpeg_jni.cc
- media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc)
+ media3_ffmpeg_decoder/ffmpeg_jni.cc)
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
target_include_directories(ffmpegJNI PRIVATE
"${MPV_FFMPEG_ROOT}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
target_link_libraries(ffmpegJNI
avcodec
- avformat
avutil
swresample
android
diff --git a/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc b/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc
deleted file mode 100644
index 03da40bf1..000000000
--- a/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc
+++ /dev/null
@@ -1,983 +0,0 @@
-/*
- * Copyright (c) 2026 Plezy
- *
- * libavformat demuxing for media3. Wraps an AVFormatContext whose AVIOContext
- * is served by a media3 ExtractorInput, so FFmpeg parses the container while
- * media3 keeps owning decoders, renderers, and the whole playback surface.
- *
- * The seam used to be seeking. media3's Extractor is a pull API: an extractor
- * cannot move the input; it returns RESULT_SEEK and the loader re-opens the
- * data source. libavformat expects random access and seeks whenever it pleases
- * (MP4 moov at end of file, AVI idx1, MKV Cues, MPEG-PS binary search), so
- * serving it from the loader alone meant unwinding the in-flight libavformat
- * call on every backward jump and replaying the whole open — which a
- * one-shot lazy parse like matroska's Cues cannot survive (#2096).
- *
- * libavformat now gets what it actually requires: the Kotlin side hands this
- * shim a random-access byte source (FfmpegRandomAccessSource), so every AVIO
- * read succeeds at the position libavformat asked for and nothing is ever
- * unwound. Two paths serve reads, chosen per read:
- *
- * - The loader's ExtractorInput when it already stands at that position. This
- * is the steady state for sample delivery, and it keeps media3's byte
- * accounting, back-pressure and load-error policy in charge of streaming.
- * - The random-access source otherwise (header, index, post-seek jumps).
- *
- * Kotlin nudges the loader back into step after a jump by returning
- * RESULT_SEEK to nativeLogicalPosition(); that is an optimization, never a
- * correctness requirement, so no budgets or retries guard it.
- */
-#include
-#include
-
-#include
-#include
-#include
-
-extern "C" {
-#ifdef __cplusplus
-#define __STDC_CONSTANT_MACROS
-#ifdef _STDINT_H
-#undef _STDINT_H
-#endif
-#include
-#endif
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-}
-
-#define LOG_TAG "ffmpeg_demuxer"
-#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
-#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
-#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
-
-namespace {
-
-// readPacket() result codes written to out[0]. Negative values are raw
-// AVERROR codes; ERR_JAVA signals the byte source threw and Kotlin must
-// rethrow the stored IOException.
-const jint CODE_PACKET = 0;
-const jint CODE_EOF = 1;
-const jint CODE_GROW = 2;
-const jint ERR_NOT_OPEN = -101;
-const jint ERR_JAVA = -102;
-
-// streamInfo() long[] layout. Keep in sync with FfmpegDemuxerJni.kt.
-const int INFO_TRACK_TYPE = 0; // 0 video, 1 audio, 2 text, -1 unsupported
-const int INFO_WIDTH = 1;
-const int INFO_HEIGHT = 2;
-const int INFO_PAR_NUM = 3;
-const int INFO_PAR_DEN = 4;
-const int INFO_FPS_NUM = 5;
-const int INFO_FPS_DEN = 6;
-const int INFO_SAMPLE_RATE = 7;
-const int INFO_CHANNELS = 8;
-const int INFO_PCM_ENCODING = 9; // media3 C.ENCODING_* value, -1 when n/a
-const int INFO_ROTATION = 10;
-const int INFO_SELECTION_FLAGS = 11;
-const int INFO_ROLE_FLAGS = 12;
-const int INFO_BITRATE = 13;
-const int INFO_DOVI_PROFILE = 14; // DV profile from dvcC/dvvC side data, -1 when none
-const int INFO_DOVI_LEVEL = 15;
-const int INFO_LATM = 16; // 1 when the audio stream is LOAS/LATM-framed AAC
-const int INFO_LENGTH = 17;
-
-// Android AudioFormat encoding constants (media3 C.ENCODING_* mirror them).
-const jint PCM_ENCODING_NONE = -1;
-const jint PCM_ENCODING_8BIT = 3;
-const jint PCM_ENCODING_16BIT = 2;
-const jint PCM_ENCODING_FLOAT = 4;
-const jint PCM_ENCODING_24BIT_PACKED = 21;
-const jint PCM_ENCODING_32BIT = 22;
-const jint PCM_ENCODING_ALAW = 11;
-const jint PCM_ENCODING_ULAW = 12;
-
-// media3 C.SELECTION_FLAG_* and C.ROLE_FLAG_* values.
-const jint SELECTION_FLAG_DEFAULT = 1;
-const jint SELECTION_FLAG_FORCED = 2;
-const jint ROLE_FLAG_MAIN = 1;
-const jint ROLE_FLAG_DESCRIPTION = 64; // C.ROLE_FLAG_DESCRIPTION
-const jint ROLE_FLAG_HARD_OF_HEARING = 512; // C.ROLE_FLAG_HARD_OF_HEARING
-
-constexpr jint kReadChunkBytes = 64 * 1024;
-
-struct DemuxState {
- JavaVM* vm = nullptr;
- jobject input = nullptr;
- jmethodID midPosition = nullptr;
- jmethodID midRead = nullptr;
- jmethodID midReadAt = nullptr;
- jmethodID midLength = nullptr;
- jbyteArray readBuffer = nullptr;
-
- AVFormatContext* format = nullptr;
- AVIOContext* avio = nullptr;
- bool opened = false;
-
- // Byte position libavformat will read next. Authoritative: the AVIO seek
- // callback moves it, and both read paths serve exactly this offset.
- int64_t logicalPos = 0;
- // format->start_time in microseconds; subtracted from delivered pts so
- // media3's zero-based timeline matches the duration we report (MPEG-PS/TS
- // containers commonly start at a nonzero PCR).
- int64_t startTimeUs = 0;
-
- AVPacket* packet = nullptr;
- AVPacket* filteredPacket = nullptr;
- // A packet that outgrew the Java-side buffer: already read and filtered,
- // waiting for redelivery after CODE_GROW. Without this flag the retry's
- // av_read_frame would silently drop it.
- bool packetPending = false;
-
- // Annex-B conversion for H264/HEVC streams stored length-prefixed
- // (matroska/mp4 family); one context per stream, null when none needed.
- AVBSFContext** bsf = nullptr;
- unsigned nbStreams = 0;
-
- bool javaError = false;
-};
-
-// One demuxer at a time: a player session owns exactly one progressive source
-// and the extractor releases its state before another can open.
-DemuxState* gState = nullptr;
-JavaVM* gVm = nullptr;
-
-// Serializes JNI entry points against closeState: the fallback path can
-// release the player while the loader thread is inside a native call, and
-// deleting DemuxState under it is a use-after-free (observed as a scudo
-// invalid-chunk abort during MPV fallback teardown).
-pthread_mutex_t gStateMutex = PTHREAD_MUTEX_INITIALIZER;
-
-struct StateLock {
- StateLock() { pthread_mutex_lock(&gStateMutex); }
- ~StateLock() { pthread_mutex_unlock(&gStateMutex); }
-};
-
-JNIEnv* envFor() {
- JNIEnv* env = nullptr;
- if (gVm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) return nullptr;
- return env;
-}
-
-bool javaPending(JNIEnv* env) { return env->ExceptionCheck() == JNI_TRUE; }
-
-void javaClear(JNIEnv* env) {
- if (env->ExceptionCheck() == JNI_TRUE) env->ExceptionClear();
-}
-
-jlong callPosition(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midPosition); }
-
-jlong callLength(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midLength); }
-
-// Returns bytes read, 0 at end of input, or -1 when the input failed (the
-// Kotlin proxy stored the IOException message).
-jint callRead(JNIEnv* env, DemuxState* s, jint length) {
- jint n = env->CallIntMethod(s->input, s->midRead, s->readBuffer, length);
- if (javaPending(env)) {
- javaClear(env);
- s->javaError = true;
- return -1;
- }
- return n;
-}
-
-// Same contract as callRead, but at an absolute position: the Kotlin source
-// reopens its handle when the position is not where it stands.
-jint callReadAt(JNIEnv* env, DemuxState* s, int64_t position, jint length) {
- jint n = env->CallIntMethod(s->input, s->midReadAt, static_cast(position), s->readBuffer, length);
- if (javaPending(env)) {
- javaClear(env);
- s->javaError = true;
- return -1;
- }
- return n;
-}
-
-int avioReadPacket(void* opaque, uint8_t* buf, int bufSize) {
- DemuxState* s = static_cast(opaque);
- JNIEnv* env = envFor();
- if (env == nullptr) return AVERROR(EIO);
- if (bufSize <= 0) return 0;
- // After the byte source has failed once in this native call, fail every
- // further read immediately: libavformat's internal recovery (matroska
- // resync) would drive the dead source again, clobbering the stored
- // IOException message — and were the source half-alive, could silently
- // resync past the failed range. Failing fast makes the whole entry return
- // ERR_JAVA, and media3's retry resumes at the exact failed position.
- if (s->javaError) return AVERROR(EIO);
-
- const jint want = bufSize < kReadChunkBytes ? bufSize : kReadChunkBytes;
- // Prefer the loader's input when it already stands here: those bytes then
- // count as media3's own load progress, which is what drives its buffering
- // and back-pressure. Anywhere else, read at the absolute position.
- const int64_t inputPos = callPosition(env, s);
- if (javaPending(env)) {
- javaClear(env);
- s->javaError = true;
- return AVERROR(EIO);
- }
- const jint n = inputPos == s->logicalPos ? callRead(env, s, want) : callReadAt(env, s, s->logicalPos, want);
- if (n < 0 || s->javaError) {
- // A negative count is the Input contract for a byte source that threw:
- // the Kotlin proxy caught the IOException and stored its message. Mark
- // javaError so the JNI entry point returns ERR_JAVA and media3's
- // load-error policy retries the load; a bare AVERROR would read as a
- // malformed container, which media3 never retries (#2113).
- s->javaError = true;
- return AVERROR(EIO);
- }
- if (n == 0) return AVERROR_EOF;
-
- env->GetByteArrayRegion(s->readBuffer, 0, n, reinterpret_cast(buf));
- s->logicalPos += n;
- return n;
-}
-
-int64_t avioSeek(void* opaque, int64_t offset, int whence) {
- DemuxState* s = static_cast(opaque);
- JNIEnv* env = envFor();
- if (env == nullptr) return AVERROR(EIO);
-
- whence &= ~AVSEEK_FORCE;
- if (whence == AVSEEK_SIZE) {
- int64_t length = callLength(env, s);
- if (javaPending(env)) {
- javaClear(env);
- s->javaError = true;
- return -1;
- }
- return length < 0 ? -1 : length;
- }
-
- int64_t target;
- if (whence == SEEK_CUR) {
- target = s->logicalPos + offset;
- } else if (whence == SEEK_END) {
- int64_t length = callLength(env, s);
- if (javaPending(env)) {
- javaClear(env);
- s->javaError = true;
- return AVERROR(EIO);
- }
- if (length < 0) return AVERROR(EIO);
- target = length + offset;
- } else {
- target = offset;
- }
-
- // Pure bookkeeping: the next read serves this position from whichever path
- // can, so a seek can no longer fail or need reconciling. avio invalidates
- // its own buffers after this successful callback.
- s->logicalPos = target;
- return target;
-}
-
-// A failed byte-source read latches AVIOContext error state: fill_buffer
-// stores the AVERROR in pb->error, sets pb->eof_reached, and never drives the
-// read callback again while either is set (aviobuf.c). The Kotlin side maps
-// ERR_JAVA to an IOException that media3's load-error policy retries with the
-// source reopened, so the latch must be dropped when the retry re-enters —
-// otherwise the first retry re-fails on the stale error without ever reaching
-// the byte source (#2113). A genuine end of file latches eof_reached with
-// error == 0 and is deliberately left alone.
-void clearLatchedIoError(DemuxState* s) {
- if (s->format == nullptr || s->format->pb == nullptr) return;
- if (s->format->pb->error >= 0) return;
- s->format->pb->error = 0;
- s->format->pb->eof_reached = 0;
-}
-
-// Probing (ffio_ensure_seekback) can swap the AVIO buffer for a larger
-// allocation and free the original, so the pointer handed to
-// avio_alloc_context may be stale. Free whichever buffer the context
-// currently holds, exactly once.
-void freeAvio(DemuxState* s) {
- if (s->avio == nullptr) return;
- av_freep(&s->avio->buffer);
- avio_context_free(&s->avio);
-}
-
-void closeState() {
- DemuxState* s = gState;
- if (s == nullptr) return; // Close without (or after) an open is a no-op.
- JNIEnv* env = envFor();
- if (s->filteredPacket != nullptr) {
- av_packet_free(&s->filteredPacket);
- }
- if (s->bsf != nullptr) {
- for (unsigned i = 0; i < s->nbStreams; i++) {
- if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]);
- }
- av_freep(&s->bsf);
- }
- if (s->packet != nullptr) {
- av_packet_free(&s->packet);
- }
- if (s->format != nullptr) {
- avformat_close_input(&s->format);
- }
- freeAvio(s);
- if (env != nullptr && s->input != nullptr) {
- if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer);
- env->DeleteGlobalRef(s->input);
- }
- delete s;
- gState = nullptr;
-}
-
-const char* videoMime(AVCodecID id) {
- switch (id) {
- case AV_CODEC_ID_H264:
- return "video/avc";
- case AV_CODEC_ID_HEVC:
- return "video/hevc";
- case AV_CODEC_ID_MPEG4:
- return "video/mp4v-es";
- case AV_CODEC_ID_MPEG2VIDEO:
- return "video/mpeg2";
- case AV_CODEC_ID_MJPEG:
- return "video/mjpeg";
- case AV_CODEC_ID_VP8:
- return "video/x-vnd.on2.vp8";
- case AV_CODEC_ID_VP9:
- return "video/x-vnd.on2.vp9";
- case AV_CODEC_ID_AV1:
- return "video/av01";
- case AV_CODEC_ID_H263:
- return "video/3gpp";
- default:
- return nullptr;
- }
-}
-
-const char* audioMime(AVCodecID id, jint* pcmEncoding) {
- *pcmEncoding = PCM_ENCODING_NONE;
- switch (id) {
- case AV_CODEC_ID_AAC:
- return "audio/mp4a-latm";
- case AV_CODEC_ID_AAC_LATM:
- // LOAS/LATM framing. Kotlin wraps the track with LatmTrackOutput, which
- // unwraps to raw AAC access units and emits the real decoder Format;
- // this mime only marks the stream as supported.
- return "audio/mp4a-latm";
- case AV_CODEC_ID_MP3:
- return "audio/mpeg";
- case AV_CODEC_ID_MP2:
- return "audio/mpeg-L2";
- case AV_CODEC_ID_AC3:
- return "audio/ac3";
- case AV_CODEC_ID_EAC3:
- return "audio/eac3";
- case AV_CODEC_ID_DTS:
- return "audio/vnd.dts";
- case AV_CODEC_ID_TRUEHD:
- return "audio/true-hd";
- case AV_CODEC_ID_FLAC:
- return "audio/flac";
- case AV_CODEC_ID_OPUS:
- return "audio/opus";
- case AV_CODEC_ID_VORBIS:
- return "audio/vorbis";
- case AV_CODEC_ID_ALAC:
- return "audio/alac";
- case AV_CODEC_ID_AMR_NB:
- return "audio/amr";
- case AV_CODEC_ID_AMR_WB:
- return "audio/amr-wb";
- case AV_CODEC_ID_PCM_S16LE:
- *pcmEncoding = PCM_ENCODING_16BIT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_S16BE:
- *pcmEncoding = PCM_ENCODING_16BIT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_S8:
- *pcmEncoding = PCM_ENCODING_8BIT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_U8:
- *pcmEncoding = PCM_ENCODING_8BIT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_S24LE:
- *pcmEncoding = PCM_ENCODING_24BIT_PACKED;
- return "audio/raw";
- case AV_CODEC_ID_PCM_S32LE:
- *pcmEncoding = PCM_ENCODING_32BIT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_F32LE:
- *pcmEncoding = PCM_ENCODING_FLOAT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_F32BE:
- *pcmEncoding = PCM_ENCODING_FLOAT;
- return "audio/raw";
- case AV_CODEC_ID_PCM_ALAW:
- *pcmEncoding = PCM_ENCODING_ALAW;
- return "audio/raw";
- case AV_CODEC_ID_PCM_MULAW:
- *pcmEncoding = PCM_ENCODING_ULAW;
- return "audio/raw";
- default:
- return nullptr;
- }
-}
-
-const char* subtitleMime(AVCodecID id) {
- switch (id) {
- case AV_CODEC_ID_SUBRIP:
- return "application/x-subrip";
- case AV_CODEC_ID_ASS:
- case AV_CODEC_ID_SSA:
- return "text/x-ssa";
- case AV_CODEC_ID_WEBVTT:
- return "text/vtt";
- case AV_CODEC_ID_HDMV_PGS_SUBTITLE:
- return "application/pgs";
- case AV_CODEC_ID_DVD_SUBTITLE:
- // media3's VobsubParser takes the .idx text (Matroska CodecPrivate,
- // surfaced verbatim as extradata) as initialization data and the raw
- // SPU payloads as samples — exactly what this path delivers.
- return "application/vobsub";
- default:
- // DVB subtitles are deliberately dropped: media3's DvbParser expects
- // the TS/PMT 5-byte config, not Matroska CodecPrivate, and DVB rides
- // TS in practice — which stays on media3's extractor.
- return nullptr;
- }
-}
-
-// Maps an attachment ordinal to its stream index. Attachment payloads
-// (embedded fonts) ride extradata and are fully parsed during
-// avformat_open_input; these streams never produce packets.
-int attachmentStreamIndex(AVFormatContext* format, jint ordinal) {
- jint seen = 0;
- for (unsigned i = 0; i < format->nb_streams; i++) {
- AVCodecParameters* params = format->streams[i]->codecpar;
- if (params->codec_type != AVMEDIA_TYPE_ATTACHMENT) continue;
- if (params->extradata == nullptr || params->extradata_size <= 0) continue;
- if (seen++ == ordinal) return static_cast(i);
- }
- return -1;
-}
-
-} // namespace
-
-extern "C" {
-
-JNIEXPORT jstring JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeProbeFormat(JNIEnv* env, jobject thiz, jbyteArray header) {
- StateLock lock;
- jsize size = env->GetArrayLength(header);
- std::vector padded(static_cast(size) + AVPROBE_PADDING_SIZE, 0);
- env->GetByteArrayRegion(header, 0, size, reinterpret_cast(padded.data()));
-
- AVProbeData probe{};
- probe.buf = padded.data();
- probe.buf_size = size;
- probe.filename = "";
- int score = 0;
- const AVInputFormat* format = av_probe_input_format2(&probe, 1, &score);
- if (format == nullptr || score <= 0) return nullptr;
- return env->NewStringUTF(format->name);
-}
-
-// Returns 0 on success or a negative AVERROR (ERR_JAVA when the byte source
-// itself threw). The stream count is NOT the return value: positive counts
-// would share the result-code namespace, so callers use nativeStreamCount().
-JNIEXPORT jint JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeOpen(JNIEnv* env, jobject thiz, jobject input) {
- StateLock lock;
- if (gVm == nullptr) {
- env->GetJavaVM(&gVm);
- av_log_set_level(AV_LOG_ERROR);
- }
- if (gState == nullptr) {
- gState = new DemuxState();
- gState->vm = gVm;
- }
- DemuxState* s = gState;
-
- // FindClass on the declared interface (not GetObjectClass on the concrete
- // proxy): JNI dispatches interface method IDs virtually, and the named
- // class is what the shrinker keep in proguard-rules.pro pins.
- jclass inputClass = env->FindClass("com/edde746/plezy/exoplayer/FfmpegDemuxerJni$Input");
- if (inputClass == nullptr) {
- javaClear(env);
- return ERR_NOT_OPEN;
- }
- jmethodID midPosition = env->GetMethodID(inputClass, "position", "()J");
- jmethodID midRead = env->GetMethodID(inputClass, "read", "([BI)I");
- jmethodID midReadAt = env->GetMethodID(inputClass, "readAt", "(J[BI)I");
- jmethodID midLength = env->GetMethodID(inputClass, "length", "()J");
- env->DeleteLocalRef(inputClass);
- if (midPosition == nullptr || midRead == nullptr || midReadAt == nullptr || midLength == nullptr) {
- javaClear(env);
- return ERR_NOT_OPEN;
- }
-
- // A fresh open replaces any previous proxy binding.
- if (s->input != nullptr) {
- if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer);
- env->DeleteGlobalRef(s->input);
- s->input = nullptr;
- s->readBuffer = nullptr;
- }
- s->input = env->NewGlobalRef(input);
- s->midPosition = midPosition;
- s->midRead = midRead;
- s->midReadAt = midReadAt;
- s->midLength = midLength;
- jbyteArray readBuffer = env->NewByteArray(kReadChunkBytes);
- s->readBuffer = static_cast(env->NewGlobalRef(readBuffer));
- env->DeleteLocalRef(readBuffer);
-
- if (s->packet == nullptr) {
- s->packet = av_packet_alloc();
- s->filteredPacket = av_packet_alloc();
- if (s->packet == nullptr || s->filteredPacket == nullptr) return AVERROR(ENOMEM);
- }
-
- // One AVIOContext per item: a stale buffer or a sticky error from the
- // previous item must not leak into this open, and reallocating is cheaper
- // than reasoning about which of libavformat's internal fields to reset.
- freeAvio(s);
- constexpr int kAvioBufferSize = 64 * 1024;
- unsigned char* avioBuffer = static_cast(av_malloc(kAvioBufferSize));
- if (avioBuffer == nullptr) return AVERROR(ENOMEM);
- s->avio = avio_alloc_context(
- avioBuffer, kAvioBufferSize, /*write_flag=*/0, s, avioReadPacket,
- /*write_packet=*/nullptr, avioSeek);
- if (s->avio == nullptr) {
- av_freep(&avioBuffer);
- return AVERROR(ENOMEM);
- }
-
- s->javaError = false;
- s->packetPending = false;
- s->logicalPos = 0;
- // Nothing is ever resumed across calls now: every read succeeds at the
- // position libavformat asked for, so open_input and find_stream_info each
- // run once, to completion.
- avformat_close_input(&s->format);
- s->opened = false;
-
- s->format = avformat_alloc_context();
- if (s->format == nullptr) return AVERROR(ENOMEM);
- s->format->pb = s->avio;
- // Generate missing PTS: some raw streams (MPEG-TS after a seek) present
- // packets without timestamps that media3 cannot schedule.
- s->format->flags |= AVFMT_FLAG_GENPTS;
-
- int err = avformat_open_input(&s->format, "", nullptr, nullptr);
- if (s->javaError) return ERR_JAVA;
- if (err < 0) {
- LOGW("avformat_open_input failed: %d", err);
- return err;
- }
-
- err = avformat_find_stream_info(s->format, nullptr);
- if (s->javaError) return ERR_JAVA;
- if (err < 0) {
- LOGW("avformat_find_stream_info failed: %d", err);
- return err;
- }
- // Zero-base the timeline: delivered pts subtract this so media3 sees
- // [0, duration] even when the container starts at a nonzero PCR/PTS.
- s->startTimeUs = s->format->start_time != AV_NOPTS_VALUE ? s->format->start_time : 0;
- s->opened = true;
-
- bool anySupported = false;
- for (unsigned i = 0; i < s->format->nb_streams; i++) {
- AVStream* stream = s->format->streams[i];
- AVCodecID codecId = stream->codecpar->codec_id;
- jint pcmEncoding;
- bool supported = videoMime(codecId) != nullptr || audioMime(codecId, &pcmEncoding) != nullptr ||
- subtitleMime(codecId) != nullptr;
- // ID3/MKV attached pictures surface as video streams; media3 cannot
- // render them as tracks.
- if (stream->attached_pic.size > 0) supported = false;
- if (!supported) {
- LOGW("Dropping stream %u: unsupported codec %s", i, avcodec_get_name(codecId));
- stream->discard = AVDISCARD_ALL;
- } else {
- anySupported = true;
- }
- }
- if (!anySupported) {
- // Fail fast so the session falls back (mpv decodes WMV and friends)
- // instead of buffering an empty track list forever.
- LOGW("No supported streams; refusing container");
- avformat_close_input(&s->format);
- s->opened = false;
- return AVERROR(ENOSYS);
- }
- // MediaCodec requires Annex-B start-code framing for H264/HEVC, while the
- // matroska/mp4 family stores length-prefixed NAL units and libavformat
- // hands those through verbatim (ffmpeg muxers insert the same
- // *_mp4toannexb bitstream filters in this situation). Filter those streams
- // here so every packet leaving the shim is decoder-ready, and adopt the
- // filter's rewritten parameters so extradata surfaces as Annex-B csd too.
- if (s->bsf != nullptr) {
- for (unsigned i = 0; i < s->nbStreams; i++) {
- if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]);
- }
- av_freep(&s->bsf);
- }
- s->nbStreams = s->format->nb_streams;
- s->bsf = static_cast(av_calloc(s->nbStreams, sizeof(*s->bsf)));
- if (s->bsf == nullptr) return AVERROR(ENOMEM);
- for (unsigned i = 0; i < s->nbStreams; i++) {
- AVCodecID codecId = s->format->streams[i]->codecpar->codec_id;
- const char* filterName = codecId == AV_CODEC_ID_H264 ? "h264_mp4toannexb"
- : codecId == AV_CODEC_ID_HEVC ? "hevc_mp4toannexb"
- // AVI-family MPEG-4 ASP commonly packs several VOPs into one chunk;
- // hardware decoders silently drop those stacks (observed as a decoder
- // hang with zero output), so unpack them like every ffmpeg pipeline.
- : codecId == AV_CODEC_ID_MPEG4 ? "mpeg4_unpack_bframes"
- : nullptr;
- const AVBitStreamFilter* filter = av_bsf_get_by_name(filterName);
- AVBSFContext* ctx = nullptr;
- if (filter == nullptr || av_bsf_alloc(filter, &ctx) < 0 ||
- avcodec_parameters_copy(ctx->par_in, s->format->streams[i]->codecpar) < 0) {
- if (ctx != nullptr) av_bsf_free(&ctx);
- continue;
- }
- ctx->time_base_in = s->format->streams[i]->time_base;
- if (av_bsf_init(ctx) < 0) {
- av_bsf_free(&ctx);
- continue;
- }
- avcodec_parameters_copy(s->format->streams[i]->codecpar, ctx->par_out);
- s->format->streams[i]->time_base = ctx->time_base_out;
- s->bsf[i] = ctx;
- }
-
- return 0; // Success; prepareTracks() reads the count via nativeStreamCount().
-}
-
-JNIEXPORT void JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeClose(JNIEnv* env, jobject thiz) {
- StateLock lock;
- closeState();
-}
-
-JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamCount(JNIEnv* env, jobject thiz) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr) return ERR_NOT_OPEN;
- return static_cast(gState->format->nb_streams);
-}
-
-JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeDurationUs(JNIEnv* env, jobject thiz) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr) return -1;
- int64_t duration = gState->format->duration;
- return duration == AV_NOPTS_VALUE ? -1 : duration;
-}
-
-// Returns true when the stream is usable and filled the out arrays.
-JNIEXPORT jboolean JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamInfo(
- JNIEnv* env, jobject thiz, jint index, jlongArray numbers, jobjectArray strings) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr || index < 0 ||
- index >= static_cast(gState->format->nb_streams)) {
- return JNI_FALSE;
- }
- AVStream* stream = gState->format->streams[index];
- AVCodecParameters* params = stream->codecpar;
-
- jint trackType;
- const char* mime = nullptr;
- jint pcmEncoding = PCM_ENCODING_NONE;
- switch (params->codec_type) {
- case AVMEDIA_TYPE_VIDEO:
- trackType = 0;
- mime = videoMime(params->codec_id);
- break;
- case AVMEDIA_TYPE_AUDIO:
- trackType = 1;
- mime = audioMime(params->codec_id, &pcmEncoding);
- break;
- case AVMEDIA_TYPE_SUBTITLE:
- trackType = 2;
- mime = subtitleMime(params->codec_id);
- break;
- default:
- return JNI_FALSE;
- }
- if (mime == nullptr) return JNI_FALSE;
-
- jlong info[INFO_LENGTH] = {};
- info[INFO_TRACK_TYPE] = trackType;
- info[INFO_WIDTH] = params->width;
- info[INFO_HEIGHT] = params->height;
- AVRational par = params->sample_aspect_ratio;
- if (par.num <= 0 || par.den <= 0) par = stream->sample_aspect_ratio;
- info[INFO_PAR_NUM] = par.num > 0 ? par.num : 1;
- info[INFO_PAR_DEN] = par.num > 0 ? par.den : 1;
- AVRational fps = stream->avg_frame_rate;
- info[INFO_FPS_NUM] = fps.num > 0 ? fps.num : 0;
- info[INFO_FPS_DEN] = fps.num > 0 ? fps.den : 0;
- info[INFO_SAMPLE_RATE] = params->sample_rate > 0 ? params->sample_rate : -1;
- info[INFO_CHANNELS] = params->ch_layout.nb_channels > 0 ? params->ch_layout.nb_channels : -1;
- info[INFO_PCM_ENCODING] = pcmEncoding;
- info[INFO_LATM] = params->codec_id == AV_CODEC_ID_AAC_LATM ? 1 : 0;
-
- jint rotation = 0;
- AVPacketSideData* sideData = nullptr;
- for (int i = 0; i < params->nb_coded_side_data; i++) {
- if (params->coded_side_data[i].type == AV_PKT_DATA_DISPLAYMATRIX) {
- sideData = ¶ms->coded_side_data[i];
- break;
- }
- }
- if (sideData != nullptr) {
- double angle = av_display_rotation_get(reinterpret_cast(sideData->data));
- if (!std::isnan(angle)) rotation = static_cast(-lround(angle));
- }
- info[INFO_ROTATION] = rotation;
-
- jint selectionFlags = 0;
- jint roleFlags = 0;
- if (stream->disposition & AV_DISPOSITION_DEFAULT) selectionFlags |= SELECTION_FLAG_DEFAULT;
- if (stream->disposition & AV_DISPOSITION_FORCED) selectionFlags |= SELECTION_FLAG_FORCED;
- if (stream->disposition & AV_DISPOSITION_HEARING_IMPAIRED) roleFlags |= ROLE_FLAG_HARD_OF_HEARING;
- if (stream->disposition & AV_DISPOSITION_VISUAL_IMPAIRED) roleFlags |= ROLE_FLAG_DESCRIPTION;
- if (selectionFlags == 0 && trackType != 2) roleFlags |= ROLE_FLAG_MAIN;
- info[INFO_SELECTION_FLAGS] = selectionFlags;
- info[INFO_ROLE_FLAGS] = roleFlags;
- info[INFO_BITRATE] = params->bit_rate > 0 ? params->bit_rate : -1;
-
- // Dolby Vision configuration (dvcC/dvvC), surfaced by libavformat as an
- // unpacked AVDOVIDecoderConfigurationRecord — one byte per field, not the
- // bit-packed ISOBMFF box layout. Kotlin mirrors media3's DolbyVisionConfig
- // to publish video/dolby-vision plus the RFC 6381 codecs string.
- info[INFO_DOVI_PROFILE] = -1;
- info[INFO_DOVI_LEVEL] = -1;
- for (int i = 0; i < params->nb_coded_side_data; i++) {
- AVPacketSideData* sd = ¶ms->coded_side_data[i];
- if (sd->type == AV_PKT_DATA_DOVI_CONF && sd->size >= sizeof(AVDOVIDecoderConfigurationRecord)) {
- const AVDOVIDecoderConfigurationRecord* dovi =
- reinterpret_cast(sd->data);
- info[INFO_DOVI_PROFILE] = dovi->dv_profile;
- info[INFO_DOVI_LEVEL] = dovi->dv_level;
- break;
- }
- }
-
- env->SetLongArrayRegion(numbers, 0, INFO_LENGTH, info);
-
- const char* language = nullptr;
- const char* label = nullptr;
- if (stream->metadata != nullptr) {
- AVDictionaryEntry* entry = av_dict_get(stream->metadata, "language", nullptr, 0);
- if (entry != nullptr) language = entry->value;
- entry = av_dict_get(stream->metadata, "title", nullptr, 0);
- if (entry != nullptr) label = entry->value;
- }
- env->SetObjectArrayElement(strings, 0, env->NewStringUTF(mime));
- env->SetObjectArrayElement(strings, 1, language != nullptr ? env->NewStringUTF(language) : nullptr);
- env->SetObjectArrayElement(strings, 2, label != nullptr ? env->NewStringUTF(label) : nullptr);
- return JNI_TRUE;
-}
-
-JNIEXPORT jbyteArray JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamExtradata(JNIEnv* env, jobject thiz, jint index) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr || index < 0 ||
- index >= static_cast(gState->format->nb_streams)) {
- return nullptr;
- }
- AVCodecParameters* params = gState->format->streams[index]->codecpar;
- // libavformat carries the AVI stream's WAVEFORMATEX header as MP3
- // extradata. MediaCodec mp3 decoders expect either no csd or the two-byte
- // ISO header, and report a WAVEFORMATEX blob as corrupt input
- // (C2_CORRUPTED on the first work item), so drop it.
- if (params->codec_id == AV_CODEC_ID_MP3) return nullptr;
- if (params->extradata == nullptr || params->extradata_size <= 0) return nullptr;
- jbyteArray result = env->NewByteArray(params->extradata_size);
- env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast(params->extradata));
- return result;
-}
-
-JNIEXPORT jint JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentCount(JNIEnv* env, jobject thiz) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr) return 0;
- jint count = 0;
- while (attachmentStreamIndex(gState->format, count) >= 0) count++;
- return count;
-}
-
-// Fills strings with [filename, mimetype] and returns the payload size in
-// bytes, or -1 when the ordinal is out of range.
-JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentInfo(
- JNIEnv* env, jobject thiz, jint ordinal, jobjectArray strings) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr) return -1;
- int index = attachmentStreamIndex(gState->format, ordinal);
- if (index < 0) return -1;
- AVStream* stream = gState->format->streams[index];
- const char* filename = nullptr;
- const char* mimetype = nullptr;
- if (stream->metadata != nullptr) {
- AVDictionaryEntry* entry = av_dict_get(stream->metadata, "filename", nullptr, 0);
- if (entry != nullptr) filename = entry->value;
- entry = av_dict_get(stream->metadata, "mimetype", nullptr, 0);
- if (entry != nullptr) mimetype = entry->value;
- }
- env->SetObjectArrayElement(strings, 0, filename != nullptr ? env->NewStringUTF(filename) : nullptr);
- env->SetObjectArrayElement(strings, 1, mimetype != nullptr ? env->NewStringUTF(mimetype) : nullptr);
- return stream->codecpar->extradata_size;
-}
-
-JNIEXPORT jbyteArray JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentData(JNIEnv* env, jobject thiz, jint ordinal) {
- StateLock lock;
- if (gState == nullptr || gState->format == nullptr) return nullptr;
- int index = attachmentStreamIndex(gState->format, ordinal);
- if (index < 0) return nullptr;
- AVCodecParameters* params = gState->format->streams[index]->codecpar;
- jbyteArray result = env->NewByteArray(params->extradata_size);
- if (result == nullptr) return nullptr;
- env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast(params->extradata));
- return result;
-}
-
-// Reads one packet. out[0] carries the result code; on CODE_PACKET the packet
-// bytes were written into the byte array and out carries
-// [1]=streamIndex [2]=ptsUs (start-time normalized; packets without any
-// timestamp are dropped, media3 cannot schedule them) [3]=flags [4]=size
-// [5]=packet position in input coordinates [6]=durationUs (-1 unknown).
-JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeReadPacket(
- JNIEnv* env, jobject thiz, jbyteArray buffer, jlongArray out) {
- StateLock lock;
- DemuxState* s = gState;
- if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
-
- jint capacity = env->GetArrayLength(buffer);
- jlong result[7] = {};
- s->javaError = false;
- clearLatchedIoError(s);
- while (true) {
- if (s->packetPending) {
- // Oversized packet held across a CODE_GROW round trip: deliver it now
- // instead of reading (and losing) a new frame.
- s->packetPending = false;
- } else {
- av_packet_unref(s->packet);
- int err = av_read_frame(s->format, s->packet);
- if (err == AVERROR_EOF) {
- result[0] = CODE_EOF;
- env->SetLongArrayRegion(out, 0, 7, result);
- return CODE_EOF;
- }
- if (err < 0 || s->javaError) {
- result[0] = s->javaError ? ERR_JAVA : err;
- env->SetLongArrayRegion(out, 0, 7, result);
- return s->javaError ? ERR_JAVA : err;
- }
- if (s->packet->size <= 0) continue;
-
- if (s->format->streams[s->packet->stream_index]->discard >= AVDISCARD_ALL) continue;
-
- AVBSFContext* bsf = s->bsf != nullptr && s->packet->stream_index < static_cast(s->nbStreams)
- ? s->bsf[s->packet->stream_index]
- : nullptr;
- if (bsf != nullptr) {
- // send consumes the packet reference; move the filtered result back so
- // the delivery code below keeps a single code path.
- int filterErr = av_bsf_send_packet(bsf, s->packet);
- if (filterErr >= 0) {
- av_packet_unref(s->packet);
- filterErr = av_bsf_receive_packet(bsf, s->filteredPacket);
- if (filterErr == AVERROR(EAGAIN)) continue;
- if (filterErr < 0) {
- result[0] = filterErr;
- env->SetLongArrayRegion(out, 0, 7, result);
- return filterErr;
- }
- av_packet_move_ref(s->packet, s->filteredPacket);
- } else {
- result[0] = filterErr;
- env->SetLongArrayRegion(out, 0, 7, result);
- return filterErr;
- }
- }
- }
- int size = s->packet->size;
- if (size > capacity) {
- s->packetPending = true;
- result[0] = CODE_GROW;
- result[4] = size;
- env->SetLongArrayRegion(out, 0, 7, result);
- return CODE_GROW;
- }
- env->SetByteArrayRegion(buffer, 0, size, reinterpret_cast(s->packet->data));
-
- AVStream* stream = s->format->streams[s->packet->stream_index];
- int64_t ptsUs;
- if (s->packet->pts != AV_NOPTS_VALUE) {
- ptsUs = av_rescale_q(s->packet->pts, stream->time_base, AVRational{1, 1000000});
- } else if (s->packet->dts != AV_NOPTS_VALUE) {
- ptsUs = av_rescale_q(s->packet->dts, stream->time_base, AVRational{1, 1000000});
- } else {
- // No timestamp even after GENPTS: media3 cannot schedule the sample,
- // and delivering data without metadata would corrupt the sample queue.
- continue;
- }
- ptsUs -= s->startTimeUs;
-
- result[0] = CODE_PACKET;
- result[1] = s->packet->stream_index;
- result[2] = ptsUs;
- result[3] = (s->packet->flags & AV_PKT_FLAG_KEY) != 0 ? 1 : 0;
- result[4] = size;
- result[5] = s->packet->pos >= 0 ? s->packet->pos : 0;
- result[6] =
- s->packet->duration > 0 ? av_rescale_q(s->packet->duration, stream->time_base, AVRational{1, 1000000}) : -1;
- env->SetLongArrayRegion(out, 0, 7, result);
- return CODE_PACKET;
- }
-}
-
-JNIEXPORT jlong JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeLogicalPosition(JNIEnv* env, jobject thiz) {
- StateLock lock;
- DemuxState* s = gState;
- if (s == nullptr || !s->opened) return -1;
- return static_cast(s->logicalPos);
-}
-
-// Seeks to a presentation time (media3 timeline microseconds, start-time
-// normalized). Called on the loader thread, where blocking IO belongs:
-// libavformat reads its own index through the random-access source, so this
-// either lands on the target keyframe or reports that the container has no
-// usable index.
-JNIEXPORT jint JNICALL
-Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeSeek(JNIEnv* env, jobject thiz, jlong timeUs) {
- StateLock lock;
- DemuxState* s = gState;
- if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
- s->javaError = false;
- clearLatchedIoError(s);
- // Any packet parked for a CODE_GROW retry belongs to the pre-seek stream.
- s->packetPending = false;
- if (s->bsf != nullptr) {
- for (unsigned i = 0; i < s->nbStreams; i++) {
- if (s->bsf[i] != nullptr) av_bsf_flush(s->bsf[i]);
- }
- }
- const int64_t ts = timeUs + s->startTimeUs;
- const int err = avformat_seek_file(s->format, -1, INT64_MIN, ts, ts, 0);
- if (s->javaError) return ERR_JAVA;
- if (err < 0) {
- LOGW("seek to %lld failed: %d", (long long)ts, err);
- return err;
- }
- LOGD("seek to %lld: logical=%lld", (long long)ts, (long long)s->logicalPos);
- return 0;
-}
-
-} // extern "C"
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt
new file mode 100644
index 000000000..bc9bfbc09
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt
@@ -0,0 +1,226 @@
+package com.edde746.plezy.exoplayer
+
+import android.util.Log
+import androidx.media3.common.C
+import androidx.media3.extractor.Extractor
+import androidx.media3.extractor.ExtractorInput
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.PositionHolder
+import androidx.media3.extractor.SeekMap
+import androidx.media3.extractor.SeekPoint
+import androidx.media3.extractor.TrackAwareSeekMap
+import androidx.media3.extractor.TrackOutput
+import java.util.concurrent.CopyOnWriteArrayList
+
+/**
+ * Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
+ *
+ * Two repairs, in priority order:
+ * - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a
+ * proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking.
+ * - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska
+ * seek map while parsing Cues, which for files whose Tracks element follows the Clusters is
+ * before any track is known — the primary seek track stays unset even though the Cues parsed):
+ * wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on
+ * this output.
+ */
+@androidx.media3.common.util.UnstableApi
+class CuelessSeekExtractorWrapper(
+ private val delegate: Extractor
+) : Extractor {
+
+ companion object {
+ private const val TAG = "CuelessSeek"
+
+ // MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
+ private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
+ private const val SCAN_BUFFER_SIZE = 8192
+
+ // Max bytes to scan for a Cluster boundary before giving up
+ private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
+ }
+
+ private var inputLength: Long = C.LENGTH_UNSET.toLong()
+ private var needsClusterResync = false
+ private var isApproximateSeeking = false
+ private var pendingSeekTimeUs: Long = C.TIME_UNSET
+
+ /** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */
+ private val registeredTracks = CopyOnWriteArrayList>()
+
+ override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
+
+ override fun init(output: ExtractorOutput) {
+ delegate.init(SeekInterceptingOutput(output))
+ }
+
+ override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
+ if (inputLength == C.LENGTH_UNSET.toLong()) {
+ inputLength = input.length
+ }
+ if (needsClusterResync) {
+ needsClusterResync = false
+ return scanForCluster(input, seekPosition)
+ }
+ return delegate.read(input, seekPosition)
+ }
+
+ override fun seek(position: Long, timeUs: Long) {
+ if (isApproximateSeeking && position > 0) {
+ needsClusterResync = true
+ pendingSeekTimeUs = timeUs
+ }
+ delegate.seek(position, timeUs)
+ }
+
+ override fun release() = delegate.release()
+
+ /**
+ * Scan forward from the current input position to find the next MKV Cluster
+ * element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
+ * byte position so ExoPlayer repositions the DataSource there.
+ */
+ private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
+ val buffer = ByteArray(SCAN_BUFFER_SIZE)
+ var totalScanned = 0L
+ // Carry over last 3 bytes across buffer boundaries to detect split IDs
+ var carry = ByteArray(0)
+
+ while (totalScanned < MAX_SCAN_BYTES) {
+ val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
+ val bytesRead: Int
+ try {
+ bytesRead = input.read(buffer, 0, toRead)
+ } catch (_: Exception) {
+ break
+ }
+ if (bytesRead == C.RESULT_END_OF_INPUT) break
+
+ // Combine carry + new data for scanning
+ val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
+
+ for (i in 0..scanData.size - 4) {
+ if (scanData[i] == CLUSTER_ID[0] &&
+ scanData[i + 1] == CLUSTER_ID[1] &&
+ scanData[i + 2] == CLUSTER_ID[2] &&
+ scanData[i + 3] == CLUSTER_ID[3]
+ ) {
+ // Compute the absolute byte position of this Cluster
+ val clusterPosition = input.position - bytesRead - carry.size + i
+ Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
+ seekPosition.position = clusterPosition
+ delegate.seek(clusterPosition, pendingSeekTimeUs)
+ pendingSeekTimeUs = C.TIME_UNSET
+ return Extractor.RESULT_SEEK
+ }
+ }
+
+ // Keep last 3 bytes as carry for next iteration
+ carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
+ totalScanned += bytesRead
+ }
+
+ // Failed to find a Cluster — fall back to position 0
+ Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
+ seekPosition.position = 0
+ delegate.seek(0, 0)
+ return Extractor.RESULT_SEEK
+ }
+
+ /**
+ * ExtractorOutput wrapper that intercepts [seekMap] calls to replace
+ * [SeekMap.Unseekable] with an approximate proportional SeekMap.
+ */
+ private inner class SeekInterceptingOutput(
+ private val delegate: ExtractorOutput
+ ) : ExtractorOutput {
+
+ override fun track(id: Int, type: Int): TrackOutput {
+ if (registeredTracks.none { it.first == id }) {
+ registeredTracks.add(id to type)
+ }
+ return delegate.track(id, type)
+ }
+
+ override fun endTracks() = delegate.endTracks()
+
+ override fun seekMap(seekMap: SeekMap) {
+ if (seekMap is SeekMap.Unseekable) {
+ val durationUs = seekMap.durationUs
+ if (durationUs != C.TIME_UNSET && durationUs > 0) {
+ Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
+ isApproximateSeeking = true
+ delegate.seekMap(ApproximateSeekMap(durationUs))
+ return
+ }
+ } else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) {
+ Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking")
+ isApproximateSeeking = false
+ delegate.seekMap(TrackCueSeekMap(seekMap))
+ return
+ }
+ // File has real Cues or unknown duration — pass through
+ isApproximateSeeking = false
+ delegate.seekMap(seekMap)
+ }
+ }
+
+ /**
+ * Approximate SeekMap that estimates byte positions proportionally.
+ * Used when the MKV has no Cues but has a known duration.
+ */
+ private inner class ApproximateSeekMap(
+ private val durationUs: Long
+ ) : SeekMap {
+
+ override fun isSeekable(): Boolean = true
+
+ override fun getDurationUs(): Long = durationUs
+
+ override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
+ val length = inputLength
+ if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
+ return SeekMap.SeekPoints(SeekPoint(0, 0))
+ }
+ val clampedTimeUs = timeUs.coerceIn(0, durationUs)
+ val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
+ return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
+ }
+ }
+
+ /**
+ * Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports
+ * unseekable overall. The per-track queries read the live cue data (populated once the Cues
+ * element parsed), so they resolve correctly even when the map was constructed before the
+ * Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377).
+ */
+ private inner class TrackCueSeekMap(
+ private val delegate: TrackAwareSeekMap
+ ) : SeekMap {
+
+ override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null
+
+ override fun getDurationUs(): Long = delegate.durationUs
+
+ override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
+ if (delegate.isSeekable) return delegate.getSeekPoints(timeUs)
+ val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs)
+ return delegate.getSeekPoints(timeUs, trackId)
+ }
+
+ /** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */
+ private fun seekableTrackId(): Int? {
+ var audio: Int? = null
+ var fallback: Int? = null
+ for ((id, type) in registeredTracks) {
+ if (!delegate.isSeekable(id)) continue
+ when (type) {
+ C.TRACK_TYPE_VIDEO -> return id
+ C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id
+ else -> if (fallback == null) fallback = id
+ }
+ }
+ return audio ?: fallback
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt
deleted file mode 100644
index 779149313..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-/**
- * RFC 6381 codecs strings for Dolby Vision tracks, mirroring media3's
- * `DolbyVisionConfig.parse` profile table so the FFmpeg demux path publishes
- * the same format media3's own MP4/Matroska extractors would: recognized
- * profiles get `video/dolby-vision` plus this string, unrecognized profiles
- * keep the base codec's format untouched.
- */
-internal object DoviCodecs {
-
- /**
- * Codecs string for a DOVIDecoderConfigurationRecord's [profile]/[level],
- * or null when media3 does not recognize the profile (the track then plays
- * as its base codec).
- */
- fun rfc6381(profile: Int, level: Int): String? {
- val prefix = when (profile) {
- 4, 5, 7, 8 -> "dvhe"
- 9 -> "dvav"
- 10 -> "dav1"
- else -> return null
- }
- return "%s.%02d.%02d".format(prefix, profile, level)
- }
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
index f50d5d5c3..31b5200eb 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
@@ -64,7 +64,6 @@ import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.extractor.mp4.Mp4Extractor
-import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput
import androidx.media3.extractor.ts.TsExtractor
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
@@ -72,7 +71,6 @@ import androidx.media3.ui.SubtitleView
import com.edde746.plezy.AndroidRuntimeDiagnostics
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
-import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.DeviceQuirks
@@ -563,18 +561,6 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null
- // The FFmpeg extractor from the current session; getStats reads its
- // measured audio bitrate for the playing item.
- @Volatile private var activeFfmpegExtractor: FfmpegExtractor? = null
-
- // Container-demuxer placement; read per media item when extractors are built.
- @Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.FFMPEG
-
- fun setDemuxerMode(mode: String) {
- demuxerPreference = FfmpegDemuxerPolicy.fromWire(mode)
- emitLog("info", "init", "demuxer mode: ${demuxerPreference.wireName}")
- }
-
private fun getConfiguredDvMode(): DvConversionMode {
val override = debugDvModeOverride
if (override != null) return override
@@ -590,10 +576,7 @@ class ExoPlayerCore(private val activity: Activity) :
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
// is internal and this function is not; unrecognised names resolve to Auto, which is also
// the default (#1816).
- bufferTier: String = "auto",
- // Whether FFmpeg demuxes ahead of media3's extractors. Wire values from
- // [FfmpegDemuxerPolicy.Preference]; unrecognised resolves to FFmpeg.
- demuxerMode: String = "ffmpeg"
+ bufferTier: String = "auto"
): Boolean {
if (isInitialized) {
Log.d(TAG, "Already initialized")
@@ -602,7 +585,6 @@ class ExoPlayerCore(private val activity: Activity) :
tunnelingUserEnabled = tunnelingEnabled
this.audioPassthroughEnabled = audioPassthroughEnabled
- demuxerPreference = FfmpegDemuxerPolicy.fromWire(demuxerMode)
this.dvMode = getConfiguredDvMode()
DoviBridge.logSupportSummary(activity)
Log.i(
@@ -744,20 +726,10 @@ class ExoPlayerCore(private val activity: Activity) :
}
httpDataSourceFactory = httpFactory
dataSourceFactory = DefaultDataSource.Factory(activity, httpFactory)
- // libavformat owns its own seeking, so the demuxer reads through a
- // random-access source (FfmpegRandomAccessSource). Recording the DataSpec
- // media3 opens lets it clone that spec — same URI, cache key, flags and
- // request headers — instead of re-deriving one.
- val recordingFactory = RecordingDataSourceFactory(dataSourceFactory!!)
val extractorsFactory = DefaultExtractorsFactory()
// High-bitrate Plex DVR MPEG-TS recordings can have sparse PCR packets; the default
// 600-packet window may leave duration unknown and seeking disabled.
.setTsExtractorTimestampSearchBytes(TS_TIMESTAMP_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE)
- // Raw subtitle samples from MatroskaExtractor: the external text
- // pipeline below feeds libass dialogue first and then re-adds cue
- // transcoding — with the internal wrapper enabled the dialogue bytes
- // would already be consumed before our wrapper could see them.
- .setMatroskaExtractorFlags(MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA)
// Inline buildWithAssSupport to retain AssHandler reference for font scale control.
val handler = AssHandler()
@@ -767,82 +739,42 @@ class ExoPlayerCore(private val activity: Activity) :
// composition object and blanks palette-only fade updates (#1953).
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
- // Wrap extractors: FFmpeg demuxes the containers media3 is weak at
- // (primary, before the list) and anything media3 cannot sniff (catch-all,
- // behind it); give MatroskaExtractor the same libass/transcoding text
- // pipeline so the media3 fallback (ffmpeg unavailable, media3-only
- // preference) keeps ASS rendering; wrap MKV/MP4 extractors with the DV
- // converter when enabled.
- // Reads this.dvMode and this.demuxerPreference each time (not captured) so
- // DV7→8.1 retry and demuxer-mode changes can reload without
- // reinitializing the player.
- val wrappedExtractorsFactory = object : androidx.media3.extractor.ExtractorsFactory {
- // media3's progressive path always supplies the item URI. Without one
- // the demuxer cannot be handed random access, so FFmpeg stays out
- // rather than failing later on its first index jump.
- override fun createExtractors(): Array {
- Log.w(TAG, "[init] extractors: no item uri, ffmpeg demuxer unavailable")
- return buildExtractors(null)
- }
-
- override fun createExtractors(
- uri: Uri,
- responseHeaders: Map>
- ): Array = buildExtractors(uri)
-
- private fun buildExtractors(uri: Uri?): Array {
- val currentDvMode = this@ExoPlayerCore.dvMode
- val doviEnabled = currentDvMode != DvConversionMode.DISABLED
- val currentDemuxerPreference = this@ExoPlayerCore.demuxerPreference
- Log.i(TAG, "[init] extractors: demuxer=${currentDemuxerPreference.wireName}")
- // media3 invokes the factory once per item and reuses the extractor
- // instances, so the sniff reads the live preference instead of a
- // captured one.
- val liveDemuxerPreference = { this@ExoPlayerCore.demuxerPreference }
- val ffmpegFirst = listOfNotNull(
- uri?.let { itemUri ->
- val io = FfmpegRandomAccessSource(dataSourceFactory!!) { recordingFactory.specFor(itemUri) }
- FfmpegExtractor.create(
- liveDemuxerPreference,
- currentDvMode,
- subtitleParserFactory,
- handler,
- io
- )?.also { activeFfmpegExtractor = it }
+ // Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM
+ // variant, wrap MP4 extractors with the DV converter when enabled.
+ // Reads this.dvMode each time (not captured) so DV7→8.1 retry can
+ // change mode and reload without reinitializing the player.
+ val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
+ val currentDvMode = this.dvMode
+ val doviEnabled = currentDvMode != DvConversionMode.DISABLED
+ extractorsFactory.createExtractors().map { extractor ->
+ when {
+ extractor is MatroskaExtractor -> {
+ val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler)
+ val inner = if (doviEnabled) {
+ DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
+ emitLog(level, prefix, message)
+ }.also {
+ activeDoviMkvWrapper = it
+ }
+ } else {
+ assExtractor
+ }
+ // Wrap with approximate seeking for MKV files without Cues
+ CuelessSeekExtractorWrapper(inner)
}
- )
- return (
- ffmpegFirst + extractorsFactory.createExtractors().map { extractor ->
- when {
- extractor is MatroskaExtractor -> {
- val withTextPipeline = OutputWrappingExtractor(extractor) { out ->
- AssSubtitleExtractorOutput(SubtitleTranscodingExtractorOutput(out, subtitleParserFactory), handler)
- }
- if (doviEnabled) {
- DoviExtractorWrapper(withTextPipeline, currentDvMode) { level, prefix, message ->
- emitLog(level, prefix, message)
- }.also {
- activeDoviMkvWrapper = it
- }
- } else {
- withTextPipeline
- }
- }
- doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
- DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
- emitLog(level, prefix, message)
- }.also {
- activeDoviMp4Wrapper = it
- }
- }
- else -> extractor
+ doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
+ DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
+ emitLog(level, prefix, message)
+ }.also {
+ activeDoviMp4Wrapper = it
}
}
- ).toTypedArray()
- }
+ else -> extractor
+ }
+ }.toTypedArray()
}
- val mediaSourceFactory = DefaultMediaSourceFactory(recordingFactory, wrappedExtractorsFactory)
+ val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
.setSubtitleParserFactory(subtitleParserFactory)
playbackMediaSourceFactory = mediaSourceFactory
@@ -3791,7 +3723,6 @@ class ExoPlayerCore(private val activity: Activity) :
dv7RetryAttempted = override != null
activeDoviMkvWrapper = null
activeDoviMp4Wrapper = null
- activeFfmpegExtractor = null
val debugMode = override?.name ?: "AUTO"
emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)")
reloadCurrentMediaForDvMode()
@@ -3828,7 +3759,6 @@ class ExoPlayerCore(private val activity: Activity) :
lastDvPlaybackInfo = null
activeDoviMkvWrapper = null
activeDoviMp4Wrapper = null
- activeFfmpegExtractor = null
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
@@ -4295,12 +4225,7 @@ class ExoPlayerCore(private val activity: Activity) :
"audioMimeType" to audioFormat?.sampleMimeType,
"audioSampleRate" to audioFormat?.sampleRate,
"audioChannels" to audioFormat?.channelCount,
- // Container-declared bitrate when present; otherwise the FFmpeg
- // demuxer's measured value (Matroska rarely carries BPS tags — #2063).
- "audioBitrate" to (
- audioFormat?.bitrate?.takeIf { it > 0 }
- ?: audioFormat?.id?.let { activeFfmpegExtractor?.measuredAudioBitrateBps(it) }
- ),
+ "audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 },
"audioDecoderName" to audioDecoderInitName,
"audioOutputEncoding" to audioTrackConfig?.encoding,
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
index e302f5246..d758c33e8 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
@@ -258,7 +258,6 @@ class ExoPlayerPlugin :
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
"setVideoZoom" -> handleSetVideoZoom(call, result)
"setDvConversionMode" -> handleSetDvConversionMode(call, result)
- "setDemuxerMode" -> handleSetDemuxerMode(call, result)
"setAudioNormalization" -> handleSetAudioNormalization(call, result)
"setAudioPassthrough" -> handleSetAudioPassthrough(call, result)
"setAudioDownmix" -> handleSetAudioDownmix(call, result)
@@ -300,7 +299,6 @@ class ExoPlayerPlugin :
// 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("bufferTier") ?: "auto"
- val demuxerMode = call.argument("demuxerMode") ?: "ffmpeg"
// 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
@@ -342,8 +340,7 @@ class ExoPlayerPlugin :
val success = core.initialize(
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled,
- bufferTier = bufferTier,
- demuxerMode = demuxerMode
+ bufferTier = bufferTier
)
if (!success) {
if (playerCore === core) playerCore = null
@@ -1148,18 +1145,6 @@ class ExoPlayerPlugin :
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
- private fun handleSetDemuxerMode(call: MethodCall, result: MethodChannel.Result) {
- val mode = call.argument("mode")
- if (mode == null) {
- result.error("INVALID_ARGS", "Missing 'mode'", null)
- return
- }
- activity?.runOnUiThread {
- playerCore?.setDemuxerMode(mode)
- result.success(null)
- } ?: result.error("NO_ACTIVITY", "Activity not available", null)
- }
-
private fun handleSetAudioNormalization(call: MethodCall, result: MethodChannel.Result) {
val enabled = call.argument("enabled")
if (enabled == null) {
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt
deleted file mode 100644
index 3d8084278..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import androidx.annotation.OptIn
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.ParserException
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.container.OpusUtil
-
-/**
- * Shapes ffmpeg codec extradata into the per-codec initializationData layout
- * media3's audio decode paths consume. MediaCodecAudioRenderer maps the list
- * entries verbatim to MediaCodec csd-0/1/2 and the bundled FfmpegAudioDecoder
- * rebuilds avcodec extradata from the same entries, so both expect the shapes
- * media3's own extractors emit — not raw ffmpeg extradata:
- *
- * - Opus: [OpusHead, codec delay ns, seek pre-roll ns]. Android's Opus
- * decoders consume their first three input buffers as exactly this
- * configuration; given only OpusHead they read the first two real packets
- * as delay/pre-roll and then silently discard every decoded sample, which
- * stalled the audio clock at 0 until the MPV fallback fired (#2088).
- * - Vorbis: [identification header, setup header], split out of the
- * Xiph-laced blob the way MatroskaExtractor's parseVorbisCodecPrivate does.
- * - FLAC: the "fLaC"-marked STREAMINFO stream MediaCodec documents; ffmpeg
- * surfaces a bare or block-headed STREAMINFO depending on the container.
- *
- * Everything else (AAC ASC, ALAC cookie, passthrough codecs) already matches
- * media3's single-entry shape and passes through. MP3's WAVEFORMATEX blob is
- * dropped on the JNI side before it reaches this shaping.
- */
-@OptIn(UnstableApi::class)
-internal object FfmpegAudioCsd {
-
- // RFC 7845 section 5.1: the identification header is at least 19 bytes;
- // OpusUtil reads the pre-skip field at offsets 10-11.
- private const val OPUS_HEADER_MIN_BYTES = 19
-
- private val FLAC_STREAM_MARKER = byteArrayOf(0x66, 0x4C, 0x61, 0x43) // "fLaC"
- private const val FLAC_STREAM_INFO_SIZE = 34
- private const val FLAC_BLOCK_HEADER_SIZE = 4
- private const val FLAC_LAST_BLOCK_FLAG = 0x80
-
- fun initializationData(mime: String, extradata: ByteArray): List = when (mime) {
- MimeTypes.AUDIO_OPUS -> opus(extradata)
- MimeTypes.AUDIO_VORBIS -> vorbis(extradata)
- MimeTypes.AUDIO_FLAC -> flac(extradata)
- else -> listOf(extradata)
- }
-
- private fun opus(extradata: ByteArray): List {
- if (extradata.size < OPUS_HEADER_MIN_BYTES) {
- throw malformed("Opus extradata is not an identification header: ${extradata.size} bytes")
- }
- // [OpusHead, pre-skip ns, 80 ms default seek pre-roll ns] — the same
- // shape media3's Matroska/Ogg/MP4 extractors publish.
- return OpusUtil.buildInitializationData(extradata)
- }
-
- /**
- * Splits the Xiph-laced header blob (identification, comment, setup) into
- * media3's [identification header, setup header] pair, mirroring media3
- * 1.11.0 MatroskaExtractor.parseVorbisCodecPrivate.
- */
- private fun vorbis(extradata: ByteArray): List {
- try {
- if (extradata[0].toInt() != 0x02) throw malformed("Vorbis extradata is not Xiph-laced headers")
- var offset = 1
- var idHeaderLength = 0
- while (extradata[offset].toInt() and 0xFF == 0xFF) {
- idHeaderLength += 0xFF
- offset++
- }
- idHeaderLength += extradata[offset++].toInt() and 0xFF
- var commentHeaderLength = 0
- while (extradata[offset].toInt() and 0xFF == 0xFF) {
- commentHeaderLength += 0xFF
- offset++
- }
- commentHeaderLength += extradata[offset++].toInt() and 0xFF
-
- if (extradata[offset].toInt() != 0x01) throw malformed("Vorbis identification header not found")
- val idHeader = extradata.copyOfRange(offset, offset + idHeaderLength)
- offset += idHeaderLength
- if (extradata[offset].toInt() != 0x03) throw malformed("Vorbis comment header not found")
- offset += commentHeaderLength
- if (extradata[offset].toInt() != 0x05) throw malformed("Vorbis setup header not found")
- return listOf(idHeader, extradata.copyOfRange(offset, extradata.size))
- } catch (e: IndexOutOfBoundsException) {
- throw malformed("Vorbis extradata truncated")
- }
- }
-
- /**
- * Normalizes to the "fLaC"-marked metadata stream. MediaCodec's decoder
- * feeds csd-0 straight into libFLAC, which requires the stream marker;
- * ffmpeg's canonical FLAC extradata is the bare 34-byte STREAMINFO, and
- * some demuxers keep the 4-byte metadata block header in front of it.
- */
- private fun flac(extradata: ByteArray): List {
- if (startsWithFlacMarker(extradata)) return listOf(extradata)
- if (extradata.size == FLAC_STREAM_INFO_SIZE) {
- val blockHeader = byteArrayOf(
- (FLAC_LAST_BLOCK_FLAG or 0).toByte(), // last-metadata-block, type 0 = STREAMINFO
- 0x00,
- 0x00,
- FLAC_STREAM_INFO_SIZE.toByte()
- )
- return listOf(FLAC_STREAM_MARKER + blockHeader + extradata)
- }
- val declaredLength = if (extradata.size >= FLAC_BLOCK_HEADER_SIZE) {
- ((extradata[1].toInt() and 0xFF) shl 16) or
- ((extradata[2].toInt() and 0xFF) shl 8) or
- (extradata[3].toInt() and 0xFF)
- } else {
- -1
- }
- if (extradata[0].toInt() and 0x7F == 0 &&
- declaredLength == FLAC_STREAM_INFO_SIZE &&
- extradata.size >= FLAC_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_SIZE
- ) {
- return listOf(FLAC_STREAM_MARKER + extradata)
- }
- throw malformed("FLAC extradata is not a STREAMINFO block: ${extradata.size} bytes")
- }
-
- private fun startsWithFlacMarker(extradata: ByteArray): Boolean {
- if (extradata.size < FLAC_STREAM_MARKER.size) return false
- for (i in FLAC_STREAM_MARKER.indices) {
- if (extradata[i] != FLAC_STREAM_MARKER[i]) return false
- }
- return true
- }
-
- private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null)
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt
deleted file mode 100644
index db63ad9bf..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import androidx.annotation.OptIn
-import androidx.media3.common.util.UnstableApi
-
-/**
- * JNI surface for the libavformat demuxer shim (`cpp/media3_ffmpeg_demuxer`).
- *
- * One demuxer exists per process, owned by whichever [FfmpegExtractor] is
- * currently open — Plezy plays at most one progressive source at a time, and
- * every other playback mode (HLS/DASH, music) stays on media3's own sources.
- *
- * The long[] layouts mirror the constants documented in
- * `ffmpeg_demuxer_jni.cc`; keep both sides in sync.
- */
-@OptIn(UnstableApi::class)
-internal object FfmpegDemuxerJni {
- /** Byte source the AVIO callbacks read through while a native call is in flight. */
- interface Input {
- /** Current byte position of the loader's [androidx.media3.extractor.ExtractorInput]. */
- fun position(): Long
-
- /**
- * Reads up to [length] bytes from the loader's input at its current
- * position. Returns the number of bytes read, `0` at end of input, or `-1`
- * after storing the failure in [lastError].
- */
- fun read(buf: ByteArray, length: Int): Int
-
- /**
- * Reads up to [length] bytes at absolute [position], wherever the loader
- * stands. Same return contract as [read]. This is what lets libavformat
- * seek its own index without the shim unwinding the in-flight call.
- */
- fun readAt(position: Long, buf: ByteArray, length: Int): Int
-
- /** Total input length in bytes, or `-1` when unknown. */
- fun length(): Long
-
- var lastError: String?
- }
-
- // readPacket out[] indices.
- const val OUT_CODE = 0
- const val OUT_STREAM_INDEX = 1
- const val OUT_PTS_US = 2
- const val OUT_FLAGS = 3
- const val OUT_SIZE = 4
- const val OUT_POSITION = 5
- const val OUT_DURATION_US = 6
- const val OUT_LENGTH = 7
-
- // Result codes written to out[OUT_CODE]; negative values are raw AVERRORs.
- const val CODE_PACKET = 0
- const val CODE_EOF = 1
- const val CODE_GROW = 2
- const val ERR_JAVA = -102
-
- // streamInfo long[] layout.
- const val INFO_TRACK_TYPE = 0
- const val INFO_WIDTH = 1
- const val INFO_HEIGHT = 2
- const val INFO_PAR_NUM = 3
- const val INFO_PAR_DEN = 4
- const val INFO_FPS_NUM = 5
- const val INFO_FPS_DEN = 6
- const val INFO_SAMPLE_RATE = 7
- const val INFO_CHANNELS = 8
- const val INFO_PCM_ENCODING = 9
- const val INFO_ROTATION = 10
- const val INFO_SELECTION_FLAGS = 11
- const val INFO_ROLE_FLAGS = 12
- const val INFO_BITRATE = 13
- const val INFO_DOVI_PROFILE = 14
- const val INFO_DOVI_LEVEL = 15
- const val INFO_LATM = 16
- const val INFO_LENGTH = 17
-
- val available: Boolean = try {
- System.loadLibrary("ffmpegJNI")
- true
- } catch (_: UnsatisfiedLinkError) {
- false
- }
-
- /** Probes container bytes; returns the ffmpeg short name ("avi", "asf", …) or null. */
- external fun nativeProbeFormat(header: ByteArray): String?
-
- /**
- * Opens the demuxer against [input]. Returns 0 on success or a negative
- * AVERROR ([ERR_JAVA] when the input itself failed). The stream count is read
- * separately via [nativeStreamCount]; it must not ride the same namespace as
- * result codes.
- */
- external fun nativeOpen(input: Input): Int
-
- external fun nativeClose()
-
- external fun nativeStreamCount(): Int
-
- /** Total duration in microseconds, or -1 when unknown. */
- external fun nativeDurationUs(): Long
-
- /** Fills [numbers]/[strings] for [index]; false when the stream is unusable. */
- external fun nativeStreamInfo(index: Int, numbers: LongArray, strings: Array): Boolean
-
- /** Codec initialization data (extradata), or null. */
- external fun nativeStreamExtradata(index: Int): ByteArray?
-
- /** Number of attachment streams (embedded fonts and friends) with payloads. */
- external fun nativeAttachmentCount(): Int
-
- /**
- * Fills [strings] with [filename, mimetype] for attachment [ordinal] and
- * returns the payload size in bytes, or -1 when out of range. Lets Kotlin
- * enforce font budgets before copying any bytes across the boundary.
- */
- external fun nativeAttachmentInfo(ordinal: Int, strings: Array): Long
-
- /** Attachment payload bytes, or null. */
- external fun nativeAttachmentData(ordinal: Int): ByteArray?
-
- /**
- * Reads one packet into [buffer], filling [out]. See the shim header
- * comment for the result contract.
- */
- external fun nativeReadPacket(buffer: ByteArray, out: LongArray): Int
-
- /** Byte position libavformat will read next, or -1 when not open. */
- external fun nativeLogicalPosition(): Long
-
- /**
- * Seeks to [timeUs] (media3 timeline microseconds, i.e. start-time
- * normalized). The demuxer resolves the byte position itself through its own
- * index. Returns 0 on success, or a negative AVERROR when the container has
- * no usable index and libavformat refused the seek.
- */
- external fun nativeSeek(timeUs: Long): Int
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt
deleted file mode 100644
index 2ba1fcb2f..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-/**
- * Decides whether the FFmpeg demuxer sits ahead of media3's own extractors.
- *
- * FFmpeg demuxes every progressive container by default. media3's bundled
- * extractors were weak exactly where this app kept accumulating container
- * patches — AVI (XviD packed-bitstream timestamps, missing VOL csd — issue
- * #2052), ASF/WMV (no extractor at all), MPEG-PS/VOB (no extractor), and
- * Matroska (zlib-compressed subtitles, LOAS/LATM audio as A_MS/ACM, cueless
- * seeking, font attachments) — and libavformat handles all of it natively.
- * media3's extractors stay behind FFmpeg in the list, so a container FFmpeg
- * fails to sniff still reaches media3's parsers. [Preference.MEDIA3_ONLY] is
- * the user-facing escape hatch in case a file misbehaves on the FFmpeg path.
- */
-internal object FfmpegDemuxerPolicy {
- enum class Preference(val wireName: String) {
- FFMPEG("ffmpeg"),
- MEDIA3_ONLY("media3")
- }
-
- /** Unknown wire values (including the retired "auto") resolve to FFmpeg. */
- fun fromWire(value: String?): Preference = Preference.entries.firstOrNull { it.wireName == value } ?: Preference.FFMPEG
-
- /** Whether the FFmpeg extractor placed before media3's list demuxes at all. */
- fun enabled(preference: Preference): Boolean = preference == Preference.FFMPEG
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt
deleted file mode 100644
index e68cbc6ad..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt
+++ /dev/null
@@ -1,577 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import android.util.Log
-import androidx.annotation.OptIn
-import androidx.media3.common.C
-import androidx.media3.common.Format
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.ParserException
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.ExtractorInput
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.PositionHolder
-import androidx.media3.extractor.SeekMap
-import androidx.media3.extractor.SeekPoint
-import androidx.media3.extractor.TrackOutput
-import androidx.media3.extractor.text.SubtitleParser
-import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput
-import com.edde746.plezy.libass.media.AssFonts
-import com.edde746.plezy.libass.media.AssHandler
-import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
-import java.io.IOException
-
-/**
- * Demuxes progressive containers with libavformat and feeds the packets to
- * media3's renderers, so FFmpeg's container coverage replaces media3's on the
- * paths where it is weak (AVI/XviD timestamps and VOL csd — #2052 — ASF/WMV,
- * MPEG-PS) and where the app accumulated Matroska patches (zlib-compressed
- * subtitles, LOAS/LATM audio, cueless seeking) without giving up any of the
- * renderer-side work (passthrough carriers, DV sanitizing, libass rendering
- * with embedded fonts, subtitle latency calibration).
- *
- * Text tracks are emitted in the exact sample shape media3's
- * MatroskaExtractor writes (see [FfmpegSubtitleSamples]); the output chain
- * composed in [init] then feeds libass dialogue and transcodes cues the same
- * way the media3 Matroska path does.
- *
- * Seeking: libavformat resolves seeks itself, reading its own index through
- * [FfmpegRandomAccessSource]. media3's byte hints are ignored, nothing is ever
- * unwound, and the loader is merely nudged to follow along so that sample
- * delivery keeps streaming on its connection (see [alignLoader]).
- *
- * The instance sits before media3's extractors and sniffs everything FFmpeg
- * can probe while [FfmpegDemuxerPolicy] enables it; media3's list behind it
- * is the fallback for anything FFmpeg cannot open.
- */
-@OptIn(UnstableApi::class)
-internal class FfmpegExtractor private constructor(
- private val preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference,
- private val dvMode: DvConversionMode,
- private val subtitleParserFactory: SubtitleParser.Factory,
- private val assHandler: AssHandler,
- private val io: FfmpegRandomAccessSource
-) : Extractor {
-
- companion object {
- private const val TAG = "FfmpegExtractor"
- private const val SNIFF_BYTES = 64 * 1024
- private const val INITIAL_PACKET_BUFFER_BYTES = 2 * 1024 * 1024
-
- /** Null when the native library is unavailable. */
- fun create(
- preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference,
- dvMode: DvConversionMode,
- subtitleParserFactory: SubtitleParser.Factory,
- assHandler: AssHandler,
- io: FfmpegRandomAccessSource
- ): FfmpegExtractor? = if (FfmpegDemuxerJni.available) {
- FfmpegExtractor(preferenceSupplier, dvMode, subtitleParserFactory, assHandler, io)
- } else {
- null
- }
- }
-
- private lateinit var output: ExtractorOutput
- private var currentInput: ExtractorInput? = null
- private val inputProxy = object : FfmpegDemuxerJni.Input {
- override fun position(): Long = currentInput?.position ?: 0L
-
- override fun read(buf: ByteArray, length: Int): Int {
- val input = currentInput ?: return 0
- lastError = null
- return try {
- val read = input.read(buf, 0, length)
- if (read == C.RESULT_END_OF_INPUT) 0 else read
- } catch (e: IOException) {
- lastError = e.message ?: "demuxer input failed"
- -1
- }
- }
-
- override fun readAt(position: Long, buf: ByteArray, length: Int): Int {
- lastError = null
- return try {
- io.readAt(position, buf, length)
- } catch (e: IOException) {
- lastError = e.message ?: "demuxer random access read failed"
- -1
- }
- }
-
- override fun length(): Long {
- val fromLoader = currentInput?.length ?: C.LENGTH_UNSET.toLong()
- if (fromLoader != C.LENGTH_UNSET.toLong()) return fromLoader
- // Length-less loader open (progressive live stream): fall back to
- // whatever a random-access open already resolved.
- val resolved = io.length()
- return if (resolved == C.LENGTH_UNSET.toLong()) -1L else resolved
- }
-
- override var lastError: String? = null
- }
-
- private var opened = false
- private var doviOutputWrapper: DoviExtractorOutputWrapper? = null
- private var durationUs = C.TIME_UNSET
- private var trackOutputs: Array = emptyArray()
- private var packetBytes = ByteArray(INITIAL_PACKET_BUFFER_BYTES)
- private val packetParsable = ParsableByteArray()
- private val packetOut = LongArray(FfmpegDemuxerJni.OUT_LENGTH)
- private val prefixParsable = ParsableByteArray()
-
- // Per-stream text handling picked in prepareTracks; parallel to trackOutputs.
- private var subtitleKinds: Array = emptyArray()
- private val latmOutputs = ArrayList()
-
- // Per-audio-stream measured bitrate, parallel to trackOutputs. Written by
- // the loader thread, read by ExoPlayerCore.getStats on another thread.
- @Volatile private var bitrateMeters: Array = emptyArray()
-
- // Set by seek(), applied by the next read(): media3 calls both on the loader
- // thread, but only read() is allowed to throw IOException and the demuxer's
- // seek reads its index.
- private var pendingSeekUs = C.TIME_UNSET
- private var deliveredAnyPacket = false
-
- private val sniffScratch = ByteArray(SNIFF_BYTES)
-
- override fun sniff(input: ExtractorInput): Boolean {
- // media3 materializes the extractor array once per player session, so the
- // policy must be read live at sniff time — a preference captured at
- // construction would freeze for the session.
- if (!FfmpegDemuxerPolicy.enabled(preferenceSupplier())) return false
- val probed = probe(input) ?: return false
- Log.i(TAG, "sniff accepted $probed")
- return true
- }
-
- private fun probe(input: ExtractorInput): String? {
- // create() only builds instances when the native library loaded.
- input.resetPeekPosition()
- return try {
- val filled =
- input.peekFully(
- sniffScratch,
- /* offset= */
- 0,
- sniffScratch.size,
- /* allowEndOfInput= */
- true
- )
- if (!filled) return null
- FfmpegDemuxerJni.nativeProbeFormat(sniffScratch)?.lowercase()
- } catch (_: Throwable) {
- // A sniffer must never take playback down: media3 escalates anything
- // that is not an IOException into a fatal loader error.
- null
- } finally {
- input.resetPeekPosition()
- }
- }
-
- override fun init(output: ExtractorOutput) {
- // media3 reuses extractor instances across sources; drop the previous
- // item's demuxer and byte source so this open starts clean.
- releaseDemuxer()
- // Same composition as the MP4/MKV paths: DoviConvertingTrackOutput
- // inspects each video track's codecs string and passes non-DV through.
- val doviWrapped = if (dvMode != DvConversionMode.DISABLED) {
- // The wrapper forwards emitLog into each converting track output; the
- // playback-info hook stays wired to the MKV/MP4 wrappers, so nothing to
- // capture here.
- DoviExtractorOutputWrapper(
- output,
- dvMode,
- emitLog = { level, prefix, message ->
- Log.println(if (level == "error") Log.ERROR else Log.INFO, TAG, "$prefix $message")
- },
- onVideoTrackWrapped = { _ -> }
- ).also { doviOutputWrapper = it }
- } else {
- doviOutputWrapper = null
- output
- }
- // Text pipeline, extractor-outward: raw media3-shape samples first hit
- // AssTrackOutput (libass dialogue feed), then the transcoding wrapper
- // parses them into cue samples. media3 does NOT wrap third-party
- // extractors' outputs; without this wrapper no text track would ever
- // reach the renderer as cues.
- this.output = AssSubtitleExtractorOutput(
- SubtitleTranscodingExtractorOutput(doviWrapped, subtitleParserFactory),
- assHandler
- )
- }
-
- override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
- currentInput = input
- try {
- if (!opened) openStreams()
- applyPendingSeek()
- alignLoader(input)?.let { position ->
- seekPosition.position = position
- return Extractor.RESULT_SEEK
- }
- // Aligned: the loader serves the samples, so nothing needs a second
- // handle open until the demuxer jumps again.
- if (io.isOpen) io.close()
- return readPacket()
- } catch (e: Throwable) {
- Log.w(TAG, "read threw at input=${input.position}", e)
- throw e
- } finally {
- currentInput = null
- }
- }
-
- /**
- * Byte position the loader should move to, or null when it already stands
- * where libavformat reads.
- *
- * Reads outside the loader's window are served by [io] regardless, so this
- * is an optimization, never a correctness requirement: it keeps sample
- * delivery on media3's own connection, which is what drives its byte
- * accounting, back-pressure (ProgressiveMediaPeriod watches the input
- * position) and load-error policy. Nothing here needs a retry budget.
- */
- private fun alignLoader(input: ExtractorInput): Long? {
- val demuxerPosition = FfmpegDemuxerJni.nativeLogicalPosition()
- if (demuxerPosition < 0 || demuxerPosition == input.position) return null
- val length = input.length
- // Opening a range at or past the end is a failed request, not an
- // alignment; let the read report end of input instead.
- if (length != C.LENGTH_UNSET.toLong() && demuxerPosition >= length) return null
- return demuxerPosition
- }
-
- /**
- * Records the target; [read] performs it. media3's byte position is its
- * SeekMap guess and is deliberately ignored — libavformat picks the byte
- * position from its own index.
- */
- override fun seek(position: Long, timeUs: Long) {
- Log.i(TAG, "extractor seek timeUs=$timeUs opened=$opened")
- doviOutputWrapper?.resetTracks()
- // Buffered LOAS bytes belong to the pre-seek stream; the StreamMuxConfig
- // survives so audio resumes without waiting for the next config.
- for (latm in latmOutputs) latm.reset()
- pendingSeekUs = timeUs
- }
-
- private fun applyPendingSeek() {
- val target = pendingSeekUs
- if (target == C.TIME_UNSET) return
- pendingSeekUs = C.TIME_UNSET
- // media3 issues seek(0) before the first read of every item; a demuxer
- // that has not delivered a packet yet is already at the start, and
- // seeking would throw away find_stream_info's buffered packets and
- // re-read the header region.
- if (target == 0L && !deliveredAnyPacket) return
- val code = FfmpegDemuxerJni.nativeSeek(target)
- if (code == FfmpegDemuxerJni.ERR_JAVA) {
- throw IOException(inputProxy.lastError ?: "demuxer input failed")
- }
- if (code != 0) {
- // No usable index in this container: libavformat keeps delivering from
- // where it stands and the renderers decode-discard toward the target,
- // which is what media3's own extractors do for a cueless file.
- Log.w(TAG, "demuxer refused seek to ${target}us: $code")
- }
- }
-
- override fun release() {
- releaseDemuxer()
- }
-
- private fun releaseDemuxer() {
- if (opened) {
- opened = false
- FfmpegDemuxerJni.nativeClose()
- }
- io.close()
- pendingSeekUs = C.TIME_UNSET
- deliveredAnyPacket = false
- // media3's extractors may demux a later item while this instance keeps
- // the previous one's stream layout; a stale measurement must not leak
- // into stats.
- bitrateMeters = emptyArray()
- }
-
- /**
- * Measured average bitrate (bps) of the audio stream whose index matches
- * [formatId] (this extractor sets `Format.id` to the stream index), or null
- * when this instance is not demuxing that stream. Fallback for containers
- * that declare no per-track bitrate (#2063).
- */
- fun measuredAudioBitrateBps(formatId: String?): Int? {
- val index = formatId?.toIntOrNull() ?: return null
- return bitrateMeters.getOrNull(index)?.bitrateBps()
- }
-
- private fun openStreams() {
- when (val code = FfmpegDemuxerJni.nativeOpen(inputProxy)) {
- 0 -> {
- prepareTracks()
- opened = true
- Log.i(TAG, "ffmpeg demuxer ready: ${trackOutputs.count { it != null }} tracks")
- }
- FfmpegDemuxerJni.ERR_JAVA -> throw IOException(inputProxy.lastError ?: "demuxer input failed")
- else -> throw malformed("ffmpeg demuxer open failed: $code")
- }
- }
-
- private enum class SubtitleKind { NONE, SSA, SUBRIP, VTT }
-
- private fun prepareTracks() {
- val count = FfmpegDemuxerJni.nativeStreamCount()
- durationUs = FfmpegDemuxerJni.nativeDurationUs().takeIf { it >= 0 } ?: C.TIME_UNSET
- trackOutputs = arrayOfNulls(count)
- subtitleKinds = Array(count) { SubtitleKind.NONE }
- bitrateMeters = arrayOfNulls(count)
- latmOutputs.clear()
- // Fonts go in before any text track exists so AssHandler's store flushes
- // them into libass when the first ASS track is created.
- deliverFontAttachments()
- val numbers = LongArray(FfmpegDemuxerJni.INFO_LENGTH)
- val strings = arrayOfNulls(3)
- var primaryVideo = -1
- var primaryAudio = -1
-
- for (index in 0 until count) {
- if (!FfmpegDemuxerJni.nativeStreamInfo(index, numbers, strings)) continue
- val mime = strings[0] ?: continue
- val trackType = when (numbers[FfmpegDemuxerJni.INFO_TRACK_TYPE]) {
- 0L -> C.TRACK_TYPE_VIDEO
- 1L -> C.TRACK_TYPE_AUDIO
- 2L -> C.TRACK_TYPE_TEXT
- else -> continue
- }
-
- val builder = Format.Builder()
- .setId(index.toString())
- .setSampleMimeType(mime)
- .setLanguage(strings[1])
- .setLabel(strings[2])
- .setSelectionFlags(numbers[FfmpegDemuxerJni.INFO_SELECTION_FLAGS].toInt())
- .setRoleFlags(numbers[FfmpegDemuxerJni.INFO_ROLE_FLAGS].toInt())
- .setAverageBitrate(numbers[FfmpegDemuxerJni.INFO_BITRATE].toInt())
-
- val extradata = FfmpegDemuxerJni.nativeStreamExtradata(index)
- if (extradata != null) {
- // Audio csd is codec-specific in media3 (see FfmpegAudioCsd); raw
- // extradata is only correct for video (already Annex-B via the JNI
- // bitstream filter) and text tracks.
- builder.setInitializationData(
- if (trackType == C.TRACK_TYPE_AUDIO) {
- FfmpegAudioCsd.initializationData(mime, extradata)
- } else {
- listOf(extradata)
- }
- )
- }
-
- when (trackType) {
- C.TRACK_TYPE_VIDEO -> {
- // A Dolby Vision config record rides the stream as side data.
- // Mirror media3's MP4/Matroska extractors: recognized profiles
- // publish video/dolby-vision with the RFC 6381 codecs string, so
- // DoviConvertingTrackOutput's P7 conversion, the P8 passthrough
- // path, and the renderer's HEVC fallback engage exactly as they do
- // on the media3 demux path.
- val doviProfile = numbers[FfmpegDemuxerJni.INFO_DOVI_PROFILE].toInt()
- if (doviProfile >= 0) {
- val doviLevel = numbers[FfmpegDemuxerJni.INFO_DOVI_LEVEL].toInt().coerceAtLeast(0)
- val doviCodecs = DoviCodecs.rfc6381(doviProfile, doviLevel)
- if (doviCodecs != null) {
- builder.setCodecs(doviCodecs)
- builder.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
- }
- Log.i(TAG, "video track $index: doviProfile=$doviProfile doviLevel=$doviLevel codecs=$doviCodecs baseMime=$mime")
- }
- val width = numbers[FfmpegDemuxerJni.INFO_WIDTH].toInt()
- val height = numbers[FfmpegDemuxerJni.INFO_HEIGHT].toInt()
- builder
- .setWidth(width)
- .setHeight(height)
- .setRotationDegrees(numbers[FfmpegDemuxerJni.INFO_ROTATION].toInt())
- val parNum = numbers[FfmpegDemuxerJni.INFO_PAR_NUM]
- val parDen = numbers[FfmpegDemuxerJni.INFO_PAR_DEN]
- if (parNum > 0 && parDen > 0 && parNum != parDen) {
- builder.setPixelWidthHeightRatio(parNum.toFloat() / parDen.toFloat())
- }
- val fpsNum = numbers[FfmpegDemuxerJni.INFO_FPS_NUM]
- val fpsDen = numbers[FfmpegDemuxerJni.INFO_FPS_DEN]
- if (fpsNum > 0 && fpsDen > 0) {
- builder.setFrameRate(fpsNum.toFloat() / fpsDen.toFloat())
- }
- if (primaryVideo < 0) {
- primaryVideo = index
- // libass needs the storage size before the first render; the
- // Matroska path published it from the Tracks element the same way.
- if (width > 0 && height > 0) assHandler.setVideoSize(width, height)
- }
- }
- C.TRACK_TYPE_AUDIO -> {
- builder
- .setSampleRate(numbers[FfmpegDemuxerJni.INFO_SAMPLE_RATE].toInt())
- .setChannelCount(numbers[FfmpegDemuxerJni.INFO_CHANNELS].toInt())
- val pcmEncoding = numbers[FfmpegDemuxerJni.INFO_PCM_ENCODING].toInt()
- if (pcmEncoding >= 0) builder.setPcmEncoding(pcmEncoding)
- if (primaryAudio < 0) primaryAudio = index
- bitrateMeters[index] = StreamBitrateMeter()
- }
- C.TRACK_TYPE_TEXT -> {
- val kind = when (mime) {
- MimeTypes.TEXT_SSA -> SubtitleKind.SSA
- MimeTypes.APPLICATION_SUBRIP -> SubtitleKind.SUBRIP
- MimeTypes.TEXT_VTT -> SubtitleKind.VTT
- else -> SubtitleKind.NONE
- }
- subtitleKinds[index] = kind
- if (kind == SubtitleKind.SSA) {
- // Embedded ASS reaches libass as per-sample dialogue, never as a
- // whole file: AssSubtitleParserFactory keys embedded handling off
- // the Matroska container mime, and AssHeaderParser reads the
- // header from initializationData[1] — the exact layout media3's
- // MatroskaExtractor publishes.
- builder.setContainerMimeType(MimeTypes.VIDEO_MATROSKA)
- if (extradata != null) {
- builder.setInitializationData(
- listOf(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT, extradata)
- )
- }
- }
- }
- }
-
- val rawOutput = output.track(index, trackType)
- val trackOutput = if (trackType == C.TRACK_TYPE_AUDIO && numbers[FfmpegDemuxerJni.INFO_LATM] == 1L) {
- // LOAS/LATM-framed AAC: unwrap to raw access units; the wrapper
- // swallows this placeholder Format and emits the real one parsed from
- // the StreamMuxConfig.
- LatmTrackOutput(rawOutput, index).also { latmOutputs.add(it) }
- } else {
- rawOutput
- }
- trackOutput.format(builder.build())
- trackOutputs[index] = trackOutput
- }
-
- output.endTracks()
- output.seekMap(SeekMapImpl())
- }
-
- /** Hands embedded font attachments to libass, under the shared budgets. */
- private fun deliverFontAttachments() {
- val count = FfmpegDemuxerJni.nativeAttachmentCount()
- if (count <= 0) return
- var acceptedBytes = 0L
- val meta = arrayOfNulls(2)
- for (ordinal in 0 until count) {
- val size = FfmpegDemuxerJni.nativeAttachmentInfo(ordinal, meta)
- if (size <= 0) continue
- val name = meta[0] ?: continue
- val mime = meta[1] ?: continue
- if (mime !in AssFonts.fontMimeTypes) continue
- val rejectionReason = AssFonts.rejectionReason(size, acceptedBytes)
- if (rejectionReason != null) {
- Log.w(TAG, "Skipping embedded font: $rejectionReason (bytes=$size, accepted=$acceptedBytes)")
- continue
- }
- val data = FfmpegDemuxerJni.nativeAttachmentData(ordinal) ?: continue
- acceptedBytes += size
- assHandler.addFont(name, data)
- }
- }
-
- private fun readPacket(): Int {
- while (true) {
- val code = FfmpegDemuxerJni.nativeReadPacket(packetBytes, packetOut)
- when (code) {
- FfmpegDemuxerJni.CODE_PACKET -> {
- val streamIndex = packetOut[FfmpegDemuxerJni.OUT_STREAM_INDEX].toInt()
- val size = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt()
- val trackOutput = trackOutputs.getOrNull(streamIndex) ?: continue
- val ptsUs = packetOut[FfmpegDemuxerJni.OUT_PTS_US]
- val isKeyframe = packetOut[FfmpegDemuxerJni.OUT_FLAGS] and 1L != 0L
- val subtitleKind = subtitleKinds.getOrNull(streamIndex) ?: SubtitleKind.NONE
- if (subtitleKind != SubtitleKind.NONE) {
- // Text samples carry their duration inside the sample text, in
- // media3's MatroskaExtractor shape; a sample without a duration
- // cannot be displayed (same skip media3 performs).
- val sampleDurationUs = packetOut[FfmpegDemuxerJni.OUT_DURATION_US]
- if (sampleDurationUs <= 0) {
- Log.w(TAG, "Skipping subtitle sample with no duration (stream=$streamIndex)")
- continue
- }
- val prefix = when (subtitleKind) {
- SubtitleKind.SSA -> FfmpegSubtitleSamples.ssaPrefix(sampleDurationUs)
- SubtitleKind.SUBRIP -> FfmpegSubtitleSamples.subripPrefix(sampleDurationUs)
- else -> FfmpegSubtitleSamples.vttPrefix(sampleDurationUs)
- }
- prefixParsable.reset(prefix, prefix.size)
- trackOutput.sampleData(prefixParsable, prefix.size)
- packetParsable.reset(packetBytes, size)
- trackOutput.sampleData(packetParsable, size)
- trackOutput.sampleMetadata(
- ptsUs,
- C.BUFFER_FLAG_KEY_FRAME,
- prefix.size + size,
- /* offset= */
- 0,
- /* cryptoData= */
- null
- )
- return Extractor.RESULT_CONTINUE
- }
- packetParsable.reset(packetBytes, size)
- trackOutput.sampleData(packetParsable, size)
- // Native drops packets without any timestamp, so ptsUs is always real.
- trackOutput.sampleMetadata(
- ptsUs,
- if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0,
- size,
- /* offset= */
- 0,
- /* cryptoData= */
- null
- )
- deliveredAnyPacket = true
- bitrateMeters.getOrNull(streamIndex)?.onPacket(ptsUs, size)
- return Extractor.RESULT_CONTINUE
- }
- FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT
- FfmpegDemuxerJni.CODE_GROW -> {
- val required = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt()
- packetBytes = ByteArray(required + 64 * 1024)
- }
- FfmpegDemuxerJni.ERR_JAVA ->
- throw IOException(inputProxy.lastError ?: "demuxer input failed")
- // Byte-source failures surface as ERR_JAVA above and become retryable
- // IOExceptions; a raw AVERROR here is libavformat's verdict on the
- // bytes themselves, which media3 rightly treats as terminal (#2113).
- else -> throw malformed("ffmpeg demuxer read failed: $code")
- }
- }
- }
-
- private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null)
-
- private inner class SeekMapImpl : SeekMap {
- override fun isSeekable(): Boolean = this@FfmpegExtractor.durationUs != C.TIME_UNSET
-
- /**
- * The demuxer owns seek resolution: it reads its own index and lands on
- * the right keyframe by itself, so there is no second index here to keep
- * consistent. The byte position exists only to satisfy the interface —
- * [alignLoader] moves the loader to wherever libavformat actually ended
- * up, on the read that follows the seek.
- */
- override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints = SeekMap.SeekPoints(SeekPoint(timeUs, 0))
-
- // Qualified on purpose: bare `durationUs` binds to the inherited Java
- // getter as a synthetic property (this.getDurationUs()) and recurses.
- override fun getDurationUs(): Long = this@FfmpegExtractor.durationUs
- }
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt
deleted file mode 100644
index d0286545e..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt
+++ /dev/null
@@ -1,175 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import android.net.Uri
-import androidx.annotation.OptIn
-import androidx.media3.common.C
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.TransferListener
-import java.io.IOException
-
-/**
- * Random-access byte source for the libavformat demuxer.
- *
- * libavformat demuxers own their own IO: they jump to the end of the file for an
- * index (matroska Cues, MP4 moov, AVI idx1), back to the header, and binary
- * search (MPEG-PS) whenever they please. media3's [
- * androidx.media3.extractor.ExtractorInput] is forward-only — an extractor may
- * only ask the loader to re-open elsewhere and be invoked again — so serving
- * libavformat from the loader alone means every backward jump has to unwind the
- * in-flight libavformat call and replay it, which is what the demuxer shim used
- * to do (and what made a matroska Cues read unrecoverable, #2096).
- *
- * This class removes the mismatch instead of working around it: reads outside
- * the loader's window open a second [DataSource] from the same factory media3
- * uses, positioned exactly where libavformat asked. Cronet, download caches,
- * SAF and the item's own request headers therefore still apply, because the
- * [DataSpec] is cloned from the one media3 opened for this item.
- *
- * Sequential sample delivery deliberately does not come through here: it stays
- * on the loader's connection so media3 keeps its byte accounting, back-pressure
- * and load-error policy (see [FfmpegExtractor]). This source is opened for
- * header/index bursts and closed again as soon as the loader is back in step.
- */
-@OptIn(UnstableApi::class)
-internal class FfmpegRandomAccessSource(
- private val factory: DataSource.Factory,
- private val specProvider: () -> DataSpec?
-) {
- private var source: DataSource? = null
-
- /** Absolute position the open handle returns next; [C.INDEX_UNSET] when closed. */
- private var handlePosition = C.INDEX_UNSET.toLong()
-
- private var resolvedLength = C.LENGTH_UNSET.toLong()
-
- private var opens = 0
-
- /** Handles opened so far; the tests assert an index seek does not walk the file. */
- val openCount: Int
- get() = synchronized(this) { opens }
-
- val isOpen: Boolean
- get() = synchronized(this) { source != null }
-
- /**
- * Reads up to [length] bytes at absolute [position]. Returns the number of
- * bytes read, or 0 at end of input. Reopens the underlying source whenever
- * the requested position is not where the current handle stands, which is the
- * only way to move backwards on a byte stream.
- */
- fun readAt(position: Long, buffer: ByteArray, length: Int): Int = synchronized(this) {
- if (length <= 0) return 0
- val handle = handleAt(position)
- val read = try {
- handle.read(buffer, 0, length)
- } catch (e: Throwable) {
- // The handle is dead but handlePosition still matches the request, so a
- // load-error retry of the same read would be handed the same broken
- // handle forever. Drop it; the retry reopens at the position (#2113).
- close()
- throw e
- }
- if (read == C.RESULT_END_OF_INPUT) return 0
- handlePosition = position + read
- read
- }
-
- /**
- * Total resource length once some open has resolved it, else
- * [C.LENGTH_UNSET]. Never performs IO of its own: the extractor prefers the
- * length media3 already resolved for the loader.
- */
- fun length(): Long = synchronized(this) { resolvedLength }
-
- fun close() = synchronized(this) {
- closeHandle()
- handlePosition = C.INDEX_UNSET.toLong()
- }
-
- private fun handleAt(position: Long): DataSource {
- val existing = source
- if (existing != null && handlePosition == position) return existing
- closeHandle()
- val spec = specProvider() ?: throw IOException("ffmpeg demuxer has no data spec for random access")
- // Clone rather than subrange: the recorded spec carries the loader's own
- // position, and subrange() is relative to it.
- val positioned = spec.buildUpon()
- .setPosition(position)
- .setLength(C.LENGTH_UNSET.toLong())
- .build()
- val created = factory.createDataSource()
- val remaining = try {
- created.open(positioned)
- } catch (e: Throwable) {
- closeQuietly(created)
- throw e
- }
- source = created
- handlePosition = position
- opens++
- if (remaining != C.LENGTH_UNSET.toLong()) resolvedLength = position + remaining
- return created
- }
-
- private fun closeHandle() {
- source?.let { closeQuietly(it) }
- source = null
- }
-
- private fun closeQuietly(dataSource: DataSource) {
- try {
- dataSource.close()
- } catch (_: IOException) {
- // Closing a handle we are abandoning cannot fail the read that replaces it.
- }
- }
-}
-
-/**
- * [DataSource.Factory] that remembers the [DataSpec] media3 opened for each
- * item, so [FfmpegRandomAccessSource] can clone it — same URI, cache key, flags
- * and request headers — instead of re-deriving one and silently losing, say, a
- * download's cache key.
- *
- * Keyed by URI because ExoPlayer prepares the next item's period while the
- * current one is still loading; the demuxer must never read the next item's
- * bytes.
- */
-@OptIn(UnstableApi::class)
-internal class RecordingDataSourceFactory(private val delegate: DataSource.Factory) : DataSource.Factory {
-
- private val specs = object : LinkedHashMap(8, 0.75f, false) {
- override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > MAX_TRACKED_ITEMS
- }
-
- fun specFor(uri: Uri): DataSpec? = synchronized(specs) { specs[uri] }
-
- override fun createDataSource(): DataSource = RecordingDataSource(delegate.createDataSource())
-
- private fun record(dataSpec: DataSpec) {
- synchronized(specs) { specs[dataSpec.uri] = dataSpec }
- }
-
- private inner class RecordingDataSource(private val inner: DataSource) : DataSource {
- override fun open(dataSpec: DataSpec): Long {
- record(dataSpec)
- return inner.open(dataSpec)
- }
-
- override fun read(buffer: ByteArray, offset: Int, length: Int): Int = inner.read(buffer, offset, length)
-
- override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener)
-
- override fun getUri(): Uri? = inner.uri
-
- override fun getResponseHeaders(): Map> = inner.responseHeaders
-
- override fun close() = inner.close()
- }
-
- private companion object {
- const val MAX_TRACKED_ITEMS = 4
- }
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt
deleted file mode 100644
index 8328c8333..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import java.util.Locale
-
-/**
- * Builds text subtitle samples in the exact shape media3's MatroskaExtractor
- * writes, so the same downstream consumers work behind the FFmpeg demuxer:
- * the bundled SsaParser/SubripParser/WebvttParser (via
- * SubtitleTranscodingExtractorOutput) and AssTrackOutput's dialogue feed.
- *
- * MatroskaExtractor prefixes each raw payload with a synthetic header whose
- * *second* timecode carries the sample duration (BlockDuration), while the
- * first stays zero; parsers reconstruct absolute times as sampleTimeUs +
- * parsedStart. FFmpeg hands us the same raw payloads (an ASS packet is the
- * MKV block: "ReadOrder,Layer,Style,...,Text"; SubRip/WebVTT packets are the
- * bare cue text), so only the prefix needs synthesizing.
- */
-internal object FfmpegSubtitleSamples {
-
- val SSA_DIALOGUE_FORMAT: ByteArray =
- "Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
- .toByteArray(Charsets.UTF_8)
-
- /** `Dialogue: 0:00:00:00,H:MM:SS:CC,` — centiseconds, ':' separator (media3's SSA shape). */
- fun ssaPrefix(durationUs: Long): ByteArray = "Dialogue: 0:00:00:00,${timecode(durationUs, "%01d:%02d:%02d:%02d", 10_000)},"
- .toByteArray(Charsets.UTF_8)
-
- /** `1\n00:00:00,000 --> HH:MM:SS,mmm\n` — a complete SRT cue header. */
- fun subripPrefix(durationUs: Long): ByteArray = "1\n00:00:00,000 --> ${timecode(durationUs, "%02d:%02d:%02d,%03d", 1_000)}\n"
- .toByteArray(Charsets.UTF_8)
-
- /** `WEBVTT\n\n00:00:00.000 --> HH:MM:SS.mmm\n` — a one-cue WebVTT document header. */
- fun vttPrefix(durationUs: Long): ByteArray = "WEBVTT\n\n00:00:00.000 --> ${timecode(durationUs, "%02d:%02d:%02d.%03d", 1_000)}\n"
- .toByteArray(Charsets.UTF_8)
-
- private fun timecode(timeUs: Long, format: String, lastValueDivisor: Long): String {
- val hours = timeUs / 3_600_000_000L
- var remainder = timeUs - hours * 3_600_000_000L
- val minutes = remainder / 60_000_000L
- remainder -= minutes * 60_000_000L
- val seconds = remainder / 1_000_000L
- remainder -= seconds * 1_000_000L
- val lastValue = remainder / lastValueDivisor
- return String.format(Locale.US, format, hours, minutes, seconds, lastValue)
- }
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt
new file mode 100644
index 000000000..01976aada
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt
@@ -0,0 +1,65 @@
+package com.edde746.plezy.exoplayer
+
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.SeekMap
+import androidx.media3.extractor.TrackOutput
+import androidx.media3.extractor.mkv.MatroskaExtractor
+
+/** MKV CodecID for Microsoft ACM compatibility mode. */
+private const val CODEC_ID_ACM = "A_MS/ACM"
+
+/**
+ * MatroskaExtractor.init is final and extractorOutput private; subclasses swap
+ * in wrapping outputs via reflection once the Segment element starts (shared
+ * with ZlibMatroskaExtractor).
+ */
+internal val matroskaExtractorOutputField by lazy {
+ MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
+ isAccessible = true
+ }
+}
+
+/** WAVEFORMATEX format tag for LOAS/LATM-wrapped AAC (WAVE_FORMAT_MPEG_LOAS). */
+private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
+
+/**
+ * Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's
+ * fallback mapping for aac_latm, which Matroska has no native codec ID for.
+ * The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate.
+ */
+fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
+ codecPrivate != null &&
+ codecPrivate.size >= 2 &&
+ ((codecPrivate[0].toInt() and 0xFF) or ((codecPrivate[1].toInt() and 0xFF) shl 8)) == WAVE_FORMAT_MPEG_LOAS
+
+/**
+ * ExtractorOutput wrapper that wraps marked tracks with [LatmTrackOutput].
+ * Call [markNextTrackLatm] before the parent extractor creates the track
+ * (i.e. before super.endMasterElement(ID_TRACK_ENTRY)).
+ */
+class LatmExtractorOutputWrapper(
+ private val delegate: ExtractorOutput
+) : ExtractorOutput {
+
+ private var nextTrackIsLatm = false
+ private val latmOutputs = mutableListOf()
+
+ fun markNextTrackLatm() {
+ nextTrackIsLatm = true
+ }
+
+ /** Resets LATM parser state after an extractor seek. */
+ fun resetTracks() {
+ latmOutputs.forEach { it.reset() }
+ }
+
+ override fun track(id: Int, type: Int): TrackOutput {
+ val original = delegate.track(id, type)
+ if (!nextTrackIsLatm) return original
+ nextTrackIsLatm = false
+ return LatmTrackOutput(original, id).also { latmOutputs.add(it) }
+ }
+
+ override fun endTracks() = delegate.endTracks()
+ override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
+}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt
deleted file mode 100644
index 4e05b95c3..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.ExtractorInput
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.PositionHolder
-
-/**
- * Delegates everything to [delegate] but hands it a wrapped [ExtractorOutput].
- * Used to compose the text pipeline (libass dialogue feed + subtitle cue
- * transcoding) around media3's raw-subtitle MatroskaExtractor without
- * subclassing or reflection.
- */
-@UnstableApi
-internal class OutputWrappingExtractor(
- private val delegate: Extractor,
- private val wrap: (ExtractorOutput) -> ExtractorOutput
-) : Extractor {
- override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
-
- override fun init(output: ExtractorOutput) = delegate.init(wrap(output))
-
- override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
-
- override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
-
- override fun release() = delegate.release()
-
- override fun getUnderlyingImplementation(): Extractor = delegate.underlyingImplementation
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt
deleted file mode 100644
index b296e4f03..000000000
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-/**
- * Rolling average bitrate of one demuxed stream, measured from packet sizes
- * over their presentation-time span — the same source of truth mpv uses for
- * its `audio-bitrate` property. It exists because containers frequently
- * declare no per-track bitrate (Matroska without BPS statistics tags), which
- * left the performance overlay with nothing to show (#2063).
- *
- * Fed from the loader thread ([onPacket]) and read from the stats path
- * ([bitrateBps]); both are synchronized. The measurement follows the demux
- * position, which runs ahead of the playhead by the buffered duration —
- * acceptable for a diagnostics overlay.
- */
-internal class StreamBitrateMeter(private val windowUs: Long = DEFAULT_WINDOW_US) {
-
- private val ptsUsQueue = ArrayDeque()
- private val sizeQueue = ArrayDeque()
- private var windowBytes = 0L
-
- @Synchronized
- fun onPacket(ptsUs: Long, sizeBytes: Int) {
- if (sizeBytes <= 0) return
- // A backwards pts jump is a seek; packets on either side of it do not
- // form a contiguous span, so the window restarts.
- val last = ptsUsQueue.lastOrNull()
- if (last != null && ptsUs < last) reset()
- ptsUsQueue.addLast(ptsUs)
- sizeQueue.addLast(sizeBytes)
- windowBytes += sizeBytes
- while (ptsUsQueue.size > 1 && ptsUsQueue.last() - ptsUsQueue.first() > windowUs) {
- ptsUsQueue.removeFirst()
- windowBytes -= sizeQueue.removeFirst()
- }
- }
-
- /**
- * Average bitrate in bits per second over the current window, or null until
- * enough packets span a measurable interval.
- */
- @Synchronized
- fun bitrateBps(): Int? {
- if (ptsUsQueue.size < MIN_PACKETS) return null
- val spanUs = ptsUsQueue.last() - ptsUsQueue.first()
- if (spanUs <= 0) return null
- // The pts span covers the durations of every packet except the last one,
- // whose bytes are therefore excluded — a constant-rate stream measures
- // exactly its rate.
- val bytes = windowBytes - sizeQueue.last()
- if (bytes <= 0) return null
- return (bytes * 8_000_000L / spanUs).toInt()
- }
-
- @Synchronized
- fun reset() {
- ptsUsQueue.clear()
- sizeQueue.clear()
- windowBytes = 0L
- }
-
- companion object {
- private const val DEFAULT_WINDOW_US = 10_000_000L
- private const val MIN_PACKETS = 16
- }
-}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt
new file mode 100644
index 000000000..156a28c78
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt
@@ -0,0 +1,91 @@
+package com.edde746.plezy.exoplayer
+
+import androidx.media3.common.ParserException
+import androidx.media3.extractor.TrackOutput
+import java.util.zip.DataFormatException
+import java.util.zip.Inflater
+
+/**
+ * TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0).
+ * Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample
+ * between sampleData() and sampleMetadata() calls.
+ *
+ * All buffers are reused across samples to minimize GC pressure on the hot path.
+ */
+class ZlibInflatingTrackOutput(
+ delegate: TrackOutput
+) : BufferedTransformingTrackOutput(
+ delegate,
+ INITIAL_BUFFER_SIZE,
+ INFLATE_CHUNK,
+ MAX_COMPRESSED_SAMPLE_SIZE
+) {
+
+ companion object {
+ private const val INITIAL_BUFFER_SIZE = 256 * 1024
+ private const val INFLATE_CHUNK = 64 * 1024
+ private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
+ private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
+ private const val MAX_COMPRESSION_RATIO = 1024L
+ private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
+ }
+
+ var active = false
+
+ private val inflater = Inflater()
+ private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
+ private val overflowProbe = ByteArray(1)
+
+ override val transformEnabled: Boolean
+ get() = active
+
+ override val transformedBuffer: ByteArray
+ get() = inflateBuf
+
+ override fun transformSample(inputLength: Int, flags: Int): Int {
+ inflater.reset()
+ inflater.setInput(inputBuffer, 0, inputLength)
+ var written = 0
+ val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
+
+ try {
+ while (true) {
+ if (written == inflateBuf.size) {
+ if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
+ val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
+ inflateBuf = inflateBuf.copyOf(nextSize)
+ } else {
+ val overflow = inflater.inflate(overflowProbe, 0, 1)
+ if (overflow > 0) {
+ throw malformed("Inflated sample exceeds the maximum size")
+ }
+ if (inflater.finished()) return written
+ throw stalledInflate()
+ }
+ }
+
+ val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
+ written += count
+ if (written.toLong() > ratioBound) {
+ throw malformed("Inflated sample exceeds the maximum compression ratio")
+ }
+ if (inflater.finished()) return written
+ if (count == 0) throw stalledInflate()
+ }
+ } catch (_: DataFormatException) {
+ // Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
+ // by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
+ if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
+ inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
+ return inputLength
+ }
+ }
+
+ private fun stalledInflate(): ParserException = when {
+ inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
+ inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
+ else -> malformed("Zlib decompressor made no progress")
+ }
+
+ private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
+}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt
new file mode 100644
index 000000000..4d7139fc8
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt
@@ -0,0 +1,315 @@
+package com.edde746.plezy.exoplayer
+
+import android.util.Log
+import androidx.media3.common.C
+import androidx.media3.common.DataReader
+import androidx.media3.extractor.DefaultExtractorInput
+import androidx.media3.extractor.ExtractorInput
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.SeekMap
+import androidx.media3.extractor.TrackOutput
+import androidx.media3.extractor.text.SubtitleParser
+import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
+import java.io.EOFException
+import java.util.zip.DataFormatException
+import java.util.zip.Inflater
+
+/**
+ * Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
+ *
+ * ContentCompAlgo 0 (zlib) — media3 only supports ContentCompAlgo 3 (header
+ * stripping). This subclass intercepts the compression algorithm during track
+ * header parsing:
+ * - Tells the parent it's header stripping (algo 3) to avoid the ParserException
+ * - Skips ContentCompSettings for zlib tracks (not applicable)
+ * - For text subtitle tracks, inflates the block payload *before* the parent
+ * parses it (see below). For every other zlib track, wraps TrackOutputs with
+ * ZlibInflatingTrackOutput to decompress per-sample data.
+ *
+ * Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput
+ * level: MatroskaExtractor rewrites their samples in-place before any TrackOutput
+ * runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to
+ * the still-compressed payload and truncates the sample at the first NUL byte,
+ * which deflate streams routinely contain (#2023). AssTrackOutput additionally
+ * feeds that same internal buffer straight to libass. So for zlib text tracks the
+ * frame payload is inflated at the block level, before the parent's sample
+ * assembly, and the TrackOutput wrapper is left inactive.
+ *
+ * LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
+ * tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
+ * which unwraps LOAS frames to raw AAC for direct-playing Matroska files.
+ */
+class ZlibMatroskaExtractor(
+ subtitleParserFactory: SubtitleParser.Factory,
+ assHandler: AssHandler
+) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
+
+ companion object {
+ private const val TAG = "ZlibMkvExtractor"
+
+ // Matroska EBML element IDs
+ private const val ID_SEGMENT = 0x18538067
+ private const val ID_TRACK_ENTRY = 0xAE
+ private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
+ private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
+ private const val ID_CONTENT_COMPRESSION = 0x5034
+ private const val ID_SIMPLE_BLOCK = 0xA3
+ private const val ID_BLOCK = 0xA1
+
+ /**
+ * Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix
+ * plus NUL truncation) before any TrackOutput runs — the authority is the
+ * prefix list in MatroskaExtractor.writeSampleData.
+ */
+ private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT")
+
+ /** Subtitle blocks are tiny; anything above this passes through untouched. */
+ private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024
+ }
+
+ private var zlibOutput: ZlibExtractorOutputWrapper? = null
+ private var latmOutput: LatmExtractorOutputWrapper? = null
+ private var currentTrackUsesZlib = false
+
+ /** Track numbers whose blocks are inflated before the parent parses them. */
+ private val zlibTextTrackNumbers = mutableSetOf()
+ private val blockInflater = Inflater()
+ private var blockBuf = ByteArray(0)
+ private val peekBuf = ByteArray(8)
+
+ override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
+ super.startMasterElement(id, contentPosition, contentSize)
+
+ // ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for
+ // zlib tracks entirely — the mere presence of ContentCompression means zlib
+ // until an explicit ContentCompAlgo says otherwise. media3 ignores an empty
+ // ContentCompression and would silently emit compressed samples.
+ if (id == ID_CONTENT_COMPRESSION) {
+ currentTrackUsesZlib = true
+ Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise")
+ }
+
+ // After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
+ // LATM layers (zlib outermost so inflation runs before LATM parsing).
+ if (id == ID_SEGMENT && zlibOutput == null) {
+ val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
+ val latmWrapper = LatmExtractorOutputWrapper(currentOutput)
+ latmOutput = latmWrapper
+ val wrapper = ZlibExtractorOutputWrapper(latmWrapper)
+ zlibOutput = wrapper
+ matroskaExtractorOutputField.set(this, wrapper)
+ Log.d(TAG, "Installed zlib+LATM ExtractorOutput wrapper")
+ }
+ }
+
+ override fun integerElement(id: Int, value: Long) {
+ if (id == ID_CONTENT_COMPRESSION_ALGORITHM) {
+ if (value == 0L) {
+ currentTrackUsesZlib = true
+ Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples")
+ // Tell parent it's header stripping (algo 3) to avoid ParserException
+ super.integerElement(id, 3)
+ return
+ }
+ // Explicit non-zlib algorithm: header stripping (3) is handled by the
+ // parent; anything else makes the parent throw, matching stock behavior.
+ currentTrackUsesZlib = false
+ }
+ super.integerElement(id, value)
+ }
+
+ override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
+ if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
+ // Skip ContentCompSettings for zlib tracks — parent would store these as
+ // sampleStrippedBytes and prepend them to every sample, corrupting output.
+ input.skipFully(contentSize)
+ return
+ }
+ if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) &&
+ zlibTextTrackNumbers.isNotEmpty() &&
+ contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES &&
+ peekBlockTrackNumber(input) in zlibTextTrackNumbers
+ ) {
+ inflateTextBlock(id, contentSize, input)
+ return
+ }
+ super.binaryElement(id, contentSize, input)
+ }
+
+ override fun endMasterElement(id: Int) {
+ var zlibTextTrackNumber: Int? = null
+ if (id == ID_TRACK_ENTRY) {
+ // Must inspect before super — the track output is created inside super's
+ // endMasterElement, and the current track is cleared afterwards.
+ val track = getCurrentTrack(id)
+ if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
+ Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
+ latmOutput?.markNextTrackLatm()
+ }
+ if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) {
+ zlibTextTrackNumber = track.number
+ }
+ }
+
+ val wasZlib = currentTrackUsesZlib
+ super.endMasterElement(id)
+
+ if (id == ID_TRACK_ENTRY && wasZlib) {
+ currentTrackUsesZlib = false
+ if (zlibTextTrackNumber != null) {
+ // Text subtitle samples are rewritten inside the parent before any
+ // TrackOutput runs, so the TrackOutput wrapper stays inactive and the
+ // block payload is inflated in binaryElement instead.
+ zlibTextTrackNumbers.add(zlibTextTrackNumber)
+ Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level")
+ } else {
+ zlibOutput?.activateLast()
+ Log.i(TAG, "Activated zlib inflation for track")
+ }
+ }
+ }
+
+ override fun seek(position: Long, timeUs: Long) {
+ latmOutput?.resetTracks()
+ zlibOutput?.resetTracks()
+ super.seek(position, timeUs)
+ }
+
+ /**
+ * Peeks the EBML varint at the block start — the block's track number — without
+ * consuming input. Returns null for malformed varints or truncated input; the
+ * parent then produces the canonical failure for the untouched stream.
+ */
+ private fun peekBlockTrackNumber(input: ExtractorInput): Int? {
+ try {
+ input.peekFully(peekBuf, 0, 1)
+ val first = peekBuf[0].toInt() and 0xFF
+ if (first == 0) return null
+ val length = Integer.numberOfLeadingZeros(first) - 23
+ var value = (first and (0xFF ushr length)).toLong()
+ if (length > 1) {
+ input.peekFully(peekBuf, 1, length - 1)
+ for (i in 1 until length) {
+ value = (value shl 8) or (peekBuf[i].toLong() and 0xFF)
+ }
+ }
+ return if (value <= Int.MAX_VALUE) value.toInt() else null
+ } catch (_: EOFException) {
+ return null
+ } finally {
+ input.resetPeekPosition()
+ }
+ }
+
+ /**
+ * Buffers one text-subtitle block, inflates its frame payload, and hands the
+ * parent a block whose payload is plaintext. A block that cannot be rewritten
+ * (laced, corrupt, or over-bound) passes through byte-identical.
+ */
+ private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) {
+ val basePosition = input.position
+ if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2))
+ input.readFully(blockBuf, 0, contentSize)
+ val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater)
+ if (rewritten == null) {
+ Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)")
+ super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition))
+ } else {
+ super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition))
+ }
+ }
+
+ private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit)
+
+ private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader {
+ private var position = 0
+
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+ if (position == limit) return C.RESULT_END_OF_INPUT
+ val count = minOf(length, limit - position)
+ System.arraycopy(data, position, buffer, offset, count)
+ position += count
+ return count
+ }
+ }
+
+ /**
+ * ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
+ * Tracks are created inactive; activateLast() enables inflation for the most recently
+ * created track (called when we know a track uses zlib compression).
+ */
+ private class ZlibExtractorOutputWrapper(
+ private val delegate: ExtractorOutput
+ ) : ExtractorOutput {
+
+ private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
+ private val trackOutputs = mutableListOf()
+
+ override fun track(id: Int, type: Int): TrackOutput {
+ val original = delegate.track(id, type)
+ return ZlibInflatingTrackOutput(original).also {
+ trackOutputs.add(it)
+ lastCreatedWrapper = it
+ }
+ }
+
+ fun activateLast() {
+ lastCreatedWrapper?.active = true
+ }
+
+ fun resetTracks() {
+ trackOutputs.forEach { it.resetBufferedData() }
+ }
+
+ override fun endTracks() = delegate.endTracks()
+ override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
+ }
+}
+
+/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */
+internal const val MIN_BLOCK_BYTES = 5
+
+/**
+ * Rewrites one unlaced Matroska block whose frame payload is zlib-compressed:
+ * returns the unchanged block header followed by the inflated payload, or null
+ * when the block must pass through unchanged — lacing (never produced for text
+ * subtitle tracks), a truncated or corrupt deflate stream, or an inflated size
+ * beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening.
+ */
+internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? {
+ if (limit < MIN_BLOCK_BYTES) return null
+ val first = data[0].toInt() and 0xFF
+ if (first == 0) return null // track-number varint longer than 8 bytes
+ val varintLength = Integer.numberOfLeadingZeros(first) - 23
+ val headerLength = varintLength + 3 // varint + 2-byte timecode + flags
+ if (limit <= headerLength) return null
+ if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced
+
+ val payloadLength = limit - headerLength
+ val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024)
+ val maxInflatedBytes = 16 * 1024 * 1024
+ inflater.reset()
+ inflater.setInput(data, headerLength, payloadLength)
+ var buf = ByteArray(maxOf(4096, payloadLength * 4))
+ var written = 0
+ try {
+ while (true) {
+ if (written == buf.size) {
+ if (buf.size >= maxInflatedBytes) return null
+ buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2))
+ }
+ val count = inflater.inflate(buf, written, buf.size - written)
+ written += count
+ if (written > ratioBound) return null
+ if (inflater.finished()) break
+ if (count == 0) return null // truncated stream or preset-dictionary request
+ }
+ } catch (_: DataFormatException) {
+ return null
+ }
+ return ByteArray(headerLength + written).also {
+ System.arraycopy(data, 0, it, 0, headerLength)
+ System.arraycopy(buf, 0, it, headerLength, written)
+ }
+}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt
deleted file mode 100644
index 528cf0799..000000000
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Test
-
-class DoviCodecsTest {
- @Test
- fun hevcProfilesUseDvhePrefix() {
- // Profile 8 level 6 is the stream from #2096: the record's dv_profile and
- // dv_level bytes must surface verbatim, matching media3's
- // DolbyVisionConfig output for the same file (dvhe.08.06).
- assertEquals("dvhe.08.06", DoviCodecs.rfc6381(8, 6))
- assertEquals("dvhe.04.09", DoviCodecs.rfc6381(4, 9))
- assertEquals("dvhe.05.06", DoviCodecs.rfc6381(5, 6))
- // Profile 7 keys DoviConvertingTrackOutput's P7 conversion.
- assertEquals("dvhe.07.06", DoviCodecs.rfc6381(7, 6))
- }
-
- @Test
- fun avcAndAv1ProfilesUseTheirOwnPrefixes() {
- assertEquals("dvav.09.05", DoviCodecs.rfc6381(9, 5))
- assertEquals("dav1.10.04", DoviCodecs.rfc6381(10, 4))
- }
-
- @Test
- fun twoDigitLevelsAreNotZeroPadded() {
- assertEquals("dvhe.08.10", DoviCodecs.rfc6381(8, 10))
- }
-
- @Test
- fun unrecognizedProfilesReturnNullSoTheBaseCodecFormatSurvives() {
- // Media3's DolbyVisionConfig table: anything it rejects must keep the
- // track's plain HEVC/AVC format instead of publishing an unplayable
- // video/dolby-vision variant.
- assertNull(DoviCodecs.rfc6381(0, 6))
- assertNull(DoviCodecs.rfc6381(2, 6))
- assertNull(DoviCodecs.rfc6381(6, 6))
- assertNull(DoviCodecs.rfc6381(20, 6))
- }
-}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt
deleted file mode 100644
index f2647d954..000000000
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.ParserException
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertSame
-import org.junit.Assert.assertThrows
-import org.junit.Test
-
-/**
- * Spec for the per-codec initializationData shapes the ffmpeg demuxer must
- * publish so MediaCodec csd-0/1/2 and FfmpegAudioDecoder's extradata
- * reconstruction both see what media3's own extractors emit. Regression
- * coverage for #2088: Opus with a bare OpusHead csd made Android's decoders
- * eat the first two real packets as configuration and discard all output.
- */
-class FfmpegAudioCsdTest {
-
- private fun opusHead(preSkip: Int): ByteArray {
- val header = ByteArray(19)
- "OpusHead".toByteArray(Charsets.US_ASCII).copyInto(header)
- header[8] = 1 // version
- header[9] = 2 // channel count
- header[10] = (preSkip and 0xFF).toByte()
- header[11] = (preSkip ushr 8).toByte()
- return header
- }
-
- private fun nativeOrderLong(bytes: ByteArray): Long = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder()).long
-
- @Test
- fun opusBuildsThreeCsdBuffers() {
- val header = opusHead(preSkip = 312)
- val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_OPUS, header)
- assertEquals(3, csd.size)
- assertArrayEquals(header, csd[0])
- // Pre-skip converted to nanoseconds at the Opus 48 kHz clock.
- assertEquals(312L * 1_000_000_000L / 48_000L, nativeOrderLong(csd[1]))
- // media3's default seek pre-roll: 3840 samples = 80 ms.
- assertEquals(80_000_000L, nativeOrderLong(csd[2]))
- }
-
- @Test
- fun opusRejectsTruncatedHeader() {
- assertThrows(ParserException::class.java) {
- FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_OPUS, ByteArray(11))
- }
- }
-
- private fun xiphLace(length: Int): ByteArray {
- val bytes = ArrayList()
- var remaining = length
- while (remaining >= 0xFF) {
- bytes.add(0xFF.toByte())
- remaining -= 0xFF
- }
- bytes.add(remaining.toByte())
- return bytes.toByteArray()
- }
-
- private fun vorbisHeader(type: Int, payloadLength: Int): ByteArray = byteArrayOf(type.toByte()) + "vorbis".toByteArray(Charsets.US_ASCII) + ByteArray(payloadLength)
-
- @Test
- fun vorbisSplitsLacedHeadersIntoIdentificationAndSetup() {
- val id = vorbisHeader(0x01, payloadLength = 23) // 30 bytes, the real identification header size
- val comment = vorbisHeader(0x03, payloadLength = 300) // > 255 exercises the 0xFF lacing
- val setup = vorbisHeader(0x05, payloadLength = 40)
- val extradata = byteArrayOf(0x02) + xiphLace(id.size) + xiphLace(comment.size) + id + comment + setup
-
- val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, extradata)
-
- assertEquals(2, csd.size)
- assertArrayEquals(id, csd[0])
- assertArrayEquals(setup, csd[1])
- }
-
- @Test
- fun vorbisRejectsNonLacedExtradata() {
- assertThrows(ParserException::class.java) {
- FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, byteArrayOf(0x01, 0x02, 0x03))
- }
- }
-
- @Test
- fun vorbisRejectsTruncatedExtradata() {
- val id = vorbisHeader(0x01, payloadLength = 23)
- val truncated = byteArrayOf(0x02) + xiphLace(id.size) + xiphLace(64) + id // headers end early
- assertThrows(ParserException::class.java) {
- FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, truncated)
- }
- }
-
- @Test
- fun flacWrapsBareStreamInfoInMarkedStream() {
- val streamInfo = ByteArray(34) { it.toByte() }
- val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, streamInfo)
- assertEquals(1, csd.size)
- assertArrayEquals(
- "fLaC".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x80.toByte(), 0x00, 0x00, 34) + streamInfo,
- csd[0]
- )
- }
-
- @Test
- fun flacPrependsMarkerToBlockHeadedStreamInfo() {
- val block = byteArrayOf(0x00, 0x00, 0x00, 34) + ByteArray(34) { it.toByte() }
- val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, block)
- assertEquals(1, csd.size)
- assertArrayEquals("fLaC".toByteArray(Charsets.US_ASCII) + block, csd[0])
- }
-
- @Test
- fun flacKeepsMarkedStreamVerbatim() {
- val marked = "fLaC".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x80.toByte(), 0x00, 0x00, 34) + ByteArray(34)
- val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, marked)
- assertEquals(1, csd.size)
- assertSame(marked, csd[0])
- }
-
- @Test
- fun flacRejectsUnrecognizedExtradata() {
- assertThrows(ParserException::class.java) {
- FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, ByteArray(10))
- }
- }
-
- @Test
- fun otherCodecsPassExtradataThroughAsSingleEntry() {
- val asc = byteArrayOf(0x12, 0x10) // AAC-LC 44.1 kHz stereo AudioSpecificConfig
- val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_AAC, asc)
- assertEquals(1, csd.size)
- assertSame(asc, csd[0])
- }
-}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt
deleted file mode 100644
index fccd981d3..000000000
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class FfmpegDemuxerPolicyTest {
- @Test
- fun wireValuesRoundTrip() {
- assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("ffmpeg"))
- assertEquals(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY, FfmpegDemuxerPolicy.fromWire("media3"))
- }
-
- @Test
- fun unknownWireValuesResolveToFfmpeg() {
- assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(null))
- assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(""))
- assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("bogus"))
- // Retired wire value from the container-list era maps to the default.
- assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("auto"))
- }
-
- @Test
- fun ffmpegDemuxesByDefaultAndMedia3OnlyDisables() {
- assertTrue(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.FFMPEG))
- assertFalse(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY))
- }
-}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt
deleted file mode 100644
index 95aeee143..000000000
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt
+++ /dev/null
@@ -1,245 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.TransferListener
-import java.io.IOException
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertSame
-import org.junit.Assert.assertThrows
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-/**
- * The demuxer's correctness now rests on this source answering any absolute
- * position, so these pin the contract libavformat depends on: a read lands on
- * the requested byte, a backward jump reopens rather than returning the wrong
- * bytes, and sequential reads do not reopen (which would turn steady-state
- * playback into one request per 64 KB).
- */
-@RunWith(RobolectricTestRunner::class)
-class FfmpegRandomAccessSourceTest {
-
- private val content = ByteArray(4096) { (it % 251).toByte() }
- private val uri: Uri = Uri.parse("https://example.test/movie.mkv")
-
- private class FakeDataSource(private val content: ByteArray) : DataSource {
- var openedAt: Long = -1
- var closed = false
- var failNextRead = false
- private var position = 0
-
- override fun open(dataSpec: DataSpec): Long {
- openedAt = dataSpec.position
- position = dataSpec.position.toInt()
- return (content.size - position).toLong()
- }
-
- override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
- if (failNextRead) {
- failNextRead = false
- throw IOException("injected read failure")
- }
- if (position >= content.size) return C.RESULT_END_OF_INPUT
- val count = minOf(length, content.size - position)
- content.copyInto(buffer, offset, position, position + count)
- position += count
- return count
- }
-
- override fun addTransferListener(transferListener: TransferListener) = Unit
-
- override fun getUri(): Uri? = null
-
- override fun getResponseHeaders(): Map> = emptyMap()
-
- override fun close() {
- closed = true
- }
- }
-
- private inner class FakeFactory : DataSource.Factory {
- val created = mutableListOf()
-
- override fun createDataSource(): DataSource = FakeDataSource(content).also { created.add(it) }
- }
-
- private fun sourceFor(factory: DataSource.Factory, spec: DataSpec? = DataSpec(uri)) = FfmpegRandomAccessSource(factory) { spec }
-
- @Test
- fun readsLandOnTheRequestedAbsolutePosition() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
- val buffer = ByteArray(16)
-
- assertEquals(16, source.readAt(1000, buffer, 16))
-
- assertArrayEquals(content.copyOfRange(1000, 1016), buffer)
- assertEquals(1000L, factory.created.single().openedAt)
- }
-
- @Test
- fun sequentialReadsReuseOneHandle() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
- val buffer = ByteArray(64)
-
- source.readAt(0, buffer, 64)
- source.readAt(64, buffer, 64)
- source.readAt(128, buffer, 64)
-
- assertEquals(1, source.openCount)
- assertArrayEquals(content.copyOfRange(128, 192), buffer)
- }
-
- @Test
- fun backwardJumpReopensAndReturnsTheRequestedBytes() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
- val buffer = ByteArray(32)
-
- source.readAt(2048, buffer, 32)
- // The jump libavformat makes for an index at the end of the file, then back.
- assertEquals(32, source.readAt(16, buffer, 32))
-
- assertArrayEquals(content.copyOfRange(16, 48), buffer)
- assertEquals(2, source.openCount)
- assertTrue("abandoned handle must be closed", factory.created.first().closed)
- }
-
- @Test
- fun failedReadDropsTheHandleSoTheRetryReopens() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
- val buffer = ByteArray(32)
-
- source.readAt(0, buffer, 32)
- factory.created.single().failNextRead = true
- assertThrows(IOException::class.java) { source.readAt(32, buffer, 32) }
- assertTrue("failed handle must be closed", factory.created.single().closed)
-
- // media3's load-error policy retries the read with the source reopened; a
- // dead handle whose position happens to match must not serve it (#2113).
- assertEquals(32, source.readAt(32, buffer, 32))
- assertArrayEquals(content.copyOfRange(32, 64), buffer)
- assertEquals(2, source.openCount)
- }
-
- @Test
- fun endOfInputReportsZeroWithoutAdvancing() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
- val buffer = ByteArray(8)
-
- assertEquals(0, source.readAt(content.size.toLong(), buffer, 8))
- }
-
- @Test
- fun lengthResolvesFromAnOpenAndStaysKnown() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
-
- assertEquals(C.LENGTH_UNSET.toLong(), source.length())
- source.readAt(512, ByteArray(8), 8)
-
- assertEquals(content.size.toLong(), source.length())
- }
-
- @Test
- fun clonedSpecKeepsCacheKeyAndFlagsButTakesOurPosition() {
- val recorded = DataSpec.Builder()
- .setUri(uri)
- .setPosition(9999)
- .setLength(1234)
- .setKey("download-cache-key")
- .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION)
- .setHttpRequestHeaders(mapOf("X-Test" to "1"))
- .build()
- var openedSpec: DataSpec? = null
- val factory = DataSource.Factory {
- object : DataSource {
- override fun open(dataSpec: DataSpec): Long {
- openedSpec = dataSpec
- return C.LENGTH_UNSET.toLong()
- }
-
- override fun read(buffer: ByteArray, offset: Int, length: Int): Int = C.RESULT_END_OF_INPUT
-
- override fun addTransferListener(transferListener: TransferListener) = Unit
-
- override fun getUri(): Uri? = null
-
- override fun getResponseHeaders(): Map> = emptyMap()
-
- override fun close() = Unit
- }
- }
-
- sourceFor(factory, recorded).readAt(4242, ByteArray(4), 4)
-
- val spec = requireNotNull(openedSpec)
- assertEquals(4242L, spec.position)
- assertEquals(C.LENGTH_UNSET.toLong(), spec.length)
- assertEquals("download-cache-key", spec.key)
- assertEquals(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION, spec.flags)
- assertEquals("1", spec.httpRequestHeaders["X-Test"])
- assertEquals(uri, spec.uri)
- }
-
- @Test
- fun readWithoutARecordedSpecFails() {
- val source = sourceFor(FakeFactory(), spec = null)
-
- assertThrows(IOException::class.java) { source.readAt(0, ByteArray(4), 4) }
- }
-
- @Test
- fun closeReleasesTheHandle() {
- val factory = FakeFactory()
- val source = sourceFor(factory)
- source.readAt(0, ByteArray(4), 4)
-
- assertTrue(source.isOpen)
- source.close()
-
- assertFalse(source.isOpen)
- assertTrue(factory.created.single().closed)
- }
-
- @Test
- fun recordingFactoryReportsTheSpecMedia3OpenedPerUri() {
- val delegate = FakeFactory()
- val recorder = RecordingDataSourceFactory(delegate)
- val other = Uri.parse("https://example.test/next-episode.mkv")
-
- assertNull(recorder.specFor(uri))
- recorder.createDataSource().open(DataSpec.Builder().setUri(uri).setPosition(64).setKey("a").build())
- recorder.createDataSource().open(DataSpec.Builder().setUri(other).setPosition(128).build())
-
- // Preloading the next item must not redirect the current demuxer's reads.
- assertEquals(64L, recorder.specFor(uri)?.position)
- assertEquals("a", recorder.specFor(uri)?.key)
- assertEquals(128L, recorder.specFor(other)?.position)
- }
-
- @Test
- fun recordingFactoryDelegatesReads() {
- val delegate = FakeFactory()
- val recorder = RecordingDataSourceFactory(delegate)
- val buffer = ByteArray(16)
-
- val dataSource = recorder.createDataSource()
- dataSource.open(DataSpec.Builder().setUri(uri).setPosition(32).build())
- assertEquals(16, dataSource.read(buffer, 0, 16))
-
- assertArrayEquals(content.copyOfRange(32, 48), buffer)
- assertSame(delegate.created.single(), delegate.created.first())
- }
-}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt
deleted file mode 100644
index 8ac723ee6..000000000
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Byte-exact spec for the media3-shaped subtitle sample prefixes. The shapes
- * mirror media3 1.11.0 MatroskaExtractor's SSA_PREFIX/SUBRIP_PREFIX/VTT_PREFIX
- * with the duration patched into the second timecode; SsaParser,
- * SubripParser, WebvttParser, and AssTrackOutput all parse these exact bytes.
- */
-class FfmpegSubtitleSamplesTest {
-
- private fun string(bytes: ByteArray): String = bytes.toString(Charsets.UTF_8)
-
- @Test
- fun ssaPrefixMatchesMatroskaExtractorShape() {
- // 1h 2m 3s 450ms -> centiseconds, ':' separator, single-digit hour.
- assertEquals(
- "Dialogue: 0:00:00:00,1:02:03:45,",
- string(FfmpegSubtitleSamples.ssaPrefix(3_723_450_000L))
- )
- assertEquals("Dialogue: 0:00:00:00,0:00:05:00,", string(FfmpegSubtitleSamples.ssaPrefix(5_000_000L)))
- // Sub-centisecond remainders truncate, matching integer division.
- assertEquals("Dialogue: 0:00:00:00,0:00:00:99,", string(FfmpegSubtitleSamples.ssaPrefix(999_999L)))
- }
-
- @Test
- fun subripPrefixIsACompleteCueHeader() {
- assertEquals(
- "1\n00:00:00,000 --> 01:02:03,450\n",
- string(FfmpegSubtitleSamples.subripPrefix(3_723_450_000L))
- )
- assertEquals("1\n00:00:00,000 --> 00:00:05,000\n", string(FfmpegSubtitleSamples.subripPrefix(5_000_000L)))
- }
-
- @Test
- fun vttPrefixIsAOneCueDocumentHeader() {
- assertEquals(
- "WEBVTT\n\n00:00:00.000 --> 01:02:03.450\n",
- string(FfmpegSubtitleSamples.vttPrefix(3_723_450_000L))
- )
- assertEquals("WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n", string(FfmpegSubtitleSamples.vttPrefix(5_000_000L)))
- }
-
- @Test
- fun ssaDialogueFormatMatchesMedia3Constant() {
- assertEquals(
- "Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
- string(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT)
- )
- }
-}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt
new file mode 100644
index 000000000..b578a8a7d
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt
@@ -0,0 +1,154 @@
+package com.edde746.plezy.exoplayer
+
+import androidx.media3.common.C
+import androidx.media3.common.DataReader
+import androidx.media3.common.Format
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.extractor.DefaultExtractorInput
+import androidx.media3.extractor.Extractor
+import androidx.media3.extractor.ExtractorInput
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.PositionHolder
+import androidx.media3.extractor.SeekMap
+import androidx.media3.extractor.TrackOutput
+import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class MatroskaLateTracksTest {
+
+ private class CapturedTrack(val type: Int) : TrackOutput {
+ val timesUs = mutableListOf()
+ 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()
+ val seekMaps = mutableListOf()
+
+ 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())
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt
similarity index 58%
rename from android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt
rename to android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt
index fbc23f1c2..d918227ef 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt
@@ -5,7 +5,14 @@ import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.extractor.DefaultExtractorInput
+import androidx.media3.extractor.Extractor
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.PositionHolder
+import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
+import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import java.io.EOFException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -17,17 +24,16 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
- * Drives LatmTrackOutput with LOAS frames from the committed fixture (1s 440Hz
- * sine, AAC-LC 48kHz stereo, LATM/LOAS) and verifies it unwraps them to raw
- * AAC with a synthesized AudioSpecificConfig. Both demuxer paths deliver LOAS
- * this way: the FFmpeg extractor wraps aac_latm tracks directly.
+ * Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS
+ * muxed into MKV as A_MS/ACM tag 0x1602) and verifies direct Matroska playback
+ * unwraps LOAS frames to raw AAC with a synthesized AudioSpecificConfig.
*
- * Robolectric provides real android.util.* implementations for media3's
- * internals.
+ * Robolectric provides real android.util.* implementations — MatroskaExtractor
+ * stores tracks in a SparseArray, which is a no-op stub on plain JVM.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
-class LatmTrackOutputTest {
+class MatroskaLatmSupportTest {
private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray)
@@ -65,6 +71,14 @@ class LatmTrackOutputTest {
}
}
+ private class FakeExtractorOutput : ExtractorOutput {
+ val tracks = mutableMapOf()
+
+ override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
+ override fun endTracks() {}
+ override fun seekMap(seekMap: SeekMap) {}
+ }
+
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
@@ -104,46 +118,76 @@ class LatmTrackOutputTest {
return frames
}
- private fun placeholderFormat(): Format = Format.Builder()
- .setId("1")
- .setSampleMimeType(MimeTypes.AUDIO_AAC)
- .setLabel("LATM track")
- .setLanguage("eng")
- .build()
+ private fun extractFixture(): FakeExtractorOutput {
+ val data = fixtureData()
+
+ val assHandler = AssHandler()
+ val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
+ val output = FakeExtractorOutput()
+ extractor.init(output)
+
+ val reader = ByteArrayDataReader(data)
+ var input = DefaultExtractorInput(reader, 0, data.size.toLong())
+ val seekPosition = PositionHolder()
+ while (true) {
+ when (extractor.read(input, seekPosition)) {
+ Extractor.RESULT_END_OF_INPUT -> return output
+ Extractor.RESULT_SEEK -> {
+ reader.position = seekPosition.position
+ input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
+ }
+ else -> {}
+ }
+ }
+ }
@Test
- fun unwrapsLoasFramesToRawAac() {
- val delegate = FakeTrackOutput()
- val output = LatmTrackOutput(delegate, 1)
- output.format(placeholderFormat())
+ fun unwrapsLoasAcmTrackToRawAac() {
+ val output = extractFixture()
- val frames = loasFrames(40)
- var timeUs = 0L
- for (frame in frames) {
- output.sampleData(ParsableByteArray(frame), frame.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
- output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, frame.size, 0, null)
- timeUs += 21_333
- }
+ assertEquals(1, output.tracks.size)
+ val track = output.tracks.values.first()
- // The placeholder format must be swallowed; the LATM-derived AAC format
- // carries the AudioSpecificConfig and the original track metadata.
- val format = delegate.formats.last()
+ // No audio/x-unknown format may reach the queue; the LATM-derived AAC
+ // format must carry the AudioSpecificConfig for MediaCodec.
+ assertFalse(track.formats.any { it.sampleMimeType == MimeTypes.AUDIO_UNKNOWN })
+ val format = track.formats.last()
assertEquals(MimeTypes.AUDIO_AAC, format.sampleMimeType)
assertEquals(48000, format.sampleRate)
assertEquals(2, format.channelCount)
- assertEquals("1", format.id)
- assertEquals("LATM track", format.label)
assertTrue(format.initializationData.isNotEmpty())
assertTrue(format.initializationData[0].isNotEmpty())
- assertTrue("expected ~40 samples, got ${delegate.samples.size}", delegate.samples.size in 35..40)
- for (sample in delegate.samples) {
+ // 1s at 48kHz / 1024 samples per AAC frame ≈ 47 frames
+ assertTrue("expected ~47 samples, got ${track.samples.size}", track.samples.size in 40..55)
+
+ // Raw AAC payloads: smaller than the LOAS wrapping, no LOAS syncword,
+ // keyframe-flagged, monotonic timestamps spanning ~1s.
+ var prevTimeUs = Long.MIN_VALUE
+ for (sample in track.samples) {
assertTrue(sample.data.isNotEmpty())
val isLoasSync = sample.data.size >= 2 &&
(sample.data[0].toInt() and 0xFF) == 0x56 &&
(sample.data[1].toInt() and 0xE0) == 0xE0
assertFalse("sample still LOAS-framed", isLoasSync)
+ assertEquals(C.BUFFER_FLAG_KEY_FRAME, sample.flags and C.BUFFER_FLAG_KEY_FRAME)
+ assertTrue(sample.timeUs >= prevTimeUs)
+ prevTimeUs = sample.timeUs
}
+ val spanUs = track.samples.last().timeUs - track.samples.first().timeUs
+ assertTrue("expected ~1s span, got ${spanUs}us", spanUs in 800_000..1_200_000)
+ }
+
+ @Test
+ fun detectsLoasAcmCodecPrivate() {
+ val loas = byteArrayOf(0x02, 0x16, 0, 0, 0, 0)
+ assertTrue(isLoasAcmTrack("A_MS/ACM", loas))
+ // Wrong tag (PCM), wrong codec, or missing private data must not match
+ assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x01, 0x00, 0, 0)))
+ assertFalse(isLoasAcmTrack("A_AAC", loas))
+ assertFalse(isLoasAcmTrack("A_MS/ACM", null))
+ assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x02)))
+ assertFalse(isLoasAcmTrack(null, loas))
}
@Test
@@ -161,14 +205,19 @@ class LatmTrackOutputTest {
}
@Test
- fun preservesStreamMuxConfigAcrossReset() {
+ fun preservesStreamMuxConfigAcrossExtractorSeek() {
val frames = loasFrames(2)
assertEquals(0, frames[0][3].toInt() and 0x80)
assertEquals(0x80, frames[1][3].toInt() and 0x80)
val delegate = FakeTrackOutput()
val output = LatmTrackOutput(delegate, 1)
- output.format(placeholderFormat())
+ output.format(
+ Format.Builder()
+ .setId("1")
+ .setSampleMimeType(MimeTypes.AUDIO_UNKNOWN)
+ .build()
+ )
output.sampleData(ParsableByteArray(frames[0]), frames[0].size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, frames[0].size, 0, null)
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt
deleted file mode 100644
index 58ee52753..000000000
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.edde746.plezy.exoplayer
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Test
-
-class StreamBitrateMeterTest {
-
- /** Feeds [count] packets of [sizeBytes] every [intervalUs], starting at [startUs]. */
- private fun StreamBitrateMeter.feed(count: Int, sizeBytes: Int, intervalUs: Long, startUs: Long = 0L) {
- for (i in 0 until count) onPacket(startUs + i * intervalUs, sizeBytes)
- }
-
- @Test
- fun `constant packet stream measures its bitrate`() {
- val meter = StreamBitrateMeter()
- // 1200-byte packets every 32 ms: 1200 * 8 / 0.032 = 300 kbps.
- meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
- assertEquals(300_000, meter.bitrateBps())
- }
-
- @Test
- fun `too few packets report nothing`() {
- val meter = StreamBitrateMeter()
- meter.feed(count = 15, sizeBytes = 1200, intervalUs = 32_000)
- assertNull(meter.bitrateBps())
- }
-
- @Test
- fun `zero pts span reports nothing`() {
- val meter = StreamBitrateMeter()
- repeat(32) { meter.onPacket(0L, 1200) }
- assertNull(meter.bitrateBps())
- }
-
- @Test
- fun `window drops old packets so a bitrate change is followed`() {
- val meter = StreamBitrateMeter(windowUs = 1_000_000)
- meter.feed(count = 100, sizeBytes = 1200, intervalUs = 32_000)
- // Continue at four times the packet size; after well over a window of
- // new packets, only the new rate remains.
- meter.feed(count = 100, sizeBytes = 4800, intervalUs = 32_000, startUs = 100 * 32_000L)
- assertEquals(1_200_000, meter.bitrateBps())
- }
-
- @Test
- fun `backwards pts jump resets the window`() {
- val meter = StreamBitrateMeter()
- meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
- // Seek back: the single post-seek packet must not combine with the
- // pre-seek span.
- meter.onPacket(0L, 1200)
- assertNull(meter.bitrateBps())
- meter.feed(count = 31, sizeBytes = 1200, intervalUs = 32_000, startUs = 32_000)
- assertEquals(300_000, meter.bitrateBps())
- }
-
- @Test
- fun `empty packets are ignored`() {
- val meter = StreamBitrateMeter()
- meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
- meter.onPacket(32 * 32_000L, 0)
- assertEquals(300_000, meter.bitrateBps())
- }
-}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt
new file mode 100644
index 000000000..35c29815f
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt
@@ -0,0 +1,248 @@
+package com.edde746.plezy.exoplayer
+
+import androidx.media3.common.C
+import androidx.media3.common.DataReader
+import androidx.media3.common.Format
+import androidx.media3.common.ParserException
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.extractor.TrackOutput
+import java.io.ByteArrayOutputStream
+import java.util.zip.Deflater
+import java.util.zip.DeflaterOutputStream
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class ZlibInflatingTrackOutputTest {
+ @Before
+ fun enableMedia3TestBoundsChecks() {
+ ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
+ }
+
+ @After
+ fun resetMedia3TestBoundsChecks() {
+ ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
+ }
+
+ @Test
+ fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
+ val original = ByteArray(512 * 1024) { (it % 251).toByte() }
+ val compressed = deflate(original)
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+ val split = compressed.size / 2
+
+ output.sampleData(
+ ParsableByteArray(compressed.copyOfRange(0, split)),
+ split,
+ TrackOutput.SAMPLE_DATA_PART_MAIN
+ )
+ output.sampleData(
+ ParsableByteArray(compressed.copyOfRange(split, compressed.size)),
+ compressed.size - split,
+ TrackOutput.SAMPLE_DATA_PART_MAIN
+ )
+ output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
+
+ assertArrayEquals(original, delegate.retained.toByteArray())
+ assertEquals(42L, delegate.timeUs)
+ assertEquals(C.BUFFER_FLAG_KEY_FRAME, delegate.flags)
+ assertEquals(original.size, delegate.sampleSize)
+ assertEquals(0, delegate.offset)
+ assertEquals(1, delegate.metadataCount)
+ }
+
+ @Test
+ fun activeTransformUsesMetadataBoundariesForBufferedSamples() {
+ val first = ByteArray(4096) { (it % 19).toByte() }
+ val second = ByteArray(2048) { (it % 23).toByte() }
+ val firstCompressed = deflate(first)
+ val secondCompressed = deflate(second)
+ val combined = firstCompressed + secondCompressed
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ output.sampleData(ParsableByteArray(combined), combined.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
+ output.sampleMetadata(1, 0, firstCompressed.size, secondCompressed.size, null)
+ output.sampleMetadata(2, 0, secondCompressed.size, 0, null)
+
+ assertArrayEquals(first + second, delegate.retained.toByteArray())
+ assertEquals(2, delegate.metadataCount)
+ }
+
+ @Test
+ fun exactlySixteenMiBInflatedSampleIsAccepted() {
+ val size = 16 * 1024 * 1024
+ val compressed = deflateRepeated(size)
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ feed(output, compressed)
+
+ assertEquals(size, delegate.byteCount)
+ assertEquals(size, delegate.sampleSize)
+ assertEquals(1, delegate.metadataCount)
+ assertTrue(compressed.size < 64 * 1024)
+ }
+
+ @Test
+ fun oneByteOverInflatedLimitFailsBeforeDelegation() {
+ val compressed = deflateRepeated(16 * 1024 * 1024 + 1)
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ assertThrows(ParserException::class.java) { feed(output, compressed) }
+
+ assertEquals(0, delegate.byteCount)
+ assertEquals(0, delegate.metadataCount)
+ assertTrue(compressed.size < 64 * 1024)
+ }
+
+ @Test
+ fun excessiveCompressionRatioFailsBeforeInflatedSizeLimit() {
+ val compressed = deflate(ByteArray(8 * 1024 * 1024))
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ assertThrows(ParserException::class.java) { feed(output, compressed) }
+
+ assertEquals(0, delegate.byteCount)
+ assertEquals(0, delegate.metadataCount)
+ }
+
+ @Test
+ fun corruptStreamPassesTheBoundedSampleThroughUnchanged() {
+ val corrupt = byteArrayOf(0, 1, 2, 3)
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ feed(output, corrupt)
+
+ assertArrayEquals(corrupt, delegate.retained.toByteArray())
+ assertEquals(corrupt.size, delegate.sampleSize)
+ assertEquals(1, delegate.metadataCount)
+ }
+
+ @Test
+ fun truncatedAndDictionaryStreamsFailClosed() {
+ val valid = deflate(ByteArray(4096) { 7 })
+ val dictionary = "shared-zlib-dictionary".toByteArray()
+ val cases = listOf(
+ valid.copyOf(valid.size - 2),
+ deflate(ByteArray(4096) { 3 }, dictionary)
+ )
+
+ for (compressed in cases) {
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ assertThrows(ParserException::class.java) { feed(output, compressed) }
+ assertEquals(0, delegate.byteCount)
+ assertEquals(0, delegate.metadataCount)
+ }
+ }
+
+ @Test
+ fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() {
+ val bytes = byteArrayOf(9, 8, 7, 6)
+ val trailing = byteArrayOf(5, 4)
+ val allBytes = bytes + trailing
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate)
+
+ output.sampleData(ParsableByteArray(allBytes), allBytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
+ output.sampleMetadata(99L, 3, bytes.size, trailing.size, null)
+
+ assertArrayEquals(allBytes, delegate.retained.toByteArray())
+ assertEquals(bytes.size, delegate.sampleSize)
+ assertEquals(trailing.size, delegate.offset)
+ assertEquals(99L, delegate.timeUs)
+ }
+
+ private fun feed(output: ZlibInflatingTrackOutput, compressed: ByteArray) {
+ output.sampleData(
+ ParsableByteArray(compressed),
+ compressed.size,
+ TrackOutput.SAMPLE_DATA_PART_MAIN
+ )
+ output.sampleMetadata(1L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
+ }
+
+ private fun deflate(bytes: ByteArray, dictionary: ByteArray? = null): ByteArray {
+ val target = ByteArrayOutputStream()
+ val deflater = Deflater().apply {
+ if (dictionary != null) setDictionary(dictionary)
+ }
+ DeflaterOutputStream(target, deflater).use { it.write(bytes) }
+ return target.toByteArray()
+ }
+
+ private fun deflateRepeated(size: Int): ByteArray {
+ val target = ByteArrayOutputStream()
+ val chunk = ByteArray(8192) { (it % 7).toByte() }
+ DeflaterOutputStream(target).use { stream ->
+ var remaining = size
+ while (remaining > 0) {
+ val count = minOf(remaining, chunk.size)
+ stream.write(chunk, 0, count)
+ remaining -= count
+ }
+ }
+ return target.toByteArray()
+ }
+
+ private class RecordingTrackOutput(
+ private val retainBytes: Boolean = false
+ ) : TrackOutput {
+ val retained = ByteArrayOutputStream()
+ var byteCount = 0
+ var metadataCount = 0
+ var timeUs = C.TIME_UNSET
+ var flags = 0
+ var sampleSize = -1
+ var offset = -1
+
+ override fun format(format: Format) = Unit
+
+ override fun sampleData(
+ input: DataReader,
+ length: Int,
+ allowEndOfInput: Boolean,
+ sampleDataPart: Int
+ ): Int {
+ val buffer = ByteArray(length)
+ val read = input.read(buffer, 0, length)
+ if (read > 0) record(buffer, read)
+ return read
+ }
+
+ override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
+ val buffer = ByteArray(length)
+ data.readBytes(buffer, 0, length)
+ record(buffer, length)
+ }
+
+ private fun record(buffer: ByteArray, length: Int) {
+ byteCount += length
+ if (retainBytes) retained.write(buffer, 0, length)
+ }
+
+ override fun sampleMetadata(
+ timeUs: Long,
+ flags: Int,
+ size: Int,
+ offset: Int,
+ cryptoData: TrackOutput.CryptoData?
+ ) {
+ metadataCount++
+ this.timeUs = timeUs
+ this.flags = flags
+ sampleSize = size
+ this.offset = offset
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt
new file mode 100644
index 000000000..8fdc31396
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt
@@ -0,0 +1,80 @@
+package com.edde746.plezy.exoplayer
+
+import java.io.ByteArrayOutputStream
+import java.util.zip.Deflater
+import java.util.zip.DeflaterOutputStream
+import java.util.zip.Inflater
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint,
+ * timecode, flags) must survive byte-identically, the payload must inflate, and
+ * every block the rewriter cannot prove safe must pass through unchanged (null).
+ */
+class ZlibTextBlockTest {
+
+ private val inflater = Inflater()
+
+ private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload
+
+ private fun deflate(bytes: ByteArray): ByteArray {
+ val target = ByteArrayOutputStream()
+ DeflaterOutputStream(target, Deflater()).use { it.write(bytes) }
+ return target.toByteArray()
+ }
+
+ // Track 3 (varint 0x83), relative timecode 0x0102, no lacing.
+ private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00)
+
+ @Test
+ fun inflatesUnlacedBlockPayloadAndPreservesHeader() {
+ val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray()
+ val data = block(unlacedHeader, deflate(dialogue))
+
+ val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
+
+ assertArrayEquals(unlacedHeader + dialogue, rewritten)
+ }
+
+ @Test
+ fun twoByteTrackNumberVarintIsPreserved() {
+ val dialogue = "1,,Default,,0,0,0,,hello".toByteArray()
+ val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298
+ val data = block(header, deflate(dialogue))
+
+ val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
+
+ assertArrayEquals(header + dialogue, rewritten)
+ }
+
+ @Test
+ fun lacedBlockPassesThrough() {
+ val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set
+ val data = block(laced, deflate("payload".toByteArray()))
+
+ assertNull(rewriteZlibTextBlock(data, data.size, inflater))
+ }
+
+ @Test
+ fun corruptStreamPassesThrough() {
+ val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib
+
+ assertNull(rewriteZlibTextBlock(data, data.size, inflater))
+ }
+
+ @Test
+ fun truncatedStreamPassesThrough() {
+ val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray())
+ val truncated = compressed.copyOf(compressed.size - 4)
+ val data = block(unlacedHeader, truncated)
+
+ assertNull(rewriteZlibTextBlock(data, data.size, inflater))
+ }
+
+ @Test
+ fun headerOnlyBlockPassesThrough() {
+ assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater))
+ }
+}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt
new file mode 100644
index 000000000..af4b81d41
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt
@@ -0,0 +1,170 @@
+package com.edde746.plezy.exoplayer
+
+import androidx.media3.common.C
+import androidx.media3.common.DataReader
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.extractor.DefaultExtractorInput
+import androidx.media3.extractor.Extractor
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.PositionHolder
+import androidx.media3.extractor.SeekMap
+import androidx.media3.extractor.TrackOutput
+import androidx.media3.extractor.text.CueDecoder
+import androidx.media3.extractor.text.DefaultSubtitleParserFactory
+import com.edde746.plezy.libass.media.AssHandler
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with
+ * `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that
+ * broke #2023) and an identical uncompressed mux — and verifies the zlib block
+ * payloads are inflated *before* MatroskaExtractor's subtitle sample assembly.
+ *
+ * Regenerate with:
+ * mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass
+ * mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass
+ * where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events.
+ *
+ * Robolectric provides real android.util/android.os implementations —
+ * MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which
+ * are no-op stubs on plain JVM.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class ZlibTextSubtitleExtractionTest {
+
+ private class CapturedSample(val timeUs: Long, val data: ByteArray)
+
+ private class FakeTrackOutput : TrackOutput {
+ val formats = mutableListOf()
+ val samples = mutableListOf()
+ 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()
+
+ 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 {
+ 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)
+ }
+ }
+}
diff --git a/android/libass/build.gradle.kts b/android/libass/build.gradle.kts
index bbf92103a..6998c8234 100644
--- a/android/libass/build.gradle.kts
+++ b/android/libass/build.gradle.kts
@@ -58,6 +58,4 @@ dependencies {
implementation("androidx.media3:media3-ui:1.11.0")
testImplementation("junit:junit:4.13.2")
- // media3 Format/MimeTypes touch android.util in static initializers.
- testImplementation("org.robolectric:robolectric:4.16.1")
}
diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt
deleted file mode 100644
index 66f9c1710..000000000
--- a/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.edde746.plezy.libass.media
-
-/**
- * Shared policy for embedded font attachments delivered to libass: which
- * attachment mime types are fonts, and the per-font/aggregate byte budgets
- * that keep a hostile container from ballooning the font store.
- */
-object AssFonts {
- const val MAX_FONT_BYTES = 16L * 1024 * 1024
- const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
-
- val fontMimeTypes = listOf(
- "font/ttf",
- "font/otf",
- "font/sfnt",
- "font/woff",
- "font/woff2",
- "application/font-sfnt",
- "application/font-woff",
- "application/x-truetype-font",
- "application/vnd.ms-opentype",
- "application/x-font-ttf"
- )
-
- /** Null to accept the font, or a human-readable rejection reason. */
- fun rejectionReason(size: Long, acceptedBytes: Long): String? = when {
- size > MAX_FONT_BYTES -> "per-font limit"
- size > MAX_TOTAL_FONT_BYTES - acceptedBytes -> "aggregate limit"
- else -> null
- }
-}
diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt
new file mode 100644
index 000000000..9a7bdb48e
--- /dev/null
+++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt
@@ -0,0 +1,169 @@
+package com.edde746.plezy.libass.media.extractor
+
+import android.util.Log
+import androidx.annotation.OptIn
+import androidx.media3.common.ParserException
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.extractor.ExtractorInput
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.mkv.EbmlProcessor
+import androidx.media3.extractor.mkv.MatroskaExtractor
+import androidx.media3.extractor.text.SubtitleParser
+import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
+
+@OptIn(UnstableApi::class)
+open class AssMatroskaExtractor(
+ subtitleParserFactory: SubtitleParser.Factory,
+ private val assHandler: AssHandler,
+ flags: Int = 0
+) : MatroskaExtractor(subtitleParserFactory, flags) {
+
+ private var currentAttachmentName: String? = null
+ private var currentAttachmentMime: String? = null
+ internal var acceptedFontBytes = 0L
+ private set
+
+ internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray
+
+ override fun getElementType(id: Int): Int = when (id) {
+ ID_ATTACHMENTS -> EbmlProcessor.ELEMENT_TYPE_MASTER
+ ID_ATTACHED_FILE -> EbmlProcessor.ELEMENT_TYPE_MASTER
+ ID_FILE_NAME -> EbmlProcessor.ELEMENT_TYPE_STRING
+ ID_FILE_MIME_TYPE -> EbmlProcessor.ELEMENT_TYPE_STRING
+ ID_FILE_DATA -> EbmlProcessor.ELEMENT_TYPE_BINARY
+ else -> super.getElementType(id)
+ }
+
+ override fun isLevel1Element(id: Int): Boolean = super.isLevel1Element(id) || id == ID_ATTACHMENTS
+
+ override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
+ when (id) {
+ ID_EBML -> {
+ val currentExtractor = extractorOutput.get(this) as ExtractorOutput
+ if (currentExtractor !is AssSubtitleExtractorOutput) {
+ extractorOutput.set(
+ this,
+ AssSubtitleExtractorOutput(currentExtractor, assHandler, this)
+ )
+ }
+ super.startMasterElement(id, contentPosition, contentSize)
+ }
+ ID_ATTACHED_FILE -> clearAttachment()
+ else -> super.startMasterElement(id, contentPosition, contentSize)
+ }
+ }
+
+ override fun endMasterElement(id: Int) {
+ when (id) {
+ ID_VIDEO -> {
+ // We need to get the video dimensions very early
+ val track = getCurrentTrack(id)
+ assHandler.setVideoSize(track.width, track.height)
+ super.endMasterElement(id)
+ }
+ ID_ATTACHED_FILE -> clearAttachment()
+ else -> super.endMasterElement(id)
+ }
+ }
+
+ override fun stringElement(id: Int, value: String) {
+ when (id) {
+ ID_FILE_NAME -> currentAttachmentName = value
+ ID_FILE_MIME_TYPE -> currentAttachmentMime = value
+ else -> super.stringElement(id, value)
+ }
+ }
+
+ override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
+ when (id) {
+ ID_FILE_DATA -> {
+ if (contentSize < 0) {
+ throw ParserException.createForMalformedContainer(
+ "Negative Matroska attachment size",
+ null
+ )
+ }
+
+ val attachmentName = requireNotNull(currentAttachmentName)
+ val attachmentMime = requireNotNull(currentAttachmentMime)
+ if (attachmentMime !in fontMimeTypes) {
+ input.skipFully(contentSize)
+ return
+ }
+ if (contentSize == 0) {
+ input.skipFully(0)
+ return
+ }
+
+ val size = contentSize.toLong()
+ val rejectionReason = when {
+ size > MAX_FONT_BYTES -> "per-font limit"
+ size > MAX_TOTAL_FONT_BYTES - acceptedFontBytes -> "aggregate limit"
+ else -> null
+ }
+ if (rejectionReason != null) {
+ onFontRejected(contentSize, acceptedFontBytes, rejectionReason)
+ input.skipFully(contentSize)
+ return
+ }
+
+ val data = ByteArray(contentSize)
+ input.readFully(data, 0, contentSize)
+ acceptedFontBytes += size
+ assHandler.addFont(attachmentName, data)
+ }
+ else -> super.binaryElement(id, contentSize, input)
+ }
+ }
+
+ protected open fun onFontRejected(
+ contentSize: Int,
+ acceptedBytes: Long,
+ reason: String
+ ) {
+ Log.w(
+ TAG,
+ "Skipping embedded font: $reason (bytes=$contentSize, accepted=$acceptedBytes)"
+ )
+ }
+
+ private fun clearAttachment() {
+ currentAttachmentName = null
+ currentAttachmentMime = null
+ }
+
+ companion object {
+ private const val TAG = "AssMatroskaExtractor"
+ internal const val MAX_FONT_BYTES = 16L * 1024 * 1024
+ internal const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
+ const val ID_EBML = 0x1A45DFA3
+ const val ID_VIDEO = 0xE0
+ const val ID_ATTACHMENTS = 0x1941A469
+ const val ID_ATTACHED_FILE = 0x61A7
+ const val ID_FILE_NAME = 0x466E
+ const val ID_FILE_MIME_TYPE = 0x4660
+ const val ID_FILE_DATA = 0x465C
+
+ val fontMimeTypes = listOf(
+ "font/ttf",
+ "font/otf",
+ "font/sfnt",
+ "font/woff",
+ "font/woff2",
+ "application/font-sfnt",
+ "application/font-woff",
+ "application/x-truetype-font",
+ "application/vnd.ms-opentype",
+ "application/x-font-ttf"
+ )
+
+ val extractorOutput = MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
+ isAccessible = true
+ }
+ val subtitleSampleField = MatroskaExtractor::class.java.getDeclaredField("subtitleSample").apply {
+ isAccessible = true
+ }
+ }
+}
diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt
index 68c68be8e..ed4836cfd 100644
--- a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt
+++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt
@@ -5,22 +5,21 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.TrackOutput
import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
/**
- * Wraps every text track with [AssTrackOutput] so embedded ASS dialogue
- * reaches libass. Install it on the raw-subtitle side of the output chain —
- * between the extractor and the SubtitleTranscodingExtractorOutput — so the
- * dialogue bytes are still unparsed when they pass through.
+ * This class is only used by the overlay renderer. It's needed to get the start time of the subtitles.
*/
@UnstableApi
class AssSubtitleExtractorOutput(
private val delegate: ExtractorOutput,
- private val assHandler: AssHandler
+ private val assHandler: AssHandler,
+ private val extractor: AssMatroskaExtractor
) : ExtractorOutput by delegate {
override fun track(id: Int, type: Int): TrackOutput = if (type == C.TRACK_TYPE_TEXT) {
- // We can't know at this time if the subtitle track is ASS or another
- // format, so we wrap every subtitle track; non-ASS tracks pass through.
- AssTrackOutput(delegate.track(id, type), assHandler::readTrackDialogue)
+ // We can't know at this time if the subtitle track is ASS or other format, so we wrap
+ // every subtitle track
+ AssTrackOutput(delegate.track(id, type), assHandler, extractor)
} else {
delegate.track(id, type)
}
diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt
index b2138e2a0..b68d6c05b 100644
--- a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt
+++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt
@@ -1,49 +1,28 @@
package com.edde746.plezy.libass.media.text
import androidx.media3.common.C
-import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
-import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.extractor.TrackOutput
-import java.io.EOFException
+import com.edde746.plezy.libass.media.AssHandler
+import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
import java.util.regex.Pattern
/**
- * Feeds embedded ASS dialogue to libass while forwarding every sample
- * unchanged. Only used by the overlay renderer, which needs the start time and
- * duration of each dialogue line.
- *
- * Samples must arrive in the media3 MatroskaExtractor subtitle shape
- * `Dialogue: 0:00:00:00,,` — the
- * second field carries the sample *duration* (MatroskaExtractor patches
- * BlockDuration into it; the FFmpeg extractor writes the packet duration).
- * The extractor beneath must therefore emit raw subtitle samples: media3's
- * MatroskaExtractor with FLAG_EMIT_RAW_SUBTITLE_DATA, or the FFmpeg demuxer
- * path. Cue transcoding happens downstream of this wrapper.
+ * This class is only used by the overlay renderer. It's needed to get the start time of the subtitles.
*/
@UnstableApi
class AssTrackOutput(
private val delegate: TrackOutput,
- private val dialogueSink: DialogueSink
-) : TrackOutput {
-
- /** Receives one dialogue line; mirrors AssHandler.readTrackDialogue. */
- fun interface DialogueSink {
- fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int)
- }
+ private val assHandler: AssHandler,
+ private val extractor: AssMatroskaExtractor
+) : TrackOutput by delegate {
private var isAss = false
- private var trackId: String? = null
- // Sample bytes buffered between sampleData and sampleMetadata; only
- // populated for ASS tracks.
- private var buf = ByteArray(INITIAL_BUFFER_SIZE)
- private var bufLen = 0
- private var readBuf = ByteArray(INITIAL_BUFFER_SIZE)
- private val forwardParsable = ParsableByteArray()
+ private var trackId: String? = null
override fun format(format: Format) {
if (format.sampleMimeType == MimeTypes.TEXT_SSA || format.codecs == MimeTypes.TEXT_SSA) {
@@ -53,34 +32,6 @@ class AssTrackOutput(
delegate.format(format)
}
- override fun sampleData(
- input: DataReader,
- length: Int,
- allowEndOfInput: Boolean,
- sampleDataPart: Int
- ): Int {
- if (!isAss) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
- if (readBuf.size < length) readBuf = ByteArray(length)
- val bytesRead = input.read(readBuf, 0, length)
- if (bytesRead == C.RESULT_END_OF_INPUT) {
- if (!allowEndOfInput) throw EOFException()
- return C.RESULT_END_OF_INPUT
- }
- append(readBuf, bytesRead)
- forwardParsable.reset(readBuf, bytesRead)
- delegate.sampleData(forwardParsable, bytesRead, sampleDataPart)
- return bytesRead
- }
-
- override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
- if (isAss) {
- ensureCapacity(bufLen + length)
- System.arraycopy(data.data, data.position, buf, bufLen, length)
- bufLen += length
- }
- delegate.sampleData(data, length, sampleDataPart)
- }
-
override fun sampleMetadata(
timeUs: Long,
flags: Int,
@@ -88,37 +39,26 @@ class AssTrackOutput(
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
- if (isAss) {
- // offset counts bytes buffered after this sample; the slice math matches
- // media3's TrackOutput contract even though subtitle samples are
- // normally delivered one at a time with offset 0.
- val start = bufLen - offset - size
- if (timeUs != C.TIME_UNSET && start >= 0) {
- feedDialogue(timeUs, start, size)
- }
- if (offset == 0) bufLen = 0
+ if (isAss && timeUs.isValidTs) {
+ val sample = extractor.subtitleSample
+ val endIndex = findTokenIndex(sample.data, 1)
+ val lineIndex = findTokenIndex(sample.data, 2)
+
+ val rawDuration = sample.data.decodeToString(endIndex, lineIndex - 1)
+ val durationUs = parseTimecodeUs(rawDuration)
+
+ assHandler.readTrackDialogue(
+ trackId = trackId,
+ start = timeUs / 1000,
+ duration = durationUs / 1000,
+ data = sample.data,
+ offset = lineIndex,
+ length = sample.limit() - lineIndex
+ )
}
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
}
- private fun feedDialogue(timeUs: Long, start: Int, size: Int) {
- val end = start + size
- val durationIndex = findTokenIndex(start, end, 1)
- val lineIndex = findTokenIndex(start, end, 2)
- if (durationIndex <= start || lineIndex <= durationIndex) return
- val rawDuration = buf.decodeToString(durationIndex, lineIndex - 1)
- val durationUs = parseTimecodeUs(rawDuration)
- if (durationUs == C.TIME_UNSET) return
- dialogueSink.onDialogue(
- trackId,
- timeUs / 1000,
- durationUs / 1000,
- buf,
- lineIndex,
- end - lineIndex
- )
- }
-
private fun parseTimecodeUs(timeString: String): Long {
val matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim { it <= ' ' })
if (!matcher.matches()) {
@@ -132,32 +72,24 @@ class AssTrackOutput(
return timestampUs
}
- /** Index just past the [tokenNumber]th comma inside [from, to), or [from]. */
- private fun findTokenIndex(from: Int, to: Int, tokenNumber: Int): Int {
+ private fun findTokenIndex(array: ByteArray, tokenNumber: Int): Int {
+ if (tokenNumber == 0) return 0
var tokensFound = 0
- for (index in from until to) {
- if (buf[index] == COMMA && ++tokensFound == tokenNumber) {
+ array.forEachIndexed { index, byte ->
+ if (byte == COMMA && ++tokensFound == tokenNumber) {
return index + 1
}
}
- return from
+ return 0
}
- private fun append(src: ByteArray, length: Int) {
- ensureCapacity(bufLen + length)
- System.arraycopy(src, 0, buf, bufLen, length)
- bufLen += length
- }
-
- private fun ensureCapacity(needed: Int) {
- if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
- }
+ private val Long.isValidTs
+ get() = this != C.TIME_UNSET
private companion object {
val SSA_TIMECODE_PATTERN: Pattern =
Pattern.compile("""(?:(\d+):)?(\d+):(\d+)[:.](\d+)""")
const val COMMA = ','.code.toByte()
- const val INITIAL_BUFFER_SIZE = 1024
}
}
diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt
new file mode 100644
index 000000000..9a8e2ee75
--- /dev/null
+++ b/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt
@@ -0,0 +1,156 @@
+package com.edde746.plezy.libass.media.extractor
+
+import androidx.media3.common.C
+import androidx.media3.common.DataReader
+import androidx.media3.common.ParserException
+import androidx.media3.extractor.DefaultExtractorInput
+import androidx.media3.extractor.ExtractorInput
+import androidx.media3.extractor.text.DefaultSubtitleParserFactory
+import com.edde746.plezy.libass.media.AssHandler
+import java.io.EOFException
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class AssMatroskaExtractorTest {
+
+ @Test
+ fun smallFontIsDeliveredAndNonFontDoesNotConsumeBudget() {
+ val handler = AssHandler()
+ val extractor = extractor(handler)
+
+ attachment(extractor, "font/ttf")
+ extractor.fileData(4, input(4, seed = 11))
+ attachment(extractor, "application/octet-stream")
+ extractor.fileData(9, input(9, seed = 22))
+
+ val pending = handler.fontStore.pendingSnapshot()
+ assertEquals(1, pending.size)
+ assertEquals("fixture-font", pending.single().first)
+ assertArrayEquals(byteArrayOf(11, 12, 13, 14), pending.single().second)
+ assertEquals(4L, extractor.acceptedFontBytes)
+ }
+
+ @Test
+ fun perFontLimitAcceptsExactBoundaryAndSkipsOneByteOver() {
+ val handler = AssHandler()
+ val extractor = extractor(handler)
+ val limit = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
+
+ attachment(extractor, "font/otf")
+ extractor.fileData(limit, input(limit))
+ attachment(extractor, "font/otf")
+ extractor.fileData(limit + 1, input(limit + 1))
+
+ assertEquals(limit.toLong(), extractor.acceptedFontBytes)
+ assertEquals(1, handler.fontStore.pendingSnapshot().size)
+ }
+
+ @Test
+ fun aggregateLimitIsDeterministicAcrossAttachmentEntries() {
+ val handler = AssHandler()
+ val extractor = extractor(handler)
+ val perFont = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
+
+ repeat(2) {
+ attachment(extractor, "font/ttf")
+ extractor.fileData(perFont, input(perFont, seed = it))
+ extractor.endAttachment()
+ }
+ attachment(extractor, "font/ttf")
+ extractor.fileData(1, input(1))
+
+ assertEquals(AssMatroskaExtractor.MAX_TOTAL_FONT_BYTES, extractor.acceptedFontBytes)
+ assertEquals(2, handler.fontStore.pendingSnapshot().size)
+ }
+
+ @Test
+ fun negativeAndZeroSizesAllocateAndDeliverNothing() {
+ val handler = AssHandler()
+ val extractor = extractor(handler)
+ attachment(extractor, "font/woff2")
+
+ assertThrows(ParserException::class.java) {
+ extractor.fileData(-1, input(0))
+ }
+ attachment(extractor, "font/woff2")
+ extractor.fileData(0, input(0))
+
+ assertEquals(0L, extractor.acceptedFontBytes)
+ assertEquals(0, handler.fontStore.pendingSnapshot().size)
+ }
+
+ @Test
+ fun failedReadDoesNotChargeAggregateBudgetOrDeliverPartialFont() {
+ val handler = AssHandler()
+ val extractor = extractor(handler)
+ attachment(extractor, "font/ttf")
+
+ assertThrows(EOFException::class.java) {
+ extractor.fileData(1024, input(1024, available = 4))
+ }
+ assertEquals(0L, extractor.acceptedFontBytes)
+ assertEquals(0, handler.fontStore.pendingSnapshot().size)
+
+ attachment(extractor, "font/ttf")
+ extractor.fileData(1024, input(1024))
+ assertEquals(1024L, extractor.acceptedFontBytes)
+ assertEquals(1, handler.fontStore.pendingSnapshot().size)
+ }
+
+ private fun extractor(handler: AssHandler) = TestExtractor(handler)
+
+ private fun attachment(extractor: TestExtractor, mime: String) {
+ extractor.setAttachment(mime)
+ }
+
+ private class TestExtractor(handler: AssHandler) :
+ AssMatroskaExtractor(
+ DefaultSubtitleParserFactory(),
+ handler
+ ) {
+ fun setAttachment(mime: String) {
+ startMasterElement(ID_ATTACHED_FILE, 0, 0)
+ stringElement(ID_FILE_NAME, "fixture-font")
+ stringElement(ID_FILE_MIME_TYPE, mime)
+ }
+
+ fun fileData(contentSize: Int, input: ExtractorInput) {
+ binaryElement(ID_FILE_DATA, contentSize, input)
+ }
+
+ fun endAttachment() {
+ endMasterElement(ID_ATTACHED_FILE)
+ }
+
+ override fun onFontRejected(contentSize: Int, acceptedBytes: Long, reason: String) = Unit
+ }
+
+ private fun input(
+ declared: Int,
+ available: Int = declared,
+ seed: Int = 0
+ ): ExtractorInput = DefaultExtractorInput(
+ PatternDataReader(available, seed),
+ 0,
+ declared.toLong()
+ )
+
+ private class PatternDataReader(
+ private val size: Int,
+ private val seed: Int
+ ) : DataReader {
+ private var position = 0
+
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+ if (position >= size) return C.RESULT_END_OF_INPUT
+ val count = minOf(length, size - position)
+ for (index in 0 until count) {
+ buffer[offset + index] = ((seed + position + index) and 0xFF).toByte()
+ }
+ position += count
+ return count
+ }
+ }
+}
diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt
deleted file mode 100644
index ac9e1a6a6..000000000
--- a/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt
+++ /dev/null
@@ -1,157 +0,0 @@
-package com.edde746.plezy.libass.media.text
-
-import androidx.media3.common.C
-import androidx.media3.common.Format
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.extractor.TrackOutput
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-/**
- * Spec for the dialogue feed both demuxer paths rely on: samples arrive in
- * media3's MatroskaExtractor shape ("Dialogue: 0:00:00:00,,")
- * and the sink must receive the sample start, the parsed duration, and the
- * payload after the second comma — while every byte is still forwarded
- * unchanged to the delegate.
- */
-@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [34])
-class AssTrackOutputTest {
-
- private class CapturedDialogue(val trackId: String?, val startMs: Long, val durationMs: Long, val line: ByteArray)
-
- private class RecordingSink : AssTrackOutput.DialogueSink {
- val dialogues = mutableListOf()
-
- override fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int) {
- dialogues.add(CapturedDialogue(trackId, startMs, durationMs, data.copyOfRange(offset, offset + length)))
- }
- }
-
- private class RecordingTrackOutput : TrackOutput {
- val formats = mutableListOf()
- val samples = mutableListOf()
- val sampleTimes = mutableListOf()
- private var buf = ByteArray(1024)
- private var bufLen = 0
-
- override fun format(format: Format) {
- formats.add(format)
- }
-
- override fun sampleData(input: androidx.media3.common.DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
- if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length)
- val read = input.read(buf, bufLen, length)
- if (read > 0) bufLen += read
- return read
- }
-
- override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
- if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length)
- data.readBytes(buf, bufLen, length)
- bufLen += length
- }
-
- override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
- val start = bufLen - offset - size
- samples.add(buf.copyOfRange(start, start + size))
- sampleTimes.add(timeUs)
- if (offset == 0) bufLen = 0
- }
- }
-
- private fun ssaFormat(id: String = "3"): Format = Format.Builder()
- .setId(id)
- .setSampleMimeType(MimeTypes.TEXT_SSA)
- .build()
-
- private fun deliver(output: AssTrackOutput, sample: String, timeUs: Long) {
- val bytes = sample.toByteArray(Charsets.UTF_8)
- output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
- output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null)
- }
-
- @Test
- fun feedsDialogueAndForwardsSampleUnchanged() {
- val sink = RecordingSink()
- val delegate = RecordingTrackOutput()
- val output = AssTrackOutput(delegate, sink)
- output.format(ssaFormat())
-
- val sample = "Dialogue: 0:00:00:00,0:00:05:00,7,0,Default,,0,0,0,,Hello, world"
- deliver(output, sample, 90_000_000L)
-
- assertEquals(1, sink.dialogues.size)
- val dialogue = sink.dialogues.first()
- assertEquals("3", dialogue.trackId)
- assertEquals(90_000L, dialogue.startMs)
- assertEquals(5_000L, dialogue.durationMs)
- // Payload after the second comma: the raw MKV/FFmpeg block, commas in the
- // text field intact.
- assertEquals("7,0,Default,,0,0,0,,Hello, world", dialogue.line.toString(Charsets.UTF_8))
-
- // The delegate still receives the full untouched sample.
- assertEquals(1, delegate.samples.size)
- assertArrayEquals(sample.toByteArray(Charsets.UTF_8), delegate.samples.first())
- assertEquals(90_000_000L, delegate.sampleTimes.first())
- }
-
- @Test
- fun ignoresNonAssTracksAndMalformedSamples() {
- val sink = RecordingSink()
- val delegate = RecordingTrackOutput()
- val output = AssTrackOutput(delegate, sink)
-
- // SubRip track: forwarded, never fed to libass.
- output.format(Format.Builder().setId("1").setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build())
- deliver(output, "1\n00:00:00,000 --> 00:00:05,000\nplain text", 1_000_000L)
- assertTrue(sink.dialogues.isEmpty())
- assertEquals(1, delegate.samples.size)
-
- // ASS track with an unparseable duration: forwarded, not fed.
- val assOutput = AssTrackOutput(RecordingTrackOutput(), sink)
- assOutput.format(ssaFormat())
- deliver(assOutput, "Dialogue: bogus", 1_000_000L)
- assertTrue(sink.dialogues.isEmpty())
- }
-
- @Test
- fun handlesChunkedSampleDataAndDataReaderPath() {
- val sink = RecordingSink()
- val delegate = RecordingTrackOutput()
- val output = AssTrackOutput(delegate, sink)
- output.format(ssaFormat("0"))
-
- val sample = "Dialogue: 0:00:00:00,0:00:01:50,1,0,Style,,0,0,0,,Line"
- val bytes = sample.toByteArray(Charsets.UTF_8)
- val split = bytes.size / 2
- output.sampleData(ParsableByteArray(bytes.copyOfRange(0, split)), split, TrackOutput.SAMPLE_DATA_PART_MAIN)
- val rest = bytes.copyOfRange(split, bytes.size)
- var consumed = 0
- val reader = androidx.media3.common.DataReader { buffer, offset, length ->
- if (consumed >= rest.size) {
- C.RESULT_END_OF_INPUT
- } else {
- val toRead = minOf(length, rest.size - consumed)
- System.arraycopy(rest, consumed, buffer, offset, toRead)
- consumed += toRead
- toRead
- }
- }
- while (consumed < rest.size) {
- output.sampleData(reader, rest.size - consumed, false, TrackOutput.SAMPLE_DATA_PART_MAIN)
- }
- output.sampleMetadata(0L, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null)
-
- assertEquals(1, sink.dialogues.size)
- assertEquals(1_500L, sink.dialogues.first().durationMs)
- assertEquals("1,0,Style,,0,0,0,,Line", sink.dialogues.first().line.toString(Charsets.UTF_8))
- assertArrayEquals(bytes, delegate.samples.first())
- }
-}
diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json
index f6f116606..43a464174 100644
--- a/lib/i18n/az.i18n.json
+++ b/lib/i18n/az.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Daxili DV7-ni məcburi et",
"dvConversionDv81Description": "Dolby Vision profile 8.1-ə çevrilməni məcburi et",
"dvConversionHevcStripDescription": "Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et",
- "demuxer": "Konteyner demukseri",
- "demuxerDescription": "Birbaşa oxudulan faylları hansı demukserin təhlil edəcəyi. Fayl düzgün işləmirsə, media3-ə keçin.",
- "demuxerFfmpeg": "FFmpeg (Tövsiyə olunan)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Açılışda profil soruş",
"requireProfileSelectionOnOpenDescription": "Tətbiq hər dəfə açıldıqda profil seçimini göstər",
"forceTvMode": "TV rejimini məcburi et",
diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json
index 52a0d7845..216beafea 100644
--- a/lib/i18n/bg.i18n.json
+++ b/lib/i18n/bg.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване",
"dvConversionDv81Description": "Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1",
"dvConversionHevcStripDescription": "Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток",
- "demuxer": "Демултиплексор на контейнера",
- "demuxerDescription": "Кой демултиплексор анализира файловете при директно възпроизвеждане. Превключете на media3, ако даден файл създава проблеми.",
- "demuxerFfmpeg": "FFmpeg (препоръчително)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Питай за профил при отваряне на приложението",
"requireProfileSelectionOnOpenDescription": "Показвай избор на профил всеки път при отваряне на приложението",
"forceTvMode": "Принуди TV режим",
diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json
index 9a028b9fb..cdaf3a5f5 100644
--- a/lib/i18n/da.i18n.json
+++ b/lib/i18n/da.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen",
"dvConversionDv81Description": "Tving inline RPU-konvertering til Dolby Vision profil 8.1",
"dvConversionHevcStripDescription": "Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC",
- "demuxer": "Container-demuxer",
- "demuxerDescription": "Hvilken demuxer der læser direkte afspillede filer. Skift til media3, hvis en fil driller.",
- "demuxerFfmpeg": "FFmpeg (anbefalet)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Spørg om profil ved åbning",
"requireProfileSelectionOnOpenDescription": "Vis profilvalg hver gang appen åbnes",
"forceTvMode": "Gennemtving TV-tilstand",
diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json
index fae89e334..2080814e4 100644
--- a/lib/i18n/de.i18n.json
+++ b/lib/i18n/de.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken",
"dvConversionDv81Description": "Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen",
"dvConversionHevcStripDescription": "Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben",
- "demuxer": "Container-Demuxer",
- "demuxerDescription": "Welcher Demuxer direkt abgespielte Dateien einliest. Wechsle zu media3, wenn eine Datei Probleme macht.",
- "demuxerFfmpeg": "FFmpeg (empfohlen)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Profil beim Öffnen abfragen",
"requireProfileSelectionOnOpenDescription": "Profilauswahl bei jedem Öffnen der App anzeigen",
"forceTvMode": "TV-Modus erzwingen",
diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json
index d5b39dbcd..3b97cd2ba 100644
--- a/lib/i18n/en.i18n.json
+++ b/lib/i18n/en.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Force native DV7 and suppress DV conversion retry",
"dvConversionDv81Description": "Force inline RPU conversion to Dolby Vision profile 8.1",
"dvConversionHevcStripDescription": "Strip Dolby Vision RPU/EL layers and present plain HEVC",
- "demuxer": "Container Demuxer",
- "demuxerDescription": "Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.",
- "demuxerFfmpeg": "FFmpeg (Recommended)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Ask for profile on app open",
"requireProfileSelectionOnOpenDescription": "Show profile selection every time the app is opened",
"forceTvMode": "Force TV mode",
diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json
index 9087e35b1..7a7e59477 100644
--- a/lib/i18n/es.i18n.json
+++ b/lib/i18n/es.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Forzar DV7 nativo y suprimir el reintento de conversión DV",
"dvConversionDv81Description": "Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision",
"dvConversionHevcStripDescription": "Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional",
- "demuxer": "Demultiplexor de contenedor",
- "demuxerDescription": "Qué demultiplexor analiza los archivos de reproducción directa. Cambia a media3 si un archivo da problemas.",
- "demuxerFfmpeg": "FFmpeg (recomendado)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Pedir perfil al abrir la aplicación",
"requireProfileSelectionOnOpenDescription": "Mostrar selección de perfil cada vez que se abre la aplicación",
"forceTvMode": "Forzar modo TV",
diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json
index 34305840a..303e8e686 100644
--- a/lib/i18n/fr.i18n.json
+++ b/lib/i18n/fr.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV",
"dvConversionDv81Description": "Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision",
"dvConversionHevcStripDescription": "Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple",
- "demuxer": "Démultiplexeur de conteneur",
- "demuxerDescription": "Quel démultiplexeur analyse les fichiers lus en direct. Passez à media3 si un fichier pose problème.",
- "demuxerFfmpeg": "FFmpeg (recommandé)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Demander le profil à l'ouverture",
"requireProfileSelectionOnOpenDescription": "Afficher la sélection de profil à chaque ouverture de l'application",
"forceTvMode": "Forcer le mode TV",
diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json
index 54d69edf7..f24846ba1 100644
--- a/lib/i18n/hu.i18n.json
+++ b/lib/i18n/hu.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása",
"dvConversionDv81Description": "A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re",
"dvConversionHevcStripDescription": "A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés",
- "demuxer": "Konténer-demuxer",
- "demuxerDescription": "Melyik demuxer dolgozza fel a közvetlenül lejátszott fájlokat. Válts media3-ra, ha egy fájl hibásan viselkedik.",
- "demuxerFfmpeg": "FFmpeg (ajánlott)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Profil kérése az alkalmazás megnyitásakor",
"requireProfileSelectionOnOpenDescription": "Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod",
"forceTvMode": "TV-mód kényszerítése",
diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json
index 776ed1138..e8202ff68 100644
--- a/lib/i18n/it.i18n.json
+++ b/lib/i18n/it.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV",
"dvConversionDv81Description": "Forza la conversione RPU diretta al profilo Dolby Vision 8.1",
"dvConversionHevcStripDescription": "Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC",
- "demuxer": "Demuxer del contenitore",
- "demuxerDescription": "Quale demuxer analizza i file riprodotti in diretta. Passa a media3 se un file dà problemi.",
- "demuxerFfmpeg": "FFmpeg (consigliato)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Chiedi di scegliere il profilo all'apertura",
"requireProfileSelectionOnOpenDescription": "Mostra la selezione del profilo ogni volta che l'app viene aperta",
"forceTvMode": "Forza modalità TV",
diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json
index 7459ca3d8..b69ef7141 100644
--- a/lib/i18n/ja.i18n.json
+++ b/lib/i18n/ja.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "ネイティブ DV7 を強制し、DV 変換の再試行を抑制します",
"dvConversionDv81Description": "Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します",
"dvConversionHevcStripDescription": "Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います",
- "demuxer": "コンテナデマルチプレクサ",
- "demuxerDescription": "ダイレクト再生のファイルを解析するデマルチプレクサ。ファイルの再生に問題がある場合は media3 に切り替えてください。",
- "demuxerFfmpeg": "FFmpeg(推奨)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "アプリ起動時にプロフィールを確認",
"requireProfileSelectionOnOpenDescription": "アプリを開くたびにプロフィール選択を表示",
"forceTvMode": "TVモードを強制",
diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json
index f6e2c9d4a..d8bad7326 100644
--- a/lib/i18n/kk.i18n.json
+++ b/lib/i18n/kk.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Ішкі DV7 режимін мәжбүрлеу",
"dvConversionDv81Description": "Dolby Vision profile 8.1 форматына түрлендіру",
"dvConversionHevcStripDescription": "Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету",
- "demuxer": "Контейнер демультиплексоры",
- "demuxerDescription": "Тікелей ойнатылатын файлдарды қай демультиплексор талдайды. Файл дұрыс ойнатылмаса, media3-ке ауысыңыз.",
- "demuxerFfmpeg": "FFmpeg (ұсынылады)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Ашқанда профильді сұрау",
"requireProfileSelectionOnOpenDescription": "Қолданба ашылған сайын профильді таңдауды көрсету",
"forceTvMode": "TV режимін мәжбүрлеу",
diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json
index 115dc99e6..4ed6156c3 100644
--- a/lib/i18n/ko.i18n.json
+++ b/lib/i18n/ko.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다",
"dvConversionDv81Description": "Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다",
"dvConversionHevcStripDescription": "Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다",
- "demuxer": "컨테이너 디먹서",
- "demuxerDescription": "다이렉트 재생 파일을 분석할 디먹서입니다. 파일이 제대로 재생되지 않으면 media3으로 전환하세요.",
- "demuxerFfmpeg": "FFmpeg (권장)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "앱 실행 시 프로필 선택",
"requireProfileSelectionOnOpenDescription": "앱을 열 때마다 프로필 선택 화면을 표시합니다",
"forceTvMode": "TV 모드 강제 사용",
diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json
index 198e7a9dc..68448a822 100644
--- a/lib/i18n/nb.i18n.json
+++ b/lib/i18n/nb.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Tving opprinnelig DV7-avspilling og ikke prøv DV-konvertering på nytt",
"dvConversionDv81Description": "Tving direkte RPU-konvertering til Dolby Vision-profil 8.1",
"dvConversionHevcStripDescription": "Fjern Dolby Vision RPU/EL-lag og lever som vanlig HEVC",
- "demuxer": "Container-demukser",
- "demuxerDescription": "Hvilken demukser som leser direkteavspilte filer. Bytt til media3 hvis en fil oppfører seg rart.",
- "demuxerFfmpeg": "FFmpeg (anbefalt)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Spør om profil ved appåpning",
"requireProfileSelectionOnOpenDescription": "Vis profilvalg hver gang appen åpnes",
"forceTvMode": "Tving TV-modus",
diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json
index e37dcf70d..aaca49138 100644
--- a/lib/i18n/nl.i18n.json
+++ b/lib/i18n/nl.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Dwing native DV7 af en voorkom een nieuwe poging met DV-conversie",
"dvConversionDv81Description": "Dwing directe RPU-conversie naar Dolby Vision-profiel 8.1 af",
"dvConversionHevcStripDescription": "Verwijder Dolby Vision RPU/EL-lagen en bied gewone HEVC aan",
- "demuxer": "Container-demuxer",
- "demuxerDescription": "Welke demuxer direct afgespeelde bestanden inleest. Schakel over naar media3 als een bestand zich vreemd gedraagt.",
- "demuxerFfmpeg": "FFmpeg (aanbevolen)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Vraag om profiel bij openen",
"requireProfileSelectionOnOpenDescription": "Toon profielselectie telkens wanneer de app wordt geopend",
"forceTvMode": "Tv-modus afdwingen",
diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json
index 69d17921f..1bc22a322 100644
--- a/lib/i18n/pl.i18n.json
+++ b/lib/i18n/pl.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Wymuś natywne DV7 i wyłącz ponowną próbę konwersji DV",
"dvConversionDv81Description": "Wymuś wbudowaną konwersję RPU do profilu Dolby Vision 8.1",
"dvConversionHevcStripDescription": "Usuń warstwy Dolby Vision RPU/EL i przedstaw zwykłe HEVC",
- "demuxer": "Demukser kontenera",
- "demuxerDescription": "Który demukser analizuje pliki odtwarzane bezpośrednio. Przełącz na media3, jeśli plik sprawia problemy.",
- "demuxerFfmpeg": "FFmpeg (zalecany)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Pytaj o profil przy otwarciu aplikacji",
"requireProfileSelectionOnOpenDescription": "Pokaż wybór profilu za każdym razem, gdy aplikacja jest otwierana",
"forceTvMode": "Wymuś tryb TV",
diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json
index cf920a7c1..01643a50a 100644
--- a/lib/i18n/pt.i18n.json
+++ b/lib/i18n/pt.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Forçar DV7 nativo e impedir uma nova tentativa de conversão de DV",
"dvConversionDv81Description": "Forçar a conversão RPU integrada para Dolby Vision perfil 8.1",
"dvConversionHevcStripDescription": "Remover as camadas RPU/EL do Dolby Vision e apresentar HEVC sem Dolby Vision",
- "demuxer": "Demuxer de contêiner",
- "demuxerDescription": "Qual demuxer processa os arquivos reproduzidos diretamente. Mude para media3 se algum arquivo apresentar problemas.",
- "demuxerFfmpeg": "FFmpeg (recomendado)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Pedir perfil ao abrir o app",
"requireProfileSelectionOnOpenDescription": "Mostrar a seleção de perfil sempre que o app for aberto",
"forceTvMode": "Forçar modo TV",
diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json
index 62a0e1347..df6a4bcef 100644
--- a/lib/i18n/ru.i18n.json
+++ b/lib/i18n/ru.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Принудительно использовать нативный DV7 и не повторять DV-конвертацию",
"dvConversionDv81Description": "Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1",
"dvConversionHevcStripDescription": "Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC",
- "demuxer": "Демультиплексор контейнера",
- "demuxerDescription": "Какой демультиплексор разбирает файлы при прямом воспроизведении. Переключитесь на media3, если файл воспроизводится неправильно.",
- "demuxerFfmpeg": "FFmpeg (рекомендуется)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Запрашивать профиль при запуске",
"requireProfileSelectionOnOpenDescription": "Показывать выбор профиля при каждом открытии приложения",
"forceTvMode": "Принудительный режим ТВ",
diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart
index 314ece5b7..3fd7de1fe 100644
--- a/lib/i18n/strings.g.dart
+++ b/lib/i18n/strings.g.dart
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
-/// Strings: 41706 (1895 per locale)
+/// Strings: 41618 (1891 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
diff --git a/lib/i18n/strings_az.g.dart b/lib/i18n/strings_az.g.dart
index 4d7ca408a..28132b766 100644
--- a/lib/i18n/strings_az.g.dart
+++ b/lib/i18n/strings_az.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$az extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Daxili DV7-ni məcburi et';
@override String get dvConversionDv81Description => 'Dolby Vision profile 8.1-ə çevrilməni məcburi et';
@override String get dvConversionHevcStripDescription => 'Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et';
- @override String get demuxer => 'Konteyner demukseri';
- @override String get demuxerDescription => 'Birbaşa oxudulan faylları hansı demukserin təhlil edəcəyi. Fayl düzgün işləmirsə, media3-ə keçin.';
- @override String get demuxerFfmpeg => 'FFmpeg (Tövsiyə olunan)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Açılışda profil soruş';
@override String get requireProfileSelectionOnOpenDescription => 'Tətbiq hər dəfə açıldıqda profil seçimini göstər';
@override String get forceTvMode => 'TV rejimini məcburi et';
@@ -3089,10 +3085,6 @@ extension on TranslationsAz {
'settings.dvConversionNativeDescription' => 'Daxili DV7-ni məcburi et',
'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1-ə çevrilməni məcburi et',
'settings.dvConversionHevcStripDescription' => 'Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et',
- 'settings.demuxer' => 'Konteyner demukseri',
- 'settings.demuxerDescription' => 'Birbaşa oxudulan faylları hansı demukserin təhlil edəcəyi. Fayl düzgün işləmirsə, media3-ə keçin.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (Tövsiyə olunan)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Açılışda profil soruş',
'settings.requireProfileSelectionOnOpenDescription' => 'Tətbiq hər dəfə açıldıqda profil seçimini göstər',
'settings.forceTvMode' => 'TV rejimini məcburi et',
@@ -3284,12 +3276,12 @@ extension on TranslationsAz {
'mediaMenu.markAsUnwatched' => 'Baxılmadı olaraq işarələ',
'mediaMenu.removeFromContinueWatching' => 'İzləməyə davam et-dən sil',
'mediaMenu.viewDetails' => 'Ətraflı bax',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Seriala keç',
'mediaMenu.shufflePlay' => 'Qarışıq oynat',
'mediaMenu.shuffleNotAvailableOffline' => 'Qarışıq oynatma oflayn rejimdə əlçatan deyil',
'mediaMenu.fileInfo' => 'Fayl məlumatı',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Seriyanı serverdən sil',
'mediaMenu.deleteSeasonFromServer' => 'Mövsümü serverdən sil',
'mediaMenu.deleteShowFromServer' => 'Serialı serverdən sil',
@@ -3798,12 +3790,12 @@ extension on TranslationsAz {
'explore.rows.trendingShows' => 'Trend seriallar',
'explore.rows.popularMovies' => 'Məşhur kinolar',
'explore.rows.popularShows' => 'Məşhur seriallar',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Trend animelər',
'explore.rows.suggestedAnime' => 'Tövsiyə olunan animelər',
'explore.rows.airingAnime' => 'Ən yaxşı yayımlanan animelər',
'explore.rows.popularAnime' => 'Ən məşhur animelər',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Trendlər',
'explore.rows.upcomingMovies' => 'Gələcək kinolar',
'explore.rows.upcomingShows' => 'Gələcək seriallar',
@@ -4312,12 +4304,12 @@ extension on TranslationsAz {
'companionRemote.pairing.validationHostFormat' => 'Format IP:port şəklində olmalıdır',
'companionRemote.pairing.connectionTimedOut' => 'Qoşulma vaxtı bitdi.',
'companionRemote.pairing.sessionNotFound' => 'Cihaz tapılmadı.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Kimlik doğrulanması uğursuz oldu.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Qoşulma uğursuz oldu: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Pult seansından ayrılmaq istəyirsiniz?',
'companionRemote.remote.reconnecting' => 'Yenidən qoşulunur...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Cəhd ${current} / 5',
'companionRemote.remote.retryNow' => 'İndi təzədən cəhd et',
'companionRemote.remote.tabRemote' => 'Pult',
diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart
index a74d226d4..75b0f5ff2 100644
--- a/lib/i18n/strings_bg.g.dart
+++ b/lib/i18n/strings_bg.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$bg extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване';
@override String get dvConversionDv81Description => 'Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1';
@override String get dvConversionHevcStripDescription => 'Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток';
- @override String get demuxer => 'Демултиплексор на контейнера';
- @override String get demuxerDescription => 'Кой демултиплексор анализира файловете при директно възпроизвеждане. Превключете на media3, ако даден файл създава проблеми.';
- @override String get demuxerFfmpeg => 'FFmpeg (препоръчително)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Питай за профил при отваряне на приложението';
@override String get requireProfileSelectionOnOpenDescription => 'Показвай избор на профил всеки път при отваряне на приложението';
@override String get forceTvMode => 'Принуди TV режим';
@@ -3089,10 +3085,6 @@ extension on TranslationsBg {
'settings.dvConversionNativeDescription' => 'Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване',
'settings.dvConversionDv81Description' => 'Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1',
'settings.dvConversionHevcStripDescription' => 'Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток',
- 'settings.demuxer' => 'Демултиплексор на контейнера',
- 'settings.demuxerDescription' => 'Кой демултиплексор анализира файловете при директно възпроизвеждане. Превключете на media3, ако даден файл създава проблеми.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (препоръчително)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Питай за профил при отваряне на приложението',
'settings.requireProfileSelectionOnOpenDescription' => 'Показвай избор на профил всеки път при отваряне на приложението',
'settings.forceTvMode' => 'Принуди TV режим',
@@ -3284,12 +3276,12 @@ extension on TranslationsBg {
'mediaMenu.markAsUnwatched' => 'Маркирай като негледано',
'mediaMenu.removeFromContinueWatching' => 'Премахни от продължаване на гледането',
'mediaMenu.viewDetails' => 'Виж подробности',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Към сериала',
'mediaMenu.shufflePlay' => 'Разбъркано възпроизвеждане',
'mediaMenu.shuffleNotAvailableOffline' => 'Разбърканото възпроизвеждане не е налично офлайн',
'mediaMenu.fileInfo' => 'Информация за файла',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Изтрий епизода от сървъра',
'mediaMenu.deleteSeasonFromServer' => 'Изтрий сезона от сървъра',
'mediaMenu.deleteShowFromServer' => 'Изтрий сериала от сървъра',
@@ -3798,12 +3790,12 @@ extension on TranslationsBg {
'explore.rows.trendingShows' => 'Набиращи популярност сериали',
'explore.rows.popularMovies' => 'Популярни филми',
'explore.rows.popularShows' => 'Популярни сериали',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Набиращи популярност аниме',
'explore.rows.suggestedAnime' => 'Препоръчани аниме',
'explore.rows.airingAnime' => 'Топ излъчвани аниме',
'explore.rows.popularAnime' => 'Най-популярни аниме',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Набиращи популярност',
'explore.rows.upcomingMovies' => 'Предстоящи филми',
'explore.rows.upcomingShows' => 'Предстоящи сериали',
@@ -4312,12 +4304,12 @@ extension on TranslationsBg {
'companionRemote.pairing.validationHostFormat' => 'Форматът трябва да е IP:port (напр. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Връзката изтече. Използвайте една и съща мрежа на двете устройства.',
'companionRemote.pairing.sessionNotFound' => 'Устройството не е намерено. Уверете се, че Plezy работи на хоста.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Неуспешно свързване: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Искате ли да прекъснете връзката с дистанционната сесия?',
'companionRemote.remote.reconnecting' => 'Повторно свързване...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Опит ${current} от 5',
'companionRemote.remote.retryNow' => 'Опитай сега',
'companionRemote.remote.tabRemote' => 'Дистанционно',
diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart
index 11c1b7eff..897ee41f9 100644
--- a/lib/i18n/strings_da.g.dart
+++ b/lib/i18n/strings_da.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$da extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen';
@override String get dvConversionDv81Description => 'Tving inline RPU-konvertering til Dolby Vision profil 8.1';
@override String get dvConversionHevcStripDescription => 'Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC';
- @override String get demuxer => 'Container-demuxer';
- @override String get demuxerDescription => 'Hvilken demuxer der læser direkte afspillede filer. Skift til media3, hvis en fil driller.';
- @override String get demuxerFfmpeg => 'FFmpeg (anbefalet)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Spørg om profil ved åbning';
@override String get requireProfileSelectionOnOpenDescription => 'Vis profilvalg hver gang appen åbnes';
@override String get forceTvMode => 'Gennemtving TV-tilstand';
@@ -3089,10 +3085,6 @@ extension on TranslationsDa {
'settings.dvConversionNativeDescription' => 'Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen',
'settings.dvConversionDv81Description' => 'Tving inline RPU-konvertering til Dolby Vision profil 8.1',
'settings.dvConversionHevcStripDescription' => 'Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC',
- 'settings.demuxer' => 'Container-demuxer',
- 'settings.demuxerDescription' => 'Hvilken demuxer der læser direkte afspillede filer. Skift til media3, hvis en fil driller.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (anbefalet)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Spørg om profil ved åbning',
'settings.requireProfileSelectionOnOpenDescription' => 'Vis profilvalg hver gang appen åbnes',
'settings.forceTvMode' => 'Gennemtving TV-tilstand',
@@ -3284,12 +3276,12 @@ extension on TranslationsDa {
'mediaMenu.markAsUnwatched' => 'Markér som uset',
'mediaMenu.removeFromContinueWatching' => 'Fjern fra Fortsæt med at se',
'mediaMenu.viewDetails' => 'Vis detaljer',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Gå til serie',
'mediaMenu.shufflePlay' => 'Afspil tilfældigt',
'mediaMenu.shuffleNotAvailableOffline' => 'Tilfældig afspilning er ikke tilgængelig offline',
'mediaMenu.fileInfo' => 'Filinfo',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Slet episode fra serveren',
'mediaMenu.deleteSeasonFromServer' => 'Slet sæson fra serveren',
'mediaMenu.deleteShowFromServer' => 'Slet TV-serie fra serveren',
@@ -3798,12 +3790,12 @@ extension on TranslationsDa {
'explore.rows.trendingShows' => 'Populære serier lige nu',
'explore.rows.popularMovies' => 'Populære film',
'explore.rows.popularShows' => 'Populære serier',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Populær anime lige nu',
'explore.rows.suggestedAnime' => 'Anbefalet anime',
'explore.rows.airingAnime' => 'Bedste aktuelle anime',
'explore.rows.popularAnime' => 'Mest populære anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Populært lige nu',
'explore.rows.upcomingMovies' => 'Kommende film',
'explore.rows.upcomingShows' => 'Kommende serier',
@@ -4312,12 +4304,12 @@ extension on TranslationsDa {
'companionRemote.pairing.validationHostFormat' => 'Format skal være IP:port (f.eks. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Forbindelsen fik timeout. Brug samme netværk på begge enheder.',
'companionRemote.pairing.sessionNotFound' => 'Enhed ikke fundet. Sørg for, at Plezy kører på værten.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Godkendelse mislykkedes. Begge enheder skal bruge samme Plex-konto.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke oprette forbindelse: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vil du afbryde forbindelsen til fjernsessionen?',
'companionRemote.remote.reconnecting' => 'Genopretter forbindelse...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Forsøg ${current} af 5',
'companionRemote.remote.retryNow' => 'Prøv igen nu',
'companionRemote.remote.tabRemote' => 'Fjernbetjening',
diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart
index 745282d57..859aa91b5 100644
--- a/lib/i18n/strings_de.g.dart
+++ b/lib/i18n/strings_de.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$de extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken';
@override String get dvConversionDv81Description => 'Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen';
@override String get dvConversionHevcStripDescription => 'Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben';
- @override String get demuxer => 'Container-Demuxer';
- @override String get demuxerDescription => 'Welcher Demuxer direkt abgespielte Dateien einliest. Wechsle zu media3, wenn eine Datei Probleme macht.';
- @override String get demuxerFfmpeg => 'FFmpeg (empfohlen)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Profil beim Öffnen abfragen';
@override String get requireProfileSelectionOnOpenDescription => 'Profilauswahl bei jedem Öffnen der App anzeigen';
@override String get forceTvMode => 'TV-Modus erzwingen';
@@ -3089,10 +3085,6 @@ extension on TranslationsDe {
'settings.dvConversionNativeDescription' => 'Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken',
'settings.dvConversionDv81Description' => 'Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen',
'settings.dvConversionHevcStripDescription' => 'Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben',
- 'settings.demuxer' => 'Container-Demuxer',
- 'settings.demuxerDescription' => 'Welcher Demuxer direkt abgespielte Dateien einliest. Wechsle zu media3, wenn eine Datei Probleme macht.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (empfohlen)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Profil beim Öffnen abfragen',
'settings.requireProfileSelectionOnOpenDescription' => 'Profilauswahl bei jedem Öffnen der App anzeigen',
'settings.forceTvMode' => 'TV-Modus erzwingen',
@@ -3284,12 +3276,12 @@ extension on TranslationsDe {
'mediaMenu.markAsUnwatched' => 'Als ungesehen markieren',
'mediaMenu.removeFromContinueWatching' => 'Aus ‚Weiterschauen‘ entfernen',
'mediaMenu.viewDetails' => 'Details anzeigen',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Zur Serie',
'mediaMenu.shufflePlay' => 'Zufallswiedergabe',
'mediaMenu.shuffleNotAvailableOffline' => 'Zufallswiedergabe ist offline nicht verfügbar',
'mediaMenu.fileInfo' => 'Dateiinfo',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Folge vom Server löschen',
'mediaMenu.deleteSeasonFromServer' => 'Staffel vom Server löschen',
'mediaMenu.deleteShowFromServer' => 'Serie vom Server löschen',
@@ -3798,12 +3790,12 @@ extension on TranslationsDe {
'explore.rows.trendingShows' => 'Angesagte Serien',
'explore.rows.popularMovies' => 'Beliebte Filme',
'explore.rows.popularShows' => 'Beliebte Serien',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Angesagte Anime',
'explore.rows.suggestedAnime' => 'Empfohlene Anime',
'explore.rows.airingAnime' => 'Beste derzeit laufende Anime',
'explore.rows.popularAnime' => 'Beliebteste Anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Angesagt',
'explore.rows.upcomingMovies' => 'Kommende Filme',
'explore.rows.upcomingShows' => 'Kommende Serien',
@@ -4312,12 +4304,12 @@ extension on TranslationsDe {
'companionRemote.pairing.validationHostFormat' => 'Format muss IP:Port sein (z. B. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Zeitüberschreitung bei der Verbindung. Nutze auf beiden Geräten dasselbe Netzwerk.',
'companionRemote.pairing.sessionNotFound' => 'Gerät nicht gefunden. Stelle sicher, dass Plezy auf dem Host läuft.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Authentifizierung fehlgeschlagen. Beide Geräte benötigen dasselbe Plex-Konto.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?',
'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Versuch ${current} von 5',
'companionRemote.remote.retryNow' => 'Jetzt wiederholen',
'companionRemote.remote.tabRemote' => 'Fernbedienung',
diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart
index e3887d984..67b7b0492 100644
--- a/lib/i18n/strings_en.g.dart
+++ b/lib/i18n/strings_en.g.dart
@@ -1035,18 +1035,6 @@ class Translations$settings$en {
/// en: 'Strip Dolby Vision RPU/EL layers and present plain HEVC'
String get dvConversionHevcStripDescription => 'Strip Dolby Vision RPU/EL layers and present plain HEVC';
- /// en: 'Container Demuxer'
- String get demuxer => 'Container Demuxer';
-
- /// en: 'Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.'
- String get demuxerDescription => 'Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.';
-
- /// en: 'FFmpeg (Recommended)'
- String get demuxerFfmpeg => 'FFmpeg (Recommended)';
-
- /// en: 'media3'
- String get demuxerMedia3 => 'media3';
-
/// en: 'Ask for profile on app open'
String get requireProfileSelectionOnOpen => 'Ask for profile on app open';
@@ -7127,10 +7115,6 @@ extension on Translations {
'settings.dvConversionNativeDescription' => 'Force native DV7 and suppress DV conversion retry',
'settings.dvConversionDv81Description' => 'Force inline RPU conversion to Dolby Vision profile 8.1',
'settings.dvConversionHevcStripDescription' => 'Strip Dolby Vision RPU/EL layers and present plain HEVC',
- 'settings.demuxer' => 'Container Demuxer',
- 'settings.demuxerDescription' => 'Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (Recommended)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Ask for profile on app open',
'settings.requireProfileSelectionOnOpenDescription' => 'Show profile selection every time the app is opened',
'settings.forceTvMode' => 'Force TV mode',
@@ -7321,12 +7305,12 @@ extension on Translations {
'mediaMenu.markAsWatched' => 'Mark as Watched',
'mediaMenu.markAsUnwatched' => 'Mark as Unwatched',
'mediaMenu.removeFromContinueWatching' => 'Remove from Continue Watching',
- _ => null,
- } ?? switch (path) {
'mediaMenu.viewDetails' => 'View details',
'mediaMenu.goToSeries' => 'Go to series',
'mediaMenu.shufflePlay' => 'Shuffle Play',
'mediaMenu.shuffleNotAvailableOffline' => 'Shuffle not available offline',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.fileInfo' => 'File Info',
'mediaMenu.deleteEpisodeFromServer' => 'Delete episode from server',
'mediaMenu.deleteSeasonFromServer' => 'Delete season from server',
@@ -7835,12 +7819,12 @@ extension on Translations {
'about.versionLabel' => ({required Object version}) => 'Version ${version}',
'about.appDescription' => 'A beautiful Plex, Jellyfin, and Emby client for Flutter',
'about.viewLicensesDescription' => 'View licenses of third-party libraries',
- _ => null,
- } ?? switch (path) {
'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'No servers found for ${username} (${email})',
'serverSelection.failedToLoadServers' => ({required Object error}) => 'Failed to load servers: ${error}',
'serverSelection.noValidServers' => 'No usable servers were found on this account',
'hubDetail.title' => 'Title',
+ _ => null,
+ } ?? switch (path) {
'hubDetail.releaseYear' => 'Release Year',
'hubDetail.dateAdded' => 'Date Added',
'hubDetail.rating' => 'Rating',
@@ -8349,12 +8333,12 @@ extension on Translations {
'downloads.backgroundWarning.dialogFixFirst' => 'Fix this first',
'downloads.backgroundWarning.statusTile' => 'Background downloads',
'downloads.backgroundWarning.statusOk' => 'Allowed to run in the background',
- _ => null,
- } ?? switch (path) {
'downloads.backgroundWarning.statusBlocked' => 'Blocked by system settings',
'downloads.backgroundWarning.statusDegraded' => 'Limited by system settings',
'downloads.backgroundWarning.statusUnknown' => 'Not checked yet',
'downloads.backgroundWarning.settingsUnavailable' => 'Couldn\'t open system settings on this device',
+ _ => null,
+ } ?? switch (path) {
'downloads.backgroundWarning.linkUnavailable' => 'Couldn\'t open dontkillmyapp.com on this device',
'downloads.unknownShow' => 'Unknown Show',
'downloads.unknownSeason' => 'Unknown Season',
diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart
index d9cecece2..6da8d9fbe 100644
--- a/lib/i18n/strings_es.g.dart
+++ b/lib/i18n/strings_es.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Forzar DV7 nativo y suprimir el reintento de conversión DV';
@override String get dvConversionDv81Description => 'Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision';
@override String get dvConversionHevcStripDescription => 'Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional';
- @override String get demuxer => 'Demultiplexor de contenedor';
- @override String get demuxerDescription => 'Qué demultiplexor analiza los archivos de reproducción directa. Cambia a media3 si un archivo da problemas.';
- @override String get demuxerFfmpeg => 'FFmpeg (recomendado)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Pedir perfil al abrir la aplicación';
@override String get requireProfileSelectionOnOpenDescription => 'Mostrar selección de perfil cada vez que se abre la aplicación';
@override String get forceTvMode => 'Forzar modo TV';
@@ -3089,10 +3085,6 @@ extension on TranslationsEs {
'settings.dvConversionNativeDescription' => 'Forzar DV7 nativo y suprimir el reintento de conversión DV',
'settings.dvConversionDv81Description' => 'Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision',
'settings.dvConversionHevcStripDescription' => 'Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional',
- 'settings.demuxer' => 'Demultiplexor de contenedor',
- 'settings.demuxerDescription' => 'Qué demultiplexor analiza los archivos de reproducción directa. Cambia a media3 si un archivo da problemas.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (recomendado)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Pedir perfil al abrir la aplicación',
'settings.requireProfileSelectionOnOpenDescription' => 'Mostrar selección de perfil cada vez que se abre la aplicación',
'settings.forceTvMode' => 'Forzar modo TV',
@@ -3284,12 +3276,12 @@ extension on TranslationsEs {
'mediaMenu.markAsUnwatched' => 'Marcar como no visto',
'mediaMenu.removeFromContinueWatching' => 'Eliminar de Seguir viendo',
'mediaMenu.viewDetails' => 'Ver detalles',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Ir a la serie',
'mediaMenu.shufflePlay' => 'Reproducción aleatoria',
'mediaMenu.shuffleNotAvailableOffline' => 'La reproducción aleatoria no está disponible sin conexión',
'mediaMenu.fileInfo' => 'Información del archivo',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Eliminar el episodio del servidor',
'mediaMenu.deleteSeasonFromServer' => 'Eliminar la temporada del servidor',
'mediaMenu.deleteShowFromServer' => 'Eliminar la serie del servidor',
@@ -3798,12 +3790,12 @@ extension on TranslationsEs {
'explore.rows.trendingShows' => 'Series en tendencia',
'explore.rows.popularMovies' => 'Películas populares',
'explore.rows.popularShows' => 'Series populares',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Anime en tendencia',
'explore.rows.suggestedAnime' => 'Anime sugerido',
'explore.rows.airingAnime' => 'Mejor anime en emisión',
'explore.rows.popularAnime' => 'Anime más popular',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Tendencias',
'explore.rows.upcomingMovies' => 'Próximas películas',
'explore.rows.upcomingShows' => 'Próximas series',
@@ -4312,12 +4304,12 @@ extension on TranslationsEs {
'companionRemote.pairing.validationHostFormat' => 'El formato debe ser IP:puerto (p. ej., 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Tiempo de conexión agotado. Usa la misma red en ambos dispositivos.',
'companionRemote.pairing.sessionNotFound' => 'Dispositivo no encontrado. Asegúrate de que Plezy esté en ejecución en el host.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Autenticación fallida. Ambos dispositivos necesitan la misma cuenta Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}',
'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?',
'companionRemote.remote.reconnecting' => 'Reconectando...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Intento ${current} de 5',
'companionRemote.remote.retryNow' => 'Reintentar ahora',
'companionRemote.remote.tabRemote' => 'Remoto',
diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart
index dca2041ab..e47b6f08e 100644
--- a/lib/i18n/strings_fr.g.dart
+++ b/lib/i18n/strings_fr.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$fr extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV';
@override String get dvConversionDv81Description => 'Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision';
@override String get dvConversionHevcStripDescription => 'Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple';
- @override String get demuxer => 'Démultiplexeur de conteneur';
- @override String get demuxerDescription => 'Quel démultiplexeur analyse les fichiers lus en direct. Passez à media3 si un fichier pose problème.';
- @override String get demuxerFfmpeg => 'FFmpeg (recommandé)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Demander le profil à l\'ouverture';
@override String get requireProfileSelectionOnOpenDescription => 'Afficher la sélection de profil à chaque ouverture de l\'application';
@override String get forceTvMode => 'Forcer le mode TV';
@@ -3089,10 +3085,6 @@ extension on TranslationsFr {
'settings.dvConversionNativeDescription' => 'Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV',
'settings.dvConversionDv81Description' => 'Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision',
'settings.dvConversionHevcStripDescription' => 'Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple',
- 'settings.demuxer' => 'Démultiplexeur de conteneur',
- 'settings.demuxerDescription' => 'Quel démultiplexeur analyse les fichiers lus en direct. Passez à media3 si un fichier pose problème.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (recommandé)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Demander le profil à l\'ouverture',
'settings.requireProfileSelectionOnOpenDescription' => 'Afficher la sélection de profil à chaque ouverture de l\'application',
'settings.forceTvMode' => 'Forcer le mode TV',
@@ -3284,12 +3276,12 @@ extension on TranslationsFr {
'mediaMenu.markAsUnwatched' => 'Marquer comme non visionné',
'mediaMenu.removeFromContinueWatching' => 'Supprimer de la liste "Continuer à regarder"',
'mediaMenu.viewDetails' => 'Voir les détails',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Aller à la série',
'mediaMenu.shufflePlay' => 'Lecture aléatoire',
'mediaMenu.shuffleNotAvailableOffline' => 'La lecture aléatoire n’est pas disponible hors ligne',
'mediaMenu.fileInfo' => 'Informations sur le fichier',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Supprimer l\'épisode du serveur',
'mediaMenu.deleteSeasonFromServer' => 'Supprimer la saison du serveur',
'mediaMenu.deleteShowFromServer' => 'Supprimer la série du serveur',
@@ -3798,12 +3790,12 @@ extension on TranslationsFr {
'explore.rows.trendingShows' => 'Séries tendance',
'explore.rows.popularMovies' => 'Films populaires',
'explore.rows.popularShows' => 'Séries populaires',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Animes tendance',
'explore.rows.suggestedAnime' => 'Animes suggérés',
'explore.rows.airingAnime' => 'Meilleurs animes en diffusion',
'explore.rows.popularAnime' => 'Animes les plus populaires',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Tendances',
'explore.rows.upcomingMovies' => 'Films à venir',
'explore.rows.upcomingShows' => 'Séries à venir',
@@ -4312,12 +4304,12 @@ extension on TranslationsFr {
'companionRemote.pairing.validationHostFormat' => 'Le format doit être IP:port (p. ex. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Délai de connexion dépassé. Utilisez le même réseau sur les deux appareils.',
'companionRemote.pairing.sessionNotFound' => 'Appareil introuvable. Assurez-vous que Plezy fonctionne sur l\'hôte.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Échec de l\'authentification. Les deux appareils doivent utiliser le même compte Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}',
'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?',
'companionRemote.remote.reconnecting' => 'Reconnexion...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentative ${current} sur 5',
'companionRemote.remote.retryNow' => 'Réessayer maintenant',
'companionRemote.remote.tabRemote' => 'Télécommande',
diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart
index bedc3fa2f..beaae4a4c 100644
--- a/lib/i18n/strings_hu.g.dart
+++ b/lib/i18n/strings_hu.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$hu extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása';
@override String get dvConversionDv81Description => 'A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re';
@override String get dvConversionHevcStripDescription => 'A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés';
- @override String get demuxer => 'Konténer-demuxer';
- @override String get demuxerDescription => 'Melyik demuxer dolgozza fel a közvetlenül lejátszott fájlokat. Válts media3-ra, ha egy fájl hibásan viselkedik.';
- @override String get demuxerFfmpeg => 'FFmpeg (ajánlott)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Profil kérése az alkalmazás megnyitásakor';
@override String get requireProfileSelectionOnOpenDescription => 'Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod';
@override String get forceTvMode => 'TV-mód kényszerítése';
@@ -3089,10 +3085,6 @@ extension on TranslationsHu {
'settings.dvConversionNativeDescription' => 'A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása',
'settings.dvConversionDv81Description' => 'A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re',
'settings.dvConversionHevcStripDescription' => 'A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés',
- 'settings.demuxer' => 'Konténer-demuxer',
- 'settings.demuxerDescription' => 'Melyik demuxer dolgozza fel a közvetlenül lejátszott fájlokat. Válts media3-ra, ha egy fájl hibásan viselkedik.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (ajánlott)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Profil kérése az alkalmazás megnyitásakor',
'settings.requireProfileSelectionOnOpenDescription' => 'Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod',
'settings.forceTvMode' => 'TV-mód kényszerítése',
@@ -3284,12 +3276,12 @@ extension on TranslationsHu {
'mediaMenu.markAsUnwatched' => 'Megjelölés nem megtekintettként',
'mediaMenu.removeFromContinueWatching' => 'Eltávolítás a folytatásból',
'mediaMenu.viewDetails' => 'Részletek megtekintése',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Ugrás a sorozathoz',
'mediaMenu.shufflePlay' => 'Véletlenszerű lejátszás',
'mediaMenu.shuffleNotAvailableOffline' => 'A véletlenszerű lejátszás nem érhető el offline',
'mediaMenu.fileInfo' => 'Fájlinformáció',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Epizód törlése a szerverről',
'mediaMenu.deleteSeasonFromServer' => 'Évad törlése a szerverről',
'mediaMenu.deleteShowFromServer' => 'Sorozat törlése a szerverről',
@@ -3798,12 +3790,12 @@ extension on TranslationsHu {
'explore.rows.trendingShows' => 'Felkapott sorozatok',
'explore.rows.popularMovies' => 'Népszerű filmek',
'explore.rows.popularShows' => 'Népszerű sorozatok',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Felkapott animék',
'explore.rows.suggestedAnime' => 'Ajánlott animék',
'explore.rows.airingAnime' => 'Jelenleg futó top animék',
'explore.rows.popularAnime' => 'Legnépszerűbb animék',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Felkapott',
'explore.rows.upcomingMovies' => 'Közelgő filmek',
'explore.rows.upcomingShows' => 'Közelgő sorozatok',
@@ -4312,12 +4304,12 @@ extension on TranslationsHu {
'companionRemote.pairing.validationHostFormat' => 'A formátumnak IP:port kell lennie (pl. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'A csatlakozás túllépte az időkorlátot. Használd ugyanazt a hálózatot mindkét eszközön.',
'companionRemote.pairing.sessionNotFound' => 'Az eszköz nem található. Győződj meg arról, hogy a Plezy fut a gazdagépen.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'A hitelesítés nem sikerült. Mindkét eszközön ugyanazt a Plex-fiókot kell használni.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Nem sikerült a csatlakozás: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Bontod a kapcsolatot a távoli munkamenettel?',
'companionRemote.remote.reconnecting' => 'Újracsatlakozás...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current} / 5 próbálkozás',
'companionRemote.remote.retryNow' => 'Újrapróbálkozás most',
'companionRemote.remote.tabRemote' => 'Távirányító',
diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart
index 5846dc60c..cd0e5ba89 100644
--- a/lib/i18n/strings_it.g.dart
+++ b/lib/i18n/strings_it.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$it extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV';
@override String get dvConversionDv81Description => 'Forza la conversione RPU diretta al profilo Dolby Vision 8.1';
@override String get dvConversionHevcStripDescription => 'Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC';
- @override String get demuxer => 'Demuxer del contenitore';
- @override String get demuxerDescription => 'Quale demuxer analizza i file riprodotti in diretta. Passa a media3 se un file dà problemi.';
- @override String get demuxerFfmpeg => 'FFmpeg (consigliato)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Chiedi di scegliere il profilo all\'apertura';
@override String get requireProfileSelectionOnOpenDescription => 'Mostra la selezione del profilo ogni volta che l\'app viene aperta';
@override String get forceTvMode => 'Forza modalità TV';
@@ -3089,10 +3085,6 @@ extension on TranslationsIt {
'settings.dvConversionNativeDescription' => 'Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV',
'settings.dvConversionDv81Description' => 'Forza la conversione RPU diretta al profilo Dolby Vision 8.1',
'settings.dvConversionHevcStripDescription' => 'Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC',
- 'settings.demuxer' => 'Demuxer del contenitore',
- 'settings.demuxerDescription' => 'Quale demuxer analizza i file riprodotti in diretta. Passa a media3 se un file dà problemi.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (consigliato)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Chiedi di scegliere il profilo all\'apertura',
'settings.requireProfileSelectionOnOpenDescription' => 'Mostra la selezione del profilo ogni volta che l\'app viene aperta',
'settings.forceTvMode' => 'Forza modalità TV',
@@ -3284,12 +3276,12 @@ extension on TranslationsIt {
'mediaMenu.markAsUnwatched' => 'Segna come non visto',
'mediaMenu.removeFromContinueWatching' => 'Rimuovi da Continua a guardare',
'mediaMenu.viewDetails' => 'Visualizza dettagli',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Vai alla serie',
'mediaMenu.shufflePlay' => 'Riproduzione casuale',
'mediaMenu.shuffleNotAvailableOffline' => 'Riproduzione casuale non disponibile offline',
'mediaMenu.fileInfo' => 'Info sul file',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Elimina episodio dal server',
'mediaMenu.deleteSeasonFromServer' => 'Elimina stagione dal server',
'mediaMenu.deleteShowFromServer' => 'Elimina serie dal server',
@@ -3798,12 +3790,12 @@ extension on TranslationsIt {
'explore.rows.trendingShows' => 'Serie TV di tendenza',
'explore.rows.popularMovies' => 'Film popolari',
'explore.rows.popularShows' => 'Serie TV popolari',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Anime di tendenza',
'explore.rows.suggestedAnime' => 'Anime suggeriti',
'explore.rows.airingAnime' => 'Migliori anime in onda',
'explore.rows.popularAnime' => 'Anime più popolari',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Di tendenza',
'explore.rows.upcomingMovies' => 'Film in arrivo',
'explore.rows.upcomingShows' => 'Serie TV in arrivo',
@@ -4312,12 +4304,12 @@ extension on TranslationsIt {
'companionRemote.pairing.validationHostFormat' => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Tempo scaduto per la connessione. Usa la stessa rete su entrambi i dispositivi.',
'companionRemote.pairing.sessionNotFound' => 'Dispositivo non trovato. Assicurati che Plezy sia in esecuzione sull\'host.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Autenticazione non riuscita. Entrambi i dispositivi devono usare lo stesso account Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Connessione non riuscita: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?',
'companionRemote.remote.reconnecting' => 'Riconnessione...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativo ${current} di 5',
'companionRemote.remote.retryNow' => 'Riprova ora',
'companionRemote.remote.tabRemote' => 'Telecomando',
diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart
index c06b87a02..1e714001d 100644
--- a/lib/i18n/strings_ja.g.dart
+++ b/lib/i18n/strings_ja.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$ja extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'ネイティブ DV7 を強制し、DV 変換の再試行を抑制します';
@override String get dvConversionDv81Description => 'Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します';
@override String get dvConversionHevcStripDescription => 'Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います';
- @override String get demuxer => 'コンテナデマルチプレクサ';
- @override String get demuxerDescription => 'ダイレクト再生のファイルを解析するデマルチプレクサ。ファイルの再生に問題がある場合は media3 に切り替えてください。';
- @override String get demuxerFfmpeg => 'FFmpeg(推奨)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'アプリ起動時にプロフィールを確認';
@override String get requireProfileSelectionOnOpenDescription => 'アプリを開くたびにプロフィール選択を表示';
@override String get forceTvMode => 'TVモードを強制';
@@ -3079,10 +3075,6 @@ extension on TranslationsJa {
'settings.dvConversionNativeDescription' => 'ネイティブ DV7 を強制し、DV 変換の再試行を抑制します',
'settings.dvConversionDv81Description' => 'Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します',
'settings.dvConversionHevcStripDescription' => 'Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います',
- 'settings.demuxer' => 'コンテナデマルチプレクサ',
- 'settings.demuxerDescription' => 'ダイレクト再生のファイルを解析するデマルチプレクサ。ファイルの再生に問題がある場合は media3 に切り替えてください。',
- 'settings.demuxerFfmpeg' => 'FFmpeg(推奨)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'アプリ起動時にプロフィールを確認',
'settings.requireProfileSelectionOnOpenDescription' => 'アプリを開くたびにプロフィール選択を表示',
'settings.forceTvMode' => 'TVモードを強制',
@@ -3274,12 +3266,12 @@ extension on TranslationsJa {
'mediaMenu.markAsUnwatched' => '未視聴にする',
'mediaMenu.removeFromContinueWatching' => '視聴中から削除',
'mediaMenu.viewDetails' => '詳細を表示',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'シリーズへ移動',
'mediaMenu.shufflePlay' => 'シャッフル再生',
'mediaMenu.shuffleNotAvailableOffline' => 'オフラインではシャッフルを利用できません',
'mediaMenu.fileInfo' => 'ファイル情報',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'サーバーからエピソードを削除',
'mediaMenu.deleteSeasonFromServer' => 'サーバーからシーズンを削除',
'mediaMenu.deleteShowFromServer' => 'サーバーから番組を削除',
@@ -3788,12 +3780,12 @@ extension on TranslationsJa {
'explore.rows.trendingShows' => 'トレンドのテレビ番組',
'explore.rows.popularMovies' => '人気の映画',
'explore.rows.popularShows' => '人気のテレビ番組',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'トレンドのアニメ',
'explore.rows.suggestedAnime' => 'おすすめのアニメ',
'explore.rows.airingAnime' => '放送中の注目アニメ',
'explore.rows.popularAnime' => '人気のアニメ',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'トレンド',
'explore.rows.upcomingMovies' => '近日公開の映画',
'explore.rows.upcomingShows' => '放送予定の番組',
@@ -4302,12 +4294,12 @@ extension on TranslationsJa {
'companionRemote.pairing.validationHostFormat' => '形式はIP:ポートである必要があります(例: 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => '接続がタイムアウトしました。両方のデバイスで同じネットワークを使用してください。',
'companionRemote.pairing.sessionNotFound' => 'デバイスが見つかりません。ホストでPlezyが実行中か確認してください。',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '接続に失敗しました: ${error}',
'companionRemote.remote.disconnectConfirm' => 'リモートセッションを切断しますか?',
'companionRemote.remote.reconnecting' => '再接続中…',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => '試行 ${current}/5',
'companionRemote.remote.retryNow' => '今すぐ再試行',
'companionRemote.remote.tabRemote' => 'リモコン',
diff --git a/lib/i18n/strings_kk.g.dart b/lib/i18n/strings_kk.g.dart
index 679235af9..0f235af39 100644
--- a/lib/i18n/strings_kk.g.dart
+++ b/lib/i18n/strings_kk.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$kk extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Ішкі DV7 режимін мәжбүрлеу';
@override String get dvConversionDv81Description => 'Dolby Vision profile 8.1 форматына түрлендіру';
@override String get dvConversionHevcStripDescription => 'Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету';
- @override String get demuxer => 'Контейнер демультиплексоры';
- @override String get demuxerDescription => 'Тікелей ойнатылатын файлдарды қай демультиплексор талдайды. Файл дұрыс ойнатылмаса, media3-ке ауысыңыз.';
- @override String get demuxerFfmpeg => 'FFmpeg (ұсынылады)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Ашқанда профильді сұрау';
@override String get requireProfileSelectionOnOpenDescription => 'Қолданба ашылған сайын профильді таңдауды көрсету';
@override String get forceTvMode => 'TV режимін мәжбүрлеу';
@@ -3089,10 +3085,6 @@ extension on TranslationsKk {
'settings.dvConversionNativeDescription' => 'Ішкі DV7 режимін мәжбүрлеу',
'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1 форматына түрлендіру',
'settings.dvConversionHevcStripDescription' => 'Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету',
- 'settings.demuxer' => 'Контейнер демультиплексоры',
- 'settings.demuxerDescription' => 'Тікелей ойнатылатын файлдарды қай демультиплексор талдайды. Файл дұрыс ойнатылмаса, media3-ке ауысыңыз.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (ұсынылады)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Ашқанда профильді сұрау',
'settings.requireProfileSelectionOnOpenDescription' => 'Қолданба ашылған сайын профильді таңдауды көрсету',
'settings.forceTvMode' => 'TV режимін мәжбүрлеу',
@@ -3284,12 +3276,12 @@ extension on TranslationsKk {
'mediaMenu.markAsUnwatched' => 'Көрілмеді деп белгілеу',
'mediaMenu.removeFromContinueWatching' => '"Көруді жалғастыру" тізімінен өшіру',
'mediaMenu.viewDetails' => 'Толығырақ көру',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Сериалға өту',
'mediaMenu.shufflePlay' => 'Араластырып ойнату',
'mediaMenu.shuffleNotAvailableOffline' => 'Араластырып ойнату офлайн режимде қолжетімсіз',
'mediaMenu.fileInfo' => 'Файл туралы ақпарат',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Бөлімді серверден өшіру',
'mediaMenu.deleteSeasonFromServer' => 'Маусымды серверден өшіру',
'mediaMenu.deleteShowFromServer' => 'Сериалды серверден өшіру',
@@ -3798,12 +3790,12 @@ extension on TranslationsKk {
'explore.rows.trendingShows' => 'Трендтегі сериалдар',
'explore.rows.popularMovies' => 'Танымал фильмдер',
'explore.rows.popularShows' => 'Танымал сериалдар',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Трендтегі аниме',
'explore.rows.suggestedAnime' => 'Ұсынылатын аниме',
'explore.rows.airingAnime' => 'Эфирдегі үздік аниме',
'explore.rows.popularAnime' => 'Ең танымал аниме',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Трендтер',
'explore.rows.upcomingMovies' => 'Күтілетін фильмдер',
'explore.rows.upcomingShows' => 'Күтілетін сериалдар',
@@ -4312,12 +4304,12 @@ extension on TranslationsKk {
'companionRemote.pairing.validationHostFormat' => 'Формат IP:порт түрінде болуы керек',
'companionRemote.pairing.connectionTimedOut' => 'Қосылу уақыты өтті.',
'companionRemote.pairing.sessionNotFound' => 'Құрылғы табылмады.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Аутентификация қатесі.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Қосылу мүмкін болмады: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Пульт сеансынан ажырайсыз ба?',
'companionRemote.remote.reconnecting' => 'Қайта қосылуда...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Әрекет ${current} / 5',
'companionRemote.remote.retryNow' => 'Қазір қайталау',
'companionRemote.remote.tabRemote' => 'Пульт',
diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart
index bc67bf9a9..261a0e130 100644
--- a/lib/i18n/strings_ko.g.dart
+++ b/lib/i18n/strings_ko.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$ko extends Translations$settings$en {
@override String get dvConversionNativeDescription => '네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다';
@override String get dvConversionDv81Description => 'Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다';
@override String get dvConversionHevcStripDescription => 'Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다';
- @override String get demuxer => '컨테이너 디먹서';
- @override String get demuxerDescription => '다이렉트 재생 파일을 분석할 디먹서입니다. 파일이 제대로 재생되지 않으면 media3으로 전환하세요.';
- @override String get demuxerFfmpeg => 'FFmpeg (권장)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => '앱 실행 시 프로필 선택';
@override String get requireProfileSelectionOnOpenDescription => '앱을 열 때마다 프로필 선택 화면을 표시합니다';
@override String get forceTvMode => 'TV 모드 강제 사용';
@@ -3079,10 +3075,6 @@ extension on TranslationsKo {
'settings.dvConversionNativeDescription' => '네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다',
'settings.dvConversionDv81Description' => 'Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다',
'settings.dvConversionHevcStripDescription' => 'Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다',
- 'settings.demuxer' => '컨테이너 디먹서',
- 'settings.demuxerDescription' => '다이렉트 재생 파일을 분석할 디먹서입니다. 파일이 제대로 재생되지 않으면 media3으로 전환하세요.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (권장)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => '앱 실행 시 프로필 선택',
'settings.requireProfileSelectionOnOpenDescription' => '앱을 열 때마다 프로필 선택 화면을 표시합니다',
'settings.forceTvMode' => 'TV 모드 강제 사용',
@@ -3274,12 +3266,12 @@ extension on TranslationsKo {
'mediaMenu.markAsUnwatched' => '미시청으로 표시',
'mediaMenu.removeFromContinueWatching' => '계속 보기에서 제거',
'mediaMenu.viewDetails' => '상세 정보 보기',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => '시리즈로 이동',
'mediaMenu.shufflePlay' => '무작위 재생',
'mediaMenu.shuffleNotAvailableOffline' => '오프라인에서는 무작위 재생을 사용할 수 없습니다',
'mediaMenu.fileInfo' => '파일 정보',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => '서버에서 에피소드 삭제',
'mediaMenu.deleteSeasonFromServer' => '서버에서 시즌 삭제',
'mediaMenu.deleteShowFromServer' => '서버에서 프로그램 삭제',
@@ -3788,12 +3780,12 @@ extension on TranslationsKo {
'explore.rows.trendingShows' => '지금 뜨는 TV 프로그램',
'explore.rows.popularMovies' => '인기 영화',
'explore.rows.popularShows' => '인기 TV 프로그램',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => '지금 뜨는 애니메이션',
'explore.rows.suggestedAnime' => '추천 애니메이션',
'explore.rows.airingAnime' => '방영 중인 인기 애니메이션',
'explore.rows.popularAnime' => '가장 인기 있는 애니메이션',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => '지금 뜨는 콘텐츠',
'explore.rows.upcomingMovies' => '개봉 예정 영화',
'explore.rows.upcomingShows' => '방영 예정 TV 프로그램',
@@ -4302,12 +4294,12 @@ extension on TranslationsKo {
'companionRemote.pairing.validationHostFormat' => '형식은 IP:포트여야 합니다 (예: 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 두 기기에서 같은 네트워크를 사용하세요.',
'companionRemote.pairing.sessionNotFound' => '기기를 찾을 수 없습니다. 호스트에서 Plezy가 실행 중인지 확인하세요.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}',
'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?',
'companionRemote.remote.reconnecting' => '재연결 중...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current}/5 시도 중',
'companionRemote.remote.retryNow' => '지금 재시도',
'companionRemote.remote.tabRemote' => '리모컨',
diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart
index d32dec8f6..f52f517a8 100644
--- a/lib/i18n/strings_nb.g.dart
+++ b/lib/i18n/strings_nb.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$nb extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Tving opprinnelig DV7-avspilling og ikke prøv DV-konvertering på nytt';
@override String get dvConversionDv81Description => 'Tving direkte RPU-konvertering til Dolby Vision-profil 8.1';
@override String get dvConversionHevcStripDescription => 'Fjern Dolby Vision RPU/EL-lag og lever som vanlig HEVC';
- @override String get demuxer => 'Container-demukser';
- @override String get demuxerDescription => 'Hvilken demukser som leser direkteavspilte filer. Bytt til media3 hvis en fil oppfører seg rart.';
- @override String get demuxerFfmpeg => 'FFmpeg (anbefalt)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Spør om profil ved appåpning';
@override String get requireProfileSelectionOnOpenDescription => 'Vis profilvalg hver gang appen åpnes';
@override String get forceTvMode => 'Tving TV-modus';
@@ -3089,10 +3085,6 @@ extension on TranslationsNb {
'settings.dvConversionNativeDescription' => 'Tving opprinnelig DV7-avspilling og ikke prøv DV-konvertering på nytt',
'settings.dvConversionDv81Description' => 'Tving direkte RPU-konvertering til Dolby Vision-profil 8.1',
'settings.dvConversionHevcStripDescription' => 'Fjern Dolby Vision RPU/EL-lag og lever som vanlig HEVC',
- 'settings.demuxer' => 'Container-demukser',
- 'settings.demuxerDescription' => 'Hvilken demukser som leser direkteavspilte filer. Bytt til media3 hvis en fil oppfører seg rart.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (anbefalt)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Spør om profil ved appåpning',
'settings.requireProfileSelectionOnOpenDescription' => 'Vis profilvalg hver gang appen åpnes',
'settings.forceTvMode' => 'Tving TV-modus',
@@ -3284,12 +3276,12 @@ extension on TranslationsNb {
'mediaMenu.markAsUnwatched' => 'Merk som usett',
'mediaMenu.removeFromContinueWatching' => 'Fjern fra Fortsett å se',
'mediaMenu.viewDetails' => 'Vis detaljer',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Gå til serie',
'mediaMenu.shufflePlay' => 'Tilfeldig avspilling',
'mediaMenu.shuffleNotAvailableOffline' => 'Tilfeldig avspilling er ikke tilgjengelig uten nett',
'mediaMenu.fileInfo' => 'Filinformasjon',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Slett episode fra serveren',
'mediaMenu.deleteSeasonFromServer' => 'Slett sesong fra serveren',
'mediaMenu.deleteShowFromServer' => 'Slett serie fra serveren',
@@ -3798,12 +3790,12 @@ extension on TranslationsNb {
'explore.rows.trendingShows' => 'Populære serier nå',
'explore.rows.popularMovies' => 'Populære filmer',
'explore.rows.popularShows' => 'Populære serier',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Populær anime nå',
'explore.rows.suggestedAnime' => 'Foreslått anime',
'explore.rows.airingAnime' => 'Topp pågående anime',
'explore.rows.popularAnime' => 'Mest populær anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Populært nå',
'explore.rows.upcomingMovies' => 'Kommende filmer',
'explore.rows.upcomingShows' => 'Kommende serier',
@@ -4312,12 +4304,12 @@ extension on TranslationsNb {
'companionRemote.pairing.validationHostFormat' => 'Format må være IP:port (f.eks. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Tilkoblingen fikk tidsavbrudd. Bruk samme nettverk på begge enheter.',
'companionRemote.pairing.sessionNotFound' => 'Enhet ikke funnet. Sørg for at Plezy kjører på verten.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Autentisering mislyktes. Begge enheter må bruke samme Plex-konto.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke koble til: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vil du koble fra fjernøkten?',
'companionRemote.remote.reconnecting' => 'Kobler til på nytt...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Forsøk ${current} av 5',
'companionRemote.remote.retryNow' => 'Prøv nå',
'companionRemote.remote.tabRemote' => 'Fjernkontroll',
diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart
index 95ff604c5..d176d163b 100644
--- a/lib/i18n/strings_nl.g.dart
+++ b/lib/i18n/strings_nl.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$nl extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Dwing native DV7 af en voorkom een nieuwe poging met DV-conversie';
@override String get dvConversionDv81Description => 'Dwing directe RPU-conversie naar Dolby Vision-profiel 8.1 af';
@override String get dvConversionHevcStripDescription => 'Verwijder Dolby Vision RPU/EL-lagen en bied gewone HEVC aan';
- @override String get demuxer => 'Container-demuxer';
- @override String get demuxerDescription => 'Welke demuxer direct afgespeelde bestanden inleest. Schakel over naar media3 als een bestand zich vreemd gedraagt.';
- @override String get demuxerFfmpeg => 'FFmpeg (aanbevolen)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Vraag om profiel bij openen';
@override String get requireProfileSelectionOnOpenDescription => 'Toon profielselectie telkens wanneer de app wordt geopend';
@override String get forceTvMode => 'Tv-modus afdwingen';
@@ -3089,10 +3085,6 @@ extension on TranslationsNl {
'settings.dvConversionNativeDescription' => 'Dwing native DV7 af en voorkom een nieuwe poging met DV-conversie',
'settings.dvConversionDv81Description' => 'Dwing directe RPU-conversie naar Dolby Vision-profiel 8.1 af',
'settings.dvConversionHevcStripDescription' => 'Verwijder Dolby Vision RPU/EL-lagen en bied gewone HEVC aan',
- 'settings.demuxer' => 'Container-demuxer',
- 'settings.demuxerDescription' => 'Welke demuxer direct afgespeelde bestanden inleest. Schakel over naar media3 als een bestand zich vreemd gedraagt.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (aanbevolen)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Vraag om profiel bij openen',
'settings.requireProfileSelectionOnOpenDescription' => 'Toon profielselectie telkens wanneer de app wordt geopend',
'settings.forceTvMode' => 'Tv-modus afdwingen',
@@ -3284,12 +3276,12 @@ extension on TranslationsNl {
'mediaMenu.markAsUnwatched' => 'Als ongekeken markeren',
'mediaMenu.removeFromContinueWatching' => 'Uit \'Doorgaan met kijken\' verwijderen',
'mediaMenu.viewDetails' => 'Details bekijken',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Ga naar serie',
'mediaMenu.shufflePlay' => 'Willekeurig afspelen',
'mediaMenu.shuffleNotAvailableOffline' => 'Willekeurig afspelen is offline niet beschikbaar',
'mediaMenu.fileInfo' => 'Bestandsinformatie',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Aflevering verwijderen van server',
'mediaMenu.deleteSeasonFromServer' => 'Seizoen verwijderen van server',
'mediaMenu.deleteShowFromServer' => 'Serie verwijderen van server',
@@ -3798,12 +3790,12 @@ extension on TranslationsNl {
'explore.rows.trendingShows' => 'Trending series',
'explore.rows.popularMovies' => 'Populaire films',
'explore.rows.popularShows' => 'Populaire series',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Trending anime',
'explore.rows.suggestedAnime' => 'Aanbevolen anime',
'explore.rows.airingAnime' => 'Beste lopende anime',
'explore.rows.popularAnime' => 'Populairste anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Trending',
'explore.rows.upcomingMovies' => 'Aankomende films',
'explore.rows.upcomingShows' => 'Aankomende series',
@@ -4312,12 +4304,12 @@ extension on TranslationsNl {
'companionRemote.pairing.validationHostFormat' => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Er is een time-out opgetreden. Gebruik op beide apparaten hetzelfde netwerk.',
'companionRemote.pairing.sessionNotFound' => 'Apparaat niet gevonden. Zorg dat Plezy op de host draait.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Authenticatie mislukt. Beide apparaten hebben hetzelfde Plex-account nodig.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kan niet verbinden: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?',
'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Poging ${current} van 5',
'companionRemote.remote.retryNow' => 'Nu opnieuw proberen',
'companionRemote.remote.tabRemote' => 'Afstandsbediening',
diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart
index ba52f6271..e90cfbf3d 100644
--- a/lib/i18n/strings_pl.g.dart
+++ b/lib/i18n/strings_pl.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$pl extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Wymuś natywne DV7 i wyłącz ponowną próbę konwersji DV';
@override String get dvConversionDv81Description => 'Wymuś wbudowaną konwersję RPU do profilu Dolby Vision 8.1';
@override String get dvConversionHevcStripDescription => 'Usuń warstwy Dolby Vision RPU/EL i przedstaw zwykłe HEVC';
- @override String get demuxer => 'Demukser kontenera';
- @override String get demuxerDescription => 'Który demukser analizuje pliki odtwarzane bezpośrednio. Przełącz na media3, jeśli plik sprawia problemy.';
- @override String get demuxerFfmpeg => 'FFmpeg (zalecany)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Pytaj o profil przy otwarciu aplikacji';
@override String get requireProfileSelectionOnOpenDescription => 'Pokaż wybór profilu za każdym razem, gdy aplikacja jest otwierana';
@override String get forceTvMode => 'Wymuś tryb TV';
@@ -3109,10 +3105,6 @@ extension on TranslationsPl {
'settings.dvConversionNativeDescription' => 'Wymuś natywne DV7 i wyłącz ponowną próbę konwersji DV',
'settings.dvConversionDv81Description' => 'Wymuś wbudowaną konwersję RPU do profilu Dolby Vision 8.1',
'settings.dvConversionHevcStripDescription' => 'Usuń warstwy Dolby Vision RPU/EL i przedstaw zwykłe HEVC',
- 'settings.demuxer' => 'Demukser kontenera',
- 'settings.demuxerDescription' => 'Który demukser analizuje pliki odtwarzane bezpośrednio. Przełącz na media3, jeśli plik sprawia problemy.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (zalecany)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Pytaj o profil przy otwarciu aplikacji',
'settings.requireProfileSelectionOnOpenDescription' => 'Pokaż wybór profilu za każdym razem, gdy aplikacja jest otwierana',
'settings.forceTvMode' => 'Wymuś tryb TV',
@@ -3304,12 +3296,12 @@ extension on TranslationsPl {
'mediaMenu.markAsUnwatched' => 'Oznacz jako nieobejrzane',
'mediaMenu.removeFromContinueWatching' => 'Usuń z kontynuowania oglądania',
'mediaMenu.viewDetails' => 'Pokaż szczegóły',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Przejdź do serialu',
'mediaMenu.shufflePlay' => 'Odtwarzanie losowe',
'mediaMenu.shuffleNotAvailableOffline' => 'Odtwarzanie losowe nie jest dostępne offline',
'mediaMenu.fileInfo' => 'Informacje o pliku',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Usuń odcinek z serwera',
'mediaMenu.deleteSeasonFromServer' => 'Usuń sezon z serwera',
'mediaMenu.deleteShowFromServer' => 'Usuń serial z serwera',
@@ -3818,12 +3810,12 @@ extension on TranslationsPl {
'explore.rows.trendingShows' => 'Seriale na czasie',
'explore.rows.popularMovies' => 'Popularne filmy',
'explore.rows.popularShows' => 'Popularne seriale',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Anime na czasie',
'explore.rows.suggestedAnime' => 'Sugerowane anime',
'explore.rows.airingAnime' => 'Najpopularniejsze emitowane anime',
'explore.rows.popularAnime' => 'Najpopularniejsze anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Na czasie',
'explore.rows.upcomingMovies' => 'Nadchodzące filmy',
'explore.rows.upcomingShows' => 'Nadchodzące seriale',
@@ -4332,12 +4324,12 @@ extension on TranslationsPl {
'companionRemote.pairing.validationHostFormat' => 'Format musi być IP:port (np. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Limit czasu połączenia. Użyj tej samej sieci na obu urządzeniach.',
'companionRemote.pairing.sessionNotFound' => 'Nie znaleziono urządzenia. Upewnij się, że Plezy działa na hoście.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Uwierzytelnianie nie powiodło się. Oba urządzenia muszą używać tego samego konta Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Nie udało się połączyć: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Czy chcesz się rozłączyć od sesji zdalnej?',
'companionRemote.remote.reconnecting' => 'Ponowne łączenie...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Próba ${current} z 5',
'companionRemote.remote.retryNow' => 'Ponów teraz',
'companionRemote.remote.tabRemote' => 'Pilot',
diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart
index e9d70efde..f1379743d 100644
--- a/lib/i18n/strings_pt.g.dart
+++ b/lib/i18n/strings_pt.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$pt extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Forçar DV7 nativo e impedir uma nova tentativa de conversão de DV';
@override String get dvConversionDv81Description => 'Forçar a conversão RPU integrada para Dolby Vision perfil 8.1';
@override String get dvConversionHevcStripDescription => 'Remover as camadas RPU/EL do Dolby Vision e apresentar HEVC sem Dolby Vision';
- @override String get demuxer => 'Demuxer de contêiner';
- @override String get demuxerDescription => 'Qual demuxer processa os arquivos reproduzidos diretamente. Mude para media3 se algum arquivo apresentar problemas.';
- @override String get demuxerFfmpeg => 'FFmpeg (recomendado)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Pedir perfil ao abrir o app';
@override String get requireProfileSelectionOnOpenDescription => 'Mostrar a seleção de perfil sempre que o app for aberto';
@override String get forceTvMode => 'Forçar modo TV';
@@ -3089,10 +3085,6 @@ extension on TranslationsPt {
'settings.dvConversionNativeDescription' => 'Forçar DV7 nativo e impedir uma nova tentativa de conversão de DV',
'settings.dvConversionDv81Description' => 'Forçar a conversão RPU integrada para Dolby Vision perfil 8.1',
'settings.dvConversionHevcStripDescription' => 'Remover as camadas RPU/EL do Dolby Vision e apresentar HEVC sem Dolby Vision',
- 'settings.demuxer' => 'Demuxer de contêiner',
- 'settings.demuxerDescription' => 'Qual demuxer processa os arquivos reproduzidos diretamente. Mude para media3 se algum arquivo apresentar problemas.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (recomendado)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Pedir perfil ao abrir o app',
'settings.requireProfileSelectionOnOpenDescription' => 'Mostrar a seleção de perfil sempre que o app for aberto',
'settings.forceTvMode' => 'Forçar modo TV',
@@ -3284,12 +3276,12 @@ extension on TranslationsPt {
'mediaMenu.markAsUnwatched' => 'Marcar como Não Assistido',
'mediaMenu.removeFromContinueWatching' => 'Remover de Continuar Assistindo',
'mediaMenu.viewDetails' => 'Ver detalhes',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Ir para a série',
'mediaMenu.shufflePlay' => 'Reprodução Aleatória',
'mediaMenu.shuffleNotAvailableOffline' => 'Reprodução aleatória indisponível offline',
'mediaMenu.fileInfo' => 'Informações do arquivo',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Excluir episódio do servidor',
'mediaMenu.deleteSeasonFromServer' => 'Excluir temporada do servidor',
'mediaMenu.deleteShowFromServer' => 'Excluir série do servidor',
@@ -3798,12 +3790,12 @@ extension on TranslationsPt {
'explore.rows.trendingShows' => 'Séries em alta',
'explore.rows.popularMovies' => 'Filmes populares',
'explore.rows.popularShows' => 'Séries populares',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Anime em alta',
'explore.rows.suggestedAnime' => 'Anime sugerido',
'explore.rows.airingAnime' => 'Melhores animes em exibição',
'explore.rows.popularAnime' => 'Anime mais popular',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Em alta',
'explore.rows.upcomingMovies' => 'Próximos filmes',
'explore.rows.upcomingShows' => 'Próximas séries',
@@ -4312,12 +4304,12 @@ extension on TranslationsPt {
'companionRemote.pairing.validationHostFormat' => 'O formato deve ser IP:porta (ex.: 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'A conexão atingiu o tempo limite. Use a mesma rede nos dois dispositivos.',
'companionRemote.pairing.sessionNotFound' => 'Dispositivo não encontrado. Verifique se o Plezy está em execução no host.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Falha na autenticação. Ambos os dispositivos precisam da mesma conta Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Falha ao conectar: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Deseja desconectar da sessão remota?',
'companionRemote.remote.reconnecting' => 'Reconectando...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativa ${current} de 5',
'companionRemote.remote.retryNow' => 'Tentar agora',
'companionRemote.remote.tabRemote' => 'Remoto',
diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart
index 0c6d54b51..7f5523641 100644
--- a/lib/i18n/strings_ru.g.dart
+++ b/lib/i18n/strings_ru.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$ru extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Принудительно использовать нативный DV7 и не повторять DV-конвертацию';
@override String get dvConversionDv81Description => 'Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1';
@override String get dvConversionHevcStripDescription => 'Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC';
- @override String get demuxer => 'Демультиплексор контейнера';
- @override String get demuxerDescription => 'Какой демультиплексор разбирает файлы при прямом воспроизведении. Переключитесь на media3, если файл воспроизводится неправильно.';
- @override String get demuxerFfmpeg => 'FFmpeg (рекомендуется)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Запрашивать профиль при запуске';
@override String get requireProfileSelectionOnOpenDescription => 'Показывать выбор профиля при каждом открытии приложения';
@override String get forceTvMode => 'Принудительный режим ТВ';
@@ -3109,10 +3105,6 @@ extension on TranslationsRu {
'settings.dvConversionNativeDescription' => 'Принудительно использовать нативный DV7 и не повторять DV-конвертацию',
'settings.dvConversionDv81Description' => 'Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1',
'settings.dvConversionHevcStripDescription' => 'Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC',
- 'settings.demuxer' => 'Демультиплексор контейнера',
- 'settings.demuxerDescription' => 'Какой демультиплексор разбирает файлы при прямом воспроизведении. Переключитесь на media3, если файл воспроизводится неправильно.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (рекомендуется)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Запрашивать профиль при запуске',
'settings.requireProfileSelectionOnOpenDescription' => 'Показывать выбор профиля при каждом открытии приложения',
'settings.forceTvMode' => 'Принудительный режим ТВ',
@@ -3304,12 +3296,12 @@ extension on TranslationsRu {
'mediaMenu.markAsUnwatched' => 'Отметить как непросмотренное',
'mediaMenu.removeFromContinueWatching' => 'Удалить из «Продолжить просмотр»',
'mediaMenu.viewDetails' => 'Показать сведения',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Перейти к сериалу',
'mediaMenu.shufflePlay' => 'Случайное воспроизведение',
'mediaMenu.shuffleNotAvailableOffline' => 'Перемешивание недоступно офлайн',
'mediaMenu.fileInfo' => 'Информация о файле',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Удалить эпизод с сервера',
'mediaMenu.deleteSeasonFromServer' => 'Удалить сезон с сервера',
'mediaMenu.deleteShowFromServer' => 'Удалить сериал с сервера',
@@ -3818,12 +3810,12 @@ extension on TranslationsRu {
'explore.rows.trendingShows' => 'Сериалы в тренде',
'explore.rows.popularMovies' => 'Популярные фильмы',
'explore.rows.popularShows' => 'Популярные сериалы',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Аниме в тренде',
'explore.rows.suggestedAnime' => 'Рекомендуемые аниме',
'explore.rows.airingAnime' => 'Топ выходящих аниме',
'explore.rows.popularAnime' => 'Самые популярные аниме',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'В тренде',
'explore.rows.upcomingMovies' => 'Ожидаемые фильмы',
'explore.rows.upcomingShows' => 'Ожидаемые сериалы',
@@ -4332,12 +4324,12 @@ extension on TranslationsRu {
'companionRemote.pairing.validationHostFormat' => 'Формат должен быть IP:порт (например, 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Время подключения истекло. Используйте одну сеть на обоих устройствах.',
'companionRemote.pairing.sessionNotFound' => 'Устройство не найдено. Убедитесь, что Plezy запущен на хосте.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Не удалось подключиться: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Отключиться от удалённой сессии?',
'companionRemote.remote.reconnecting' => 'Переподключение...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Попытка ${current} из 5',
'companionRemote.remote.retryNow' => 'Повторить сейчас',
'companionRemote.remote.tabRemote' => 'Пульт',
diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart
index 9b0afa166..433ec7168 100644
--- a/lib/i18n/strings_sv.g.dart
+++ b/lib/i18n/strings_sv.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$sv extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Tvinga inbyggd DV7 och förhindra nya försök med DV-konvertering';
@override String get dvConversionDv81Description => 'Tvinga direkt RPU-konvertering till Dolby Vision-profil 8.1';
@override String get dvConversionHevcStripDescription => 'Ta bort Dolby Visions RPU-/EL-lager och använd vanlig HEVC';
- @override String get demuxer => 'Container-demuxer';
- @override String get demuxerDescription => 'Vilken demuxer som läser direktuppspelade filer. Byt till media3 om en fil krånglar.';
- @override String get demuxerFfmpeg => 'FFmpeg (rekommenderas)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Fråga efter profil vid appstart';
@override String get requireProfileSelectionOnOpenDescription => 'Visa profilval varje gång appen öppnas';
@override String get forceTvMode => 'Tvinga TV-läge';
@@ -3089,10 +3085,6 @@ extension on TranslationsSv {
'settings.dvConversionNativeDescription' => 'Tvinga inbyggd DV7 och förhindra nya försök med DV-konvertering',
'settings.dvConversionDv81Description' => 'Tvinga direkt RPU-konvertering till Dolby Vision-profil 8.1',
'settings.dvConversionHevcStripDescription' => 'Ta bort Dolby Visions RPU-/EL-lager och använd vanlig HEVC',
- 'settings.demuxer' => 'Container-demuxer',
- 'settings.demuxerDescription' => 'Vilken demuxer som läser direktuppspelade filer. Byt till media3 om en fil krånglar.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (rekommenderas)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Fråga efter profil vid appstart',
'settings.requireProfileSelectionOnOpenDescription' => 'Visa profilval varje gång appen öppnas',
'settings.forceTvMode' => 'Tvinga TV-läge',
@@ -3284,12 +3276,12 @@ extension on TranslationsSv {
'mediaMenu.markAsUnwatched' => 'Markera som osedd',
'mediaMenu.removeFromContinueWatching' => 'Ta bort från Fortsätt titta',
'mediaMenu.viewDetails' => 'Visa detaljer',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Gå till serie',
'mediaMenu.shufflePlay' => 'Blanda uppspelning',
'mediaMenu.shuffleNotAvailableOffline' => 'Blandad uppspelning är inte tillgänglig offline',
'mediaMenu.fileInfo' => 'Filinformation',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Ta bort avsnitt från servern',
'mediaMenu.deleteSeasonFromServer' => 'Ta bort säsong från servern',
'mediaMenu.deleteShowFromServer' => 'Ta bort serie från servern',
@@ -3798,12 +3790,12 @@ extension on TranslationsSv {
'explore.rows.trendingShows' => 'Populära serier just nu',
'explore.rows.popularMovies' => 'Populära filmer',
'explore.rows.popularShows' => 'Populära serier',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Populär anime just nu',
'explore.rows.suggestedAnime' => 'Föreslagen anime',
'explore.rows.airingAnime' => 'Bästa anime som sänds nu',
'explore.rows.popularAnime' => 'Mest populära anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Trendar nu',
'explore.rows.upcomingMovies' => 'Kommande filmer',
'explore.rows.upcomingShows' => 'Kommande serier',
@@ -4312,12 +4304,12 @@ extension on TranslationsSv {
'companionRemote.pairing.validationHostFormat' => 'Formatet måste vara IP:port (t.ex. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Anslutningen tog för lång tid. Använd samma nätverk på båda enheterna.',
'companionRemote.pairing.sessionNotFound' => 'Enheten hittades inte. Kontrollera att Plezy körs på värden.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Autentiseringen misslyckades. Båda enheterna måste använda samma Plex-konto.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?',
'companionRemote.remote.reconnecting' => 'Återansluter...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Försök ${current} av 5',
'companionRemote.remote.retryNow' => 'Försök nu',
'companionRemote.remote.tabRemote' => 'Fjärrkontroll',
diff --git a/lib/i18n/strings_tr.g.dart b/lib/i18n/strings_tr.g.dart
index 856fc5383..58c417b4f 100644
--- a/lib/i18n/strings_tr.g.dart
+++ b/lib/i18n/strings_tr.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$tr extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Yerel DV7\'yi zorla ve DV dönüştürme yeniden denemesini engelle';
@override String get dvConversionDv81Description => 'Dolby Vision profile 8.1\'e satır içi RPU dönüştürmeyi zorla';
@override String get dvConversionHevcStripDescription => 'Dolby Vision RPU/EL katmanlarını soy ve düz HEVC olarak sun';
- @override String get demuxer => 'Kapsayıcı demux çözücü';
- @override String get demuxerDescription => 'Doğrudan oynatılan dosyaları hangi demux çözücünün ayrıştıracağı. Bir dosya sorun çıkarırsa media3\'e geçin.';
- @override String get demuxerFfmpeg => 'FFmpeg (Önerilen)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Uygulama açılışında profil sor';
@override String get requireProfileSelectionOnOpenDescription => 'Uygulama her açıldığında profil seçimini göster';
@override String get forceTvMode => 'TV modunu zorla';
@@ -3089,10 +3085,6 @@ extension on TranslationsTr {
'settings.dvConversionNativeDescription' => 'Yerel DV7\'yi zorla ve DV dönüştürme yeniden denemesini engelle',
'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1\'e satır içi RPU dönüştürmeyi zorla',
'settings.dvConversionHevcStripDescription' => 'Dolby Vision RPU/EL katmanlarını soy ve düz HEVC olarak sun',
- 'settings.demuxer' => 'Kapsayıcı demux çözücü',
- 'settings.demuxerDescription' => 'Doğrudan oynatılan dosyaları hangi demux çözücünün ayrıştıracağı. Bir dosya sorun çıkarırsa media3\'e geçin.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (Önerilen)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Uygulama açılışında profil sor',
'settings.requireProfileSelectionOnOpenDescription' => 'Uygulama her açıldığında profil seçimini göster',
'settings.forceTvMode' => 'TV modunu zorla',
@@ -3284,12 +3276,12 @@ extension on TranslationsTr {
'mediaMenu.markAsUnwatched' => 'İzlenmedi Olarak İşaretle',
'mediaMenu.removeFromContinueWatching' => 'İzlemeye Devam Et\'ten Kaldır',
'mediaMenu.viewDetails' => 'Detayları görüntüle',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Diziye git',
'mediaMenu.shufflePlay' => 'Karışık Oynat',
'mediaMenu.shuffleNotAvailableOffline' => 'Karışık oynatma çevrimdışıyken kullanılamaz',
'mediaMenu.fileInfo' => 'Dosya Bilgisi',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Bölümü sunucudan sil',
'mediaMenu.deleteSeasonFromServer' => 'Sezonu sunucudan sil',
'mediaMenu.deleteShowFromServer' => 'Diziyi sunucudan sil',
@@ -3798,12 +3790,12 @@ extension on TranslationsTr {
'explore.rows.trendingShows' => 'Trend Diziler',
'explore.rows.popularMovies' => 'Popüler Filmler',
'explore.rows.popularShows' => 'Popüler Diziler',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Trend Animeler',
'explore.rows.suggestedAnime' => 'Önerilen Animeler',
'explore.rows.airingAnime' => 'En İyi Yayınlanan Animeler',
'explore.rows.popularAnime' => 'En Popüler Animeler',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Trendler',
'explore.rows.upcomingMovies' => 'Gelecek Filmler',
'explore.rows.upcomingShows' => 'Gelecek Diziler',
@@ -4312,12 +4304,12 @@ extension on TranslationsTr {
'companionRemote.pairing.validationHostFormat' => 'Biçim IP:port şeklinde olmalıdır (ör. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Bağlantı zaman aşımına uğradı. Her iki cihazda da aynı ağı kullanın.',
'companionRemote.pairing.sessionNotFound' => 'Cihaz bulunamadı. Plezy\'nin ana bilgisayarda çalıştığından emin olun.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Kimlik doğrulama başarısız oldu. Her iki cihazın da aynı Plex hesabına ihtiyacı var.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Bağlantı başarısız: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Uzaktan kumanda oturumundan ayrılmak istiyor musunuz?',
'companionRemote.remote.reconnecting' => 'Yeniden bağlanılıyor...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Deneme ${current} / 5',
'companionRemote.remote.retryNow' => 'Şimdi Tekrar Dene',
'companionRemote.remote.tabRemote' => 'Kumanda',
diff --git a/lib/i18n/strings_uz.g.dart b/lib/i18n/strings_uz.g.dart
index 56f3fadbd..0189ca1ea 100644
--- a/lib/i18n/strings_uz.g.dart
+++ b/lib/i18n/strings_uz.g.dart
@@ -439,10 +439,6 @@ class _Translations$settings$uz extends Translations$settings$en {
@override String get dvConversionNativeDescription => 'Ichki DV7 rejimini majburlash';
@override String get dvConversionDv81Description => 'Dolby Vision profile 8.1 formatiga oʻtkazish';
@override String get dvConversionHevcStripDescription => 'Dolby Vision qatlamlarini olib tashlash va HEVC sifatida koʻrsatish';
- @override String get demuxer => 'Konteyner demukseri';
- @override String get demuxerDescription => 'Toʻgʻridan-toʻgʻri ijro etilgan fayllarni qaysi demukser tahlil qiladi. Fayl notoʻgʻri ishlasa, media3 ga oʻting.';
- @override String get demuxerFfmpeg => 'FFmpeg (tavsiya etiladi)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => 'Ochilganda profilni soʻrash';
@override String get requireProfileSelectionOnOpenDescription => 'Ilova ochilgan har safar profilni tanlashni koʻrsatish';
@override String get forceTvMode => 'TV rejimini majburlash';
@@ -3089,10 +3085,6 @@ extension on TranslationsUz {
'settings.dvConversionNativeDescription' => 'Ichki DV7 rejimini majburlash',
'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1 formatiga oʻtkazish',
'settings.dvConversionHevcStripDescription' => 'Dolby Vision qatlamlarini olib tashlash va HEVC sifatida koʻrsatish',
- 'settings.demuxer' => 'Konteyner demukseri',
- 'settings.demuxerDescription' => 'Toʻgʻridan-toʻgʻri ijro etilgan fayllarni qaysi demukser tahlil qiladi. Fayl notoʻgʻri ishlasa, media3 ga oʻting.',
- 'settings.demuxerFfmpeg' => 'FFmpeg (tavsiya etiladi)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => 'Ochilganda profilni soʻrash',
'settings.requireProfileSelectionOnOpenDescription' => 'Ilova ochilgan har safar profilni tanlashni koʻrsatish',
'settings.forceTvMode' => 'TV rejimini majburlash',
@@ -3284,12 +3276,12 @@ extension on TranslationsUz {
'mediaMenu.markAsUnwatched' => 'Koʻrilmagan deb belgilash',
'mediaMenu.removeFromContinueWatching' => '"Tomoshani davom ettirish"dan oʻchirish',
'mediaMenu.viewDetails' => 'Batafsil koʻrish',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => 'Serialga oʻtish',
'mediaMenu.shufflePlay' => 'Aralashtirib ijro etish',
'mediaMenu.shuffleNotAvailableOffline' => 'Aralashtirib ijro etish oflayn rejimda mavjud emas',
'mediaMenu.fileInfo' => 'Fayl haqida maʼlumot',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => 'Qismni serverdan oʻchirish',
'mediaMenu.deleteSeasonFromServer' => 'Mavsumni serverdan oʻchirish',
'mediaMenu.deleteShowFromServer' => 'Shouni serverdan oʻchirish',
@@ -3798,12 +3790,12 @@ extension on TranslationsUz {
'explore.rows.trendingShows' => 'Ommabop seriallar',
'explore.rows.popularMovies' => 'Mashhur filmlar',
'explore.rows.popularShows' => 'Mashhur seriallar',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => 'Ommabop anime',
'explore.rows.suggestedAnime' => 'Tavsiya etilgan anime',
'explore.rows.airingAnime' => 'Efirga uzatilayotgan eng yaxshi anime',
'explore.rows.popularAnime' => 'Eng mashhur anime',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => 'Ommaboplar',
'explore.rows.upcomingMovies' => 'Kutilayotgan filmlar',
'explore.rows.upcomingShows' => 'Kutilayotgan seriallar',
@@ -4312,12 +4304,12 @@ extension on TranslationsUz {
'companionRemote.pairing.validationHostFormat' => 'Format IP:port koʻrinishida boʻlishi kerak',
'companionRemote.pairing.connectionTimedOut' => 'Ulanish vaqti tugadi.',
'companionRemote.pairing.sessionNotFound' => 'Qurilma topilmadi.',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => 'Autentifikatsiya xatoligi.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Ulanib boʻlmadi: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Pult seansidan uzilasizmi?',
'companionRemote.remote.reconnecting' => 'Qayta ulanilmoqda...',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Urinish ${current} / 5',
'companionRemote.remote.retryNow' => 'Hozir qaytadan urinib koʻrish',
'companionRemote.remote.tabRemote' => 'Pult',
diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart
index 0036715bd..a90744d7c 100644
--- a/lib/i18n/strings_zh.g.dart
+++ b/lib/i18n/strings_zh.g.dart
@@ -439,10 +439,6 @@ class Translations$settings$zh extends Translations$settings$en {
@override String get dvConversionNativeDescription => '强制原生 DV7 并禁止重试 DV 转换';
@override String get dvConversionDv81Description => '强制内联 RPU 转换为 Dolby Vision Profile 8.1';
@override String get dvConversionHevcStripDescription => '移除 Dolby Vision RPU/EL 层并呈现普通 HEVC';
- @override String get demuxer => '容器解复用器';
- @override String get demuxerDescription => '由哪个解复用器解析直接播放的文件。若某个文件播放异常,可切换为 media3。';
- @override String get demuxerFfmpeg => 'FFmpeg(推荐)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => '打开应用时选择用户资料';
@override String get requireProfileSelectionOnOpenDescription => '每次打开应用时都显示用户资料选择界面';
@override String get forceTvMode => '强制 TV 模式';
@@ -3079,10 +3075,6 @@ extension on TranslationsZh {
'settings.dvConversionNativeDescription' => '强制原生 DV7 并禁止重试 DV 转换',
'settings.dvConversionDv81Description' => '强制内联 RPU 转换为 Dolby Vision Profile 8.1',
'settings.dvConversionHevcStripDescription' => '移除 Dolby Vision RPU/EL 层并呈现普通 HEVC',
- 'settings.demuxer' => '容器解复用器',
- 'settings.demuxerDescription' => '由哪个解复用器解析直接播放的文件。若某个文件播放异常,可切换为 media3。',
- 'settings.demuxerFfmpeg' => 'FFmpeg(推荐)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => '打开应用时选择用户资料',
'settings.requireProfileSelectionOnOpenDescription' => '每次打开应用时都显示用户资料选择界面',
'settings.forceTvMode' => '强制 TV 模式',
@@ -3274,12 +3266,12 @@ extension on TranslationsZh {
'mediaMenu.markAsUnwatched' => '标记为未观看',
'mediaMenu.removeFromContinueWatching' => '从继续观看中移除',
'mediaMenu.viewDetails' => '查看详情',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => '前往剧集',
'mediaMenu.shufflePlay' => '随机播放',
'mediaMenu.shuffleNotAvailableOffline' => '离线时无法随机播放',
'mediaMenu.fileInfo' => '文件信息',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => '从服务器删除单集',
'mediaMenu.deleteSeasonFromServer' => '从服务器删除季度',
'mediaMenu.deleteShowFromServer' => '从服务器删除剧集',
@@ -3788,12 +3780,12 @@ extension on TranslationsZh {
'explore.rows.trendingShows' => '近期热门剧集',
'explore.rows.popularMovies' => '人气电影',
'explore.rows.popularShows' => '人气剧集',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => '热门动画',
'explore.rows.suggestedAnime' => '推荐动画',
'explore.rows.airingAnime' => '热门连载动画',
'explore.rows.popularAnime' => '最受欢迎动画',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => '近期热门',
'explore.rows.upcomingMovies' => '即将上映的电影',
'explore.rows.upcomingShows' => '即将播出的剧集',
@@ -4302,12 +4294,12 @@ extension on TranslationsZh {
'companionRemote.pairing.validationHostFormat' => '格式必须为 IP:端口(例如 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => '连接超时。请在两台设备上使用同一网络。',
'companionRemote.pairing.sessionNotFound' => '未找到设备。请确认 Plezy 正在主机上运行。',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}',
'companionRemote.remote.disconnectConfirm' => '确定要断开遥控会话吗?',
'companionRemote.remote.reconnecting' => '正在重新连接…',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => '第 ${current} 次尝试,共 5 次',
'companionRemote.remote.retryNow' => '立即重试',
'companionRemote.remote.tabRemote' => '遥控器',
diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart
index 671abcd0e..b4952ef63 100644
--- a/lib/i18n/strings_zh_Hant.g.dart
+++ b/lib/i18n/strings_zh_Hant.g.dart
@@ -440,10 +440,6 @@ class _Translations$settings$zh_Hant extends Translations$settings$zh {
@override String get dvConversionNativeDescription => '強制使用原生 DV7 並停用 DV 轉換重試';
@override String get dvConversionDv81Description => '強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1';
@override String get dvConversionHevcStripDescription => '移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現';
- @override String get demuxer => '容器解多工器';
- @override String get demuxerDescription => '由哪個解多工器解析直接播放的檔案。若某個檔案播放異常,可切換為 media3。';
- @override String get demuxerFfmpeg => 'FFmpeg(建議)';
- @override String get demuxerMedia3 => 'media3';
@override String get requireProfileSelectionOnOpen => '開啟應用程式時要求選擇使用者';
@override String get requireProfileSelectionOnOpenDescription => '每次開啟應用程式時顯示使用者設定檔選擇畫面';
@override String get forceTvMode => '強制 TV 模式';
@@ -3080,10 +3076,6 @@ extension on TranslationsZhHant {
'settings.dvConversionNativeDescription' => '強制使用原生 DV7 並停用 DV 轉換重試',
'settings.dvConversionDv81Description' => '強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1',
'settings.dvConversionHevcStripDescription' => '移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現',
- 'settings.demuxer' => '容器解多工器',
- 'settings.demuxerDescription' => '由哪個解多工器解析直接播放的檔案。若某個檔案播放異常,可切換為 media3。',
- 'settings.demuxerFfmpeg' => 'FFmpeg(建議)',
- 'settings.demuxerMedia3' => 'media3',
'settings.requireProfileSelectionOnOpen' => '開啟應用程式時要求選擇使用者',
'settings.requireProfileSelectionOnOpenDescription' => '每次開啟應用程式時顯示使用者設定檔選擇畫面',
'settings.forceTvMode' => '強制 TV 模式',
@@ -3275,12 +3267,12 @@ extension on TranslationsZhHant {
'mediaMenu.markAsUnwatched' => '標記為未觀看',
'mediaMenu.removeFromContinueWatching' => '從「繼續觀看」中移除',
'mediaMenu.viewDetails' => '查看詳情',
- _ => null,
- } ?? switch (path) {
'mediaMenu.goToSeries' => '前往影集',
'mediaMenu.shufflePlay' => '隨機播放',
'mediaMenu.shuffleNotAvailableOffline' => '離線時無法隨機播放',
'mediaMenu.fileInfo' => '檔案資訊',
+ _ => null,
+ } ?? switch (path) {
'mediaMenu.deleteEpisodeFromServer' => '從伺服器刪除單集',
'mediaMenu.deleteSeasonFromServer' => '從伺服器刪除季',
'mediaMenu.deleteShowFromServer' => '從伺服器刪除影集',
@@ -3789,12 +3781,12 @@ extension on TranslationsZhHant {
'explore.rows.trendingShows' => '近期熱門影集',
'explore.rows.popularMovies' => '熱門電影',
'explore.rows.popularShows' => '熱門影集',
- _ => null,
- } ?? switch (path) {
'explore.rows.trendingAnime' => '近期熱門動畫',
'explore.rows.suggestedAnime' => '推薦動畫',
'explore.rows.airingAnime' => '熱門連載動畫',
'explore.rows.popularAnime' => '最受歡迎的動畫',
+ _ => null,
+ } ?? switch (path) {
'explore.rows.trending' => '趨勢',
'explore.rows.upcomingMovies' => '即將上映的電影',
'explore.rows.upcomingShows' => '即將播出的影集',
@@ -4303,12 +4295,12 @@ extension on TranslationsZhHant {
'companionRemote.pairing.validationHostFormat' => '格式必須為 IP 位址:連接埠(例如 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => '連線逾時。請確認兩台裝置都使用相同網路。',
'companionRemote.pairing.sessionNotFound' => '找不到裝置。請確認主機上已啟動 Plezy。',
- _ => null,
- } ?? switch (path) {
'companionRemote.pairing.authFailed' => '驗證失敗。兩台裝置需要登入相同的 Plex 帳戶。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '連線失敗:${error}',
'companionRemote.remote.disconnectConfirm' => '是否要中斷遠端連線工作階段?',
'companionRemote.remote.reconnecting' => '重新連線中…',
+ _ => null,
+ } ?? switch (path) {
'companionRemote.remote.attemptOf' => ({required Object current}) => '第 ${current} 次嘗試,共 5 次',
'companionRemote.remote.retryNow' => '立即重試',
'companionRemote.remote.tabRemote' => '遙控',
diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json
index a8c8beb0e..9037fc32f 100644
--- a/lib/i18n/sv.i18n.json
+++ b/lib/i18n/sv.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Tvinga inbyggd DV7 och förhindra nya försök med DV-konvertering",
"dvConversionDv81Description": "Tvinga direkt RPU-konvertering till Dolby Vision-profil 8.1",
"dvConversionHevcStripDescription": "Ta bort Dolby Visions RPU-/EL-lager och använd vanlig HEVC",
- "demuxer": "Container-demuxer",
- "demuxerDescription": "Vilken demuxer som läser direktuppspelade filer. Byt till media3 om en fil krånglar.",
- "demuxerFfmpeg": "FFmpeg (rekommenderas)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Fråga efter profil vid appstart",
"requireProfileSelectionOnOpenDescription": "Visa profilval varje gång appen öppnas",
"forceTvMode": "Tvinga TV-läge",
diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json
index ca8b3d077..5c013ae7a 100644
--- a/lib/i18n/tr.i18n.json
+++ b/lib/i18n/tr.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Yerel DV7'yi zorla ve DV dönüştürme yeniden denemesini engelle",
"dvConversionDv81Description": "Dolby Vision profile 8.1'e satır içi RPU dönüştürmeyi zorla",
"dvConversionHevcStripDescription": "Dolby Vision RPU/EL katmanlarını soy ve düz HEVC olarak sun",
- "demuxer": "Kapsayıcı demux çözücü",
- "demuxerDescription": "Doğrudan oynatılan dosyaları hangi demux çözücünün ayrıştıracağı. Bir dosya sorun çıkarırsa media3'e geçin.",
- "demuxerFfmpeg": "FFmpeg (Önerilen)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Uygulama açılışında profil sor",
"requireProfileSelectionOnOpenDescription": "Uygulama her açıldığında profil seçimini göster",
"forceTvMode": "TV modunu zorla",
diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json
index 28e598e16..cea32e660 100644
--- a/lib/i18n/uz.i18n.json
+++ b/lib/i18n/uz.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "Ichki DV7 rejimini majburlash",
"dvConversionDv81Description": "Dolby Vision profile 8.1 formatiga oʻtkazish",
"dvConversionHevcStripDescription": "Dolby Vision qatlamlarini olib tashlash va HEVC sifatida koʻrsatish",
- "demuxer": "Konteyner demukseri",
- "demuxerDescription": "Toʻgʻridan-toʻgʻri ijro etilgan fayllarni qaysi demukser tahlil qiladi. Fayl notoʻgʻri ishlasa, media3 ga oʻting.",
- "demuxerFfmpeg": "FFmpeg (tavsiya etiladi)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "Ochilganda profilni soʻrash",
"requireProfileSelectionOnOpenDescription": "Ilova ochilgan har safar profilni tanlashni koʻrsatish",
"forceTvMode": "TV rejimini majburlash",
diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json
index cdbd41ae6..0729a740b 100644
--- a/lib/i18n/zh-Hant.i18n.json
+++ b/lib/i18n/zh-Hant.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "強制使用原生 DV7 並停用 DV 轉換重試",
"dvConversionDv81Description": "強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1",
"dvConversionHevcStripDescription": "移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現",
- "demuxer": "容器解多工器",
- "demuxerDescription": "由哪個解多工器解析直接播放的檔案。若某個檔案播放異常,可切換為 media3。",
- "demuxerFfmpeg": "FFmpeg(建議)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "開啟應用程式時要求選擇使用者",
"requireProfileSelectionOnOpenDescription": "每次開啟應用程式時顯示使用者設定檔選擇畫面",
"forceTvMode": "強制 TV 模式",
diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json
index 976c4e784..9487d2d78 100644
--- a/lib/i18n/zh.i18n.json
+++ b/lib/i18n/zh.i18n.json
@@ -332,10 +332,6 @@
"dvConversionNativeDescription": "强制原生 DV7 并禁止重试 DV 转换",
"dvConversionDv81Description": "强制内联 RPU 转换为 Dolby Vision Profile 8.1",
"dvConversionHevcStripDescription": "移除 Dolby Vision RPU/EL 层并呈现普通 HEVC",
- "demuxer": "容器解复用器",
- "demuxerDescription": "由哪个解复用器解析直接播放的文件。若某个文件播放异常,可切换为 media3。",
- "demuxerFfmpeg": "FFmpeg(推荐)",
- "demuxerMedia3": "media3",
"requireProfileSelectionOnOpen": "打开应用时选择用户资料",
"requireProfileSelectionOnOpenDescription": "每次打开应用时都显示用户资料选择界面",
"forceTvMode": "强制 TV 模式",
diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart
index 2655c05fb..e5671b1b5 100644
--- a/lib/mpv/player/platform/player_android.dart
+++ b/lib/mpv/player/platform/player_android.dart
@@ -14,7 +14,6 @@ class PlayerAndroid extends PlayerBase {
String _bufferTier = 'auto';
bool _tunnelingEnabled = false;
String _dvConversionMode = 'auto';
- String _demuxerMode = 'ffmpeg';
bool _audioNormalizationEnabled = false;
bool _audioPassthroughEnabled = false;
bool _downmixEnabled = false;
@@ -116,7 +115,6 @@ class PlayerAndroid extends PlayerBase {
'bufferTier': _bufferTier,
'tunnelingEnabled': _tunnelingEnabled,
'dvConversionMode': _dvConversionMode,
- 'demuxerMode': _demuxerMode,
'audioPassthroughEnabled': _audioPassthroughEnabled,
// Cheap (32-bit) TV boxes run the hardware video path a frame behind a GL
// subtitle overlay; render the ASS one frame earlier there to realign.
@@ -345,10 +343,6 @@ class PlayerAndroid extends PlayerBase {
() => _dvConversionMode == value,
);
break;
- case 'demuxer-mode':
- _demuxerMode = value;
- await _applyWhenInitialized(() => invoke('setDemuxerMode', {'mode': value}), () => _demuxerMode == value);
- break;
case 'sub-visibility':
if (value == 'no') {
if (_subtitlesHidden) break;
diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart
index 72368147c..24e957669 100644
--- a/lib/screens/settings/playback_settings_screen.dart
+++ b/lib/screens/settings/playback_settings_screen.dart
@@ -89,7 +89,6 @@ class _PlaybackSettingsScreenState extends State {
_audioDownmixTile(),
if (downmixOn) _downmixCenterBoostTile(),
if (downmixOn) _downmixNormalizeTile(),
- if (exoActive) _demuxerModeTile(),
if (Platform.isAndroid) _dvConversionModeTile(),
if (exoActive) _playbackBufferTile(),
_defaultQualityTile(),
@@ -475,19 +474,6 @@ class _PlaybackSettingsScreenState extends State {
DvConversionModePreference.hevcStrip => t.settings.dvConversionHevcStrip,
};
- Widget _demuxerModeTile() => SettingSelectionTile(
- pref: SettingsService.demuxerMode,
- icon: Symbols.schema_rounded,
- title: t.settings.demuxer,
- subtitleBuilder: (mode) => '${_demuxerModeLabel(mode)} · ${t.settings.demuxerDescription}',
- options: DemuxerPreference.values.map((m) => DialogOption(value: m, title: _demuxerModeLabel(m))).toList(),
- );
-
- String _demuxerModeLabel(DemuxerPreference mode) => switch (mode) {
- DemuxerPreference.ffmpeg => t.settings.demuxerFfmpeg,
- DemuxerPreference.media3 => t.settings.demuxerMedia3,
- };
-
Widget _playbackBufferTile() => SettingSelectionTile(
pref: SettingsService.playbackBufferTier,
icon: Symbols.hourglass_top_rounded,
diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart
index 35fdd288e..94f7600d9 100644
--- a/lib/screens/video_player_screen.dart
+++ b/lib/screens/video_player_screen.dart
@@ -1319,7 +1319,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin
final tunneledPlayback = settingsService.read(SettingsService.tunneledPlayback);
await currentPlayer.setProperty('tunneled-playback', tunneledPlayback ? 'yes' : 'no');
await currentPlayer.setProperty('exo-buffer-tier', playbackBufferTier.nativeValue);
- await currentPlayer.setProperty('demuxer-mode', settingsService.read(SettingsService.demuxerMode).nativeValue);
}
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
final dvConversionMode = settingsService.read(SettingsService.dvConversionMode);
diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart
index d66e9c8f3..0bd1d968a 100644
--- a/lib/services/settings_service.dart
+++ b/lib/services/settings_service.dart
@@ -113,17 +113,6 @@ extension DvConversionModePreferenceNativeValue on DvConversionModePreference {
};
}
-/// Which demuxer parses direct-played files on Android. Wire values are read
-/// natively by `FfmpegDemuxerPolicy`; unknown values resolve to FFmpeg there.
-enum DemuxerPreference { ffmpeg, media3 }
-
-extension DemuxerPreferenceNativeValue on DemuxerPreference {
- String get nativeValue => switch (this) {
- DemuxerPreference.ffmpeg => 'ffmpeg',
- DemuxerPreference.media3 => 'media3',
- };
-}
-
enum PlaybackBufferTier { auto, large, extraLarge }
extension PlaybackBufferTierNativeValue on PlaybackBufferTier {
@@ -136,6 +125,7 @@ extension PlaybackBufferTierNativeValue on PlaybackBufferTier {
const String _bufferSizeMigratedKey = 'buffer_size_migrated_to_auto';
const String _legacyBufferSizeKey = 'buffer_size';
+const String _legacyDemuxerModeKey = 'demuxer_mode';
const String _legacyUseSeasonPosterKey = 'use_season_poster';
const String _legacyMpvConfigEntriesKey = 'mpv_config_entries';
@@ -493,11 +483,6 @@ class SettingsService extends BaseSharedPreferencesService {
values: DvConversionModePreference.values,
defaultValue: DvConversionModePreference.auto,
);
- static const demuxerMode = EnumPref(
- 'demuxer_mode',
- values: DemuxerPreference.values,
- defaultValue: DemuxerPreference.ffmpeg,
- );
static const defaultQualityPreset = EnumPref(
'default_quality_preset',
values: TranscodeQualityPreset.values,
@@ -1060,7 +1045,6 @@ class SettingsService extends BaseSharedPreferencesService {
specialsOrdering,
useExoPlayer,
startupSection,
- demuxerMode,
showExploreTab,
alwaysKeepSidebarOpen,
librariesSectionExpanded,
@@ -1171,6 +1155,7 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_legacyUseSeasonPosterKey),
prefs.remove(_legacyMpvConfigEntriesKey),
prefs.remove(_legacyBufferSizeKey),
+ prefs.remove(_legacyDemuxerModeKey),
prefs.remove(_bufferSizeMigratedKey),
]);
refreshListenables();
diff --git a/scripts/maestro/run_maestro_ci.py b/scripts/maestro/run_maestro_ci.py
index b63e5ba04..6c77a2bc3 100755
--- a/scripts/maestro/run_maestro_ci.py
+++ b/scripts/maestro/run_maestro_ci.py
@@ -17,12 +17,7 @@ ANDROID_15_INSTRUMENTATION_CLASSES = (
ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation"
# Separate R8 reachability from instrumentation: these suites exercise APIs the app
# does not call directly, so shrinking them is expected. Covers name-based reachability (#1703).
-ANDROID_R8_REACHABILITY_CLASSES = ",".join(
- (
- "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest",
- "com.edde746.plezy.exoplayer.FfmpegDemuxerReachabilityTest",
- )
-)
+ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest"
ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability"
diff --git a/test/widgets/performance_stats_service_test.dart b/test/widgets/performance_stats_service_test.dart
index 84784096e..b13033ac0 100644
--- a/test/widgets/performance_stats_service_test.dart
+++ b/test/widgets/performance_stats_service_test.dart
@@ -136,8 +136,8 @@ void main() {
group('PerformanceStatsService ExoPlayer codec display (#2063)', () {
test('falls back to the sample MIME type when the container has no codecs string', () async {
- // Matroska via the FFmpeg demuxer: Format.codecs is null; only the
- // MIME types identify the streams.
+ // Matroska: Format.codecs is null; only the MIME types identify the
+ // streams.
final stats = await _firstStats(
_NativeStatsPlayer({
'playerType': 'exoplayer',