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',