diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5a0650d99..a0916f28e 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", "avutil", "swresample") + val libraries = listOf("avcodec", "avformat", "avutil", "swresample") inputs.file(aar) inputs.property("ffmpegVersion", mpvFfmpegVersion) inputs.property("sourceUrl", mpvFfmpegSourceUrl) @@ -145,6 +145,7 @@ 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 ) @@ -182,7 +183,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") } catch (error: Exception) { throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error) } - listOf("libavcodec", "libavutil", "libswresample").forEach { library -> + listOf("libavcodec", "libavformat", "libavutil", "libswresample").forEach { library -> project.copy { from(File(extractedSource, library)) { include("*.h") @@ -206,6 +207,7 @@ 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 fae3c6177..fe1f0eb7d 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -19,19 +19,12 @@ # growOutputBuffer's JNI descriptor names this type, so it may not be renamed either. -keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; } -# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live -# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor -# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput -# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and -# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves -# extractorOutput the same way to wrap LATM tracks. -# -# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves -# them in its companion object, so the throw surfaces as ExceptionInInitializerError -# while constructing the extractor — every MKV direct-play, release builds only. Only -# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive -# shrinking through the compile-time subclass references. --keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor { - private androidx.media3.extractor.ExtractorOutput extractorOutput; - private androidx.media3.common.util.ParsableByteArray subtitleSample; +# ffmpeg_demuxer_jni.cc resolves the AVIO input proxy's callbacks by name +# (FindClass on the interface, GetMethodID for position/read/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); + long length(); } 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 deleted file mode 100644 index 070ad98be..000000000 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.net.Uri -import android.os.Handler -import android.os.HandlerThread -import android.util.Log -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.ProgressiveMediaSource -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.mkv.MatroskaExtractor -import androidx.media3.extractor.text.DefaultSubtitleParserFactory -import androidx.test.platform.app.InstrumentationRegistry -import com.edde746.plezy.libass.media.AssHandler -import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory -import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969). - * - * media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files - * is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer - * coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The - * first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it - * fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can - * be retired. The second test drives the production wrapper stack and requires the seek to - * actually land. - */ -class MatroskaLateTracksSeekDeviceTest { - private companion object { - const val TAG = "MkvLateTracksSeek" - const val SEEK_TARGET_MS = 500L - const val SETTLE_MS = 2_000L - } - - private class Session( - val handler: Handler, - val thread: HandlerThread, - val player: AtomicReference, - 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 a9ef5873a..7c1d96008 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -19,20 +19,22 @@ else() target_link_libraries(dovi_bridge log) endif() -foreach(ffmpeg_library avcodec avutil swresample) +foreach(ffmpeg_library avcodec avformat 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_decoder/ffmpeg_jni.cc + media3_ffmpeg_demuxer/ffmpeg_demuxer_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 new file mode 100644 index 000000000..7faa9ec6c --- /dev/null +++ b/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc @@ -0,0 +1,1169 @@ +/* + * 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 hard seam is 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 at the requested position. libavformat expects a random-access + * AVIO layer and seeks whenever it pleases (MP4 moov at end of file, AVI idx1, + * MKV Cues, avformat_find_stream_info read-ahead). The bridge reconciles the + * two with a deferral protocol: + * + * - The read callback only serves when the AVIO logical position equals the + * ExtractorInput position. Any divergence aborts the in-flight libavformat + * call with AVERROR_NEED_SEEK and records the wanted position. + * - Kotlin maps that to RESULT_SEEK (or, when the loader already moved the + * input to exactly that position, to a local resume). After the input is in + * place, resumeAfterSeek() runs avio_seek(), whose success invalidates the + * stale AVIO buffers, and the interrupted operation is retried. + * - During avformat_open_input/avformat_find_stream_info a block cache serves + * header re-reads from memory so each distinct seek position costs at most + * one loader round trip even though open restarts from zero. + */ +#include +#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 +} + +#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 { + +// Returned by the AVIO callbacks to unwind into Java when the ExtractorInput +// cannot serve the requested position synchronously. FFERRTAG values are +// distinct from every real AVERROR code. +const int AVERROR_NEED_SEEK = FFERRTAG('P', 'L', 'Z', 'S'); + +// readPacket() result codes written to out[0]. Negative values are raw +// AVERROR codes; ERR_JAVA signals the ExtractorInput threw and Kotlin must +// rethrow the stored IOException. +const jint CODE_PACKET = 0; +const jint CODE_EOF = 1; +const jint CODE_NEED_SEEK = 2; +const jint CODE_GROW = 3; +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 int64_t kNoPendingSeek = -1; +constexpr int64_t kNoPendingTimeSeek = INT64_MIN; +constexpr size_t kMaxCacheBytes = 24u * 1024 * 1024; +constexpr jint kReadChunkBytes = 64 * 1024; + +struct CacheBlock { + int64_t pos; + std::vector data; +}; + +struct DemuxState { + JavaVM* vm = nullptr; + jobject input = nullptr; + jmethodID midPosition = nullptr; + jmethodID midRead = nullptr; + jmethodID midLength = nullptr; + jbyteArray readBuffer = nullptr; + + AVFormatContext* format = nullptr; + AVIOContext* avio = nullptr; + bool opened = false; + + int64_t logicalPos = 0; + int64_t pendingSeek = kNoPendingSeek; + // Presentation-time seek requested via Extractor.seek(); executed by the + // next nativeReadPacket, where the input proxy is bound and deferrals can + // round-trip through the loader. + int64_t pendingTimeSeekUs = kNoPendingTimeSeek; + // Loader round trips consumed by the pending time seek; a seek whose + // avformat_seek_file keeps deferring past this budget is abandoned so + // playback continues instead of ping-ponging forever. + int seekAttempts = 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; + + bool cacheActive = true; + std::deque cache; + size_t cacheBytes = 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; +} + +void cacheClear(DemuxState* s) { + s->cache.clear(); + s->cacheBytes = 0; +} + +void cacheAppend(DemuxState* s, int64_t pos, const uint8_t* data, size_t size) { + if (!s->cacheActive) return; + s->cache.push_back(CacheBlock{pos, std::vector(data, data + size)}); + s->cacheBytes += size; + while (s->cacheBytes > kMaxCacheBytes && !s->cache.empty()) { + s->cacheBytes -= s->cache.front().data.size(); + s->cache.pop_front(); + } +} + +const CacheBlock* cacheFind(const DemuxState* s, int64_t pos) { + for (const CacheBlock& block : s->cache) { + if (pos >= block.pos && pos < block.pos + static_cast(block.data.size())) { + return █ + } + } + return nullptr; +} + +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; + + // Cache-first: bytes already read are served at any logical position, + // which lets restarted header phases (probe → hdrl → idx1 at end of file + // → back to movi) replay through seeks that never touch the loader. + const CacheBlock* block = cacheFind(s, s->logicalPos); + if (block != nullptr) { + size_t offset = static_cast(s->logicalPos - block->pos); + size_t n = std::min(bufSize, block->data.size() - offset); + std::memcpy(buf, block->data.data() + offset, n); + s->logicalPos += static_cast(n); + return static_cast(n); + } + + // A read at or beyond the known end of file is EOF regardless of where the + // loader sits. EOF yields no bytes, so it can never enter the block cache; + // deferring it would let an open that alternates between low offsets and an + // end-of-file probe (avi idx1/ODML index scans) ping-pong against the + // loader forever, one round trip per attempt. + int64_t knownLength = callLength(env, s); + if (javaPending(env)) { + javaClear(env); + s->javaError = true; + return AVERROR(EIO); + } + if (knownLength >= 0 && s->logicalPos >= knownLength) return AVERROR_EOF; + + // Uncached read: the input must be exactly where libavformat thinks it is. + // Any divergence aborts the in-flight call so Kotlin reconciles through + // avio_seek, which also invalidates the stale AVIO buffers. + if (s->logicalPos != callPosition(env, s)) { + if (javaPending(env)) { + javaClear(env); + s->javaError = true; + return AVERROR(EIO); + } + LOGW( + "read defer: logical=%lld input=%lld active=%d cacheBlocks=%zu cacheBytes=%zu first=%lld last=%lld", + (long long)s->logicalPos, (long long)callPosition(env, s), s->cacheActive ? 1 : 0, s->cache.size(), + s->cacheBytes, s->cache.empty() ? -1 : (long long)s->cache.front().pos, + s->cache.empty() ? -1 : (long long)s->cache.back().pos); + s->pendingSeek = s->logicalPos; + return AVERROR_NEED_SEEK; + } + + jint n = callRead(env, s, bufSize < kReadChunkBytes ? bufSize : kReadChunkBytes); + if (s->javaError) return AVERROR(EIO); + if (n <= 0) return AVERROR_EOF; + + env->GetByteArrayRegion(s->readBuffer, 0, n, reinterpret_cast(buf)); + cacheAppend(s, s->logicalPos, buf, static_cast(n)); + 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; + } + + // Seeks are pure bookkeeping and never touch the loader. Restarted header + // replays hop through cached regions for free; a seek into an uncached + // region surfaces as a deferral on the next uncached read, with the input + // still where the loader left it. avio invalidates its buffers after this + // successful callback. + // + // Deliberately does NOT touch s->pendingSeek: an unconsumed deferral target + // must survive the avformat unwind until Kotlin consumes it. + s->logicalPos = target; + // A deferral leaves AVIOContext::error sticky; once a seek has been adopted + // it must not replay into later reads. + if (s->avio != nullptr) s->avio->error = 0; + return target; +} + +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); + } + if (s->avio != nullptr) { + // 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. + av_freep(&s->avio->buffer); + avio_context_free(&s->avio); + } + if (env != nullptr && s->input != nullptr) { + if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer); + env->DeleteGlobalRef(s->input); + } + delete s; + gState = nullptr; +} + +bool deferred(DemuxState* s, int err) { return err == AVERROR_NEED_SEEK || s->pendingSeek != kNoPendingSeek; } + +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, CODE_NEED_SEEK when a loader round trip is required, +// or a negative AVERROR. The stream count is NOT the return value: positive +// counts share the result-code namespace (CODE_NEED_SEEK == 2 would collide +// with every two-stream container), 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; + if (s->avio != nullptr) s->avio->error = 0; + + // 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 midLength = env->GetMethodID(inputClass, "length", "()J"); + env->DeleteLocalRef(inputClass); + if (midPosition == nullptr || midRead == 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->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); + } + + if (s->avio == nullptr) { + constexpr int kAvioBufferSize = 64 * 1024; + unsigned char* avioBuffer = static_cast(av_malloc(kAvioBufferSize)); + s->avio = avio_alloc_context( + avioBuffer, kAvioBufferSize, /*write_flag=*/0, s, avioReadPacket, + /*write_packet=*/nullptr, avioSeek); + if (s->avio == nullptr) return AVERROR(ENOMEM); + } + + s->javaError = false; + s->pendingSeek = kNoPendingSeek; + s->pendingTimeSeekUs = kNoPendingTimeSeek; + s->seekAttempts = 0; + s->packetPending = false; + // Every open attempt restarts from scratch and replays the header phase + // through the block cache. Resuming avformat_find_stream_info across + // synthetic IO aborts is not something libavformat contracts to survive + // (observed as a NULL deref inside avformat_find_stream_info), and with the + // cache a full replay costs nothing on the wire. + // + // The cache must survive retries within one open: matroska's header phase + // jumps backward twice (SeekHead -> Cues -> clusters), and clearing here — + // per attempt — makes every retry replay against an empty cache and + // ping-pong against the loader until the reconcile cap kills the open. + // Invalidation is per media item and lives in nativeResetCache, called + // from FfmpegExtractor.init when media3 binds a new source. + avformat_close_input(&s->format); + s->opened = false; + s->cacheActive = true; + + int64_t seekResult = avio_seek(s->avio, 0, SEEK_SET); + if (s->pendingSeek != kNoPendingSeek) { + return CODE_NEED_SEEK; + } + if (seekResult < 0) { + LOGW("open seek0 failed=%lld sticky=%d", (long long)seekResult, s->avio != nullptr ? s->avio->error : 0); + return static_cast(seekResult); + } + + 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 (deferred(s, err)) { + LOGW("open_input defer pending=%lld err=%d", (long long)s->pendingSeek, err); + return CODE_NEED_SEEK; + } + if (err < 0) { + LOGW("avformat_open_input failed: %d", err); + return err; + } + + err = avformat_find_stream_info(s->format, nullptr); + if (deferred(s, err)) { + LOGW("find_info defer pending=%lld err=%d", (long long)s->pendingSeek, err); + return CODE_NEED_SEEK; + } + if (err < 0) { + LOGW("avformat_find_stream_info failed: %d", err); + return err; + } + s->opened = true; + // 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; + + // Header work is done: packet reads are sequential and every backward move + // goes through a real loader seek. + s->cacheActive = false; + cacheClear(s); + + 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 void JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeResetCache(JNIEnv* env, jobject thiz) { + StateLock lock; + // Per-item boundary: media3 reuses extractor instances across sources, and + // a new item's header occupies the same low offsets as the previous item's. + // Serving those stale bytes would corrupt the new open, so drop everything; + // nativeOpen re-arms the active flag for its retry phase. + DemuxState* s = gState; + if (s == nullptr) return; + cacheClear(s); + s->cacheActive = true; +} + +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 record (dvcC/dvvC), surfaced by libavformat as + // stream side data. Kotlin turns this into a dvh1.* codecs string so the + // DV conversion pipeline engages exactly as it does for Matroska. + 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 >= 4) { + info[INFO_DOVI_PROFILE] = (sd->data[2] >> 1) & 0x7F; // 7-bit dv_profile + info[INFO_DOVI_LEVEL] = ((sd->data[2] & 0x01) << 5) | ((sd->data[3] >> 3) & 0x1F); + 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). +// Deferral targets travel through nativeConsumePendingSeek, not this array. +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; + s->pendingSeek = kNoPendingSeek; + + int stickyRecovers = 0; + while (true) { + // A presentation-time seek from Extractor.seek() runs here, where the + // input proxy is bound and a deferral can round-trip through the loader. + // The demuxer picks the byte position and resets its own parse state — + // jumping the AVIO position under it would leave demuxer-private state + // (avi->remaining, PES continuations) pointing into the old stream. + // avformat_seek_file restarts from scratch on every retry; the block + // cache is re-armed so index/probe reads replayed across loader round + // trips (matroska Cues, mpegps binary search) stay free on the wire. + if (s->pendingTimeSeekUs != kNoPendingTimeSeek) { + if (++s->seekAttempts > 64) { + // Non-converging seek (each attempt = one loader round trip): + // abandon it and keep delivering from the current position rather + // than ping-ponging forever. + LOGW( + "time seek to %lld abandoned after %d loader round trips", (long long)s->pendingTimeSeekUs, + s->seekAttempts - 1); + s->pendingTimeSeekUs = kNoPendingTimeSeek; + s->cacheActive = false; + cacheClear(s); + } else { + if (s->bsf != nullptr) { + for (unsigned i = 0; i < s->nbStreams; i++) { + if (s->bsf[i] != nullptr) av_bsf_flush(s->bsf[i]); + } + } + s->packetPending = false; + s->cacheActive = true; + int64_t ts = s->pendingTimeSeekUs + s->startTimeUs; + int err = avformat_seek_file(s->format, -1, INT64_MIN, ts, ts, 0); + LOGD( + "time seek to %lld: err=%d logical=%lld attempts=%d", (long long)ts, err, (long long)s->logicalPos, + s->seekAttempts); + if (deferred(s, err)) { + if (s->pendingSeek == kNoPendingSeek) { + if (++stickyRecovers > 16 || s->avio == nullptr) return ERR_JAVA; + s->avio->error = 0; + continue; + } + // pendingTimeSeekUs stays set: the retry after the loader round + // trip re-runs the whole seek. + result[0] = CODE_NEED_SEEK; + env->SetLongArrayRegion(out, 0, 7, result); + return CODE_NEED_SEEK; + } + if (s->javaError) { + result[0] = ERR_JAVA; + env->SetLongArrayRegion(out, 0, 7, result); + return ERR_JAVA; + } + s->pendingTimeSeekUs = kNoPendingTimeSeek; + if (err < 0) { + // Unseekable stream or demuxer refusal: keep playing from the + // current position instead of killing the session; media3 will + // decode-discard toward the target if it can. + LOGW("avformat_seek_file to %lld failed: %d", (long long)ts, err); + } else { + // Align the loader with the demuxer's chosen byte position BEFORE + // the first read. avformat_seek_file resolves in memory (cues) and + // typically leaves the AVIO position behind the loader's SeekMap + // guess; letting av_read_frame discover that and unwind with a + // synthetic IO error poisons matroskadec into resync, which skips + // the keyframe cluster — the video renderer then starves until the + // next keyframe while audio keeps advancing, and the resume-stall + // watchdog seeks in a loop. The cache stays armed across this round + // trip (until the first packet delivers) so replayed header bytes + // stay free. + int64_t inputPos = callPosition(env, s); + if (javaPending(env)) { + javaClear(env); + s->javaError = true; + result[0] = ERR_JAVA; + env->SetLongArrayRegion(out, 0, 7, result); + return ERR_JAVA; + } + if (inputPos >= 0 && inputPos != s->logicalPos) { + s->pendingSeek = s->logicalPos; + result[0] = CODE_NEED_SEEK; + env->SetLongArrayRegion(out, 0, 7, result); + return CODE_NEED_SEEK; + } + } + } + } + + 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 (deferred(s, err)) { + if (s->pendingSeek == kNoPendingSeek) { + // Sticky AVIO error replayed from a deferral Kotlin already + // reconciled: clear it and retry instead of surfacing a targetless + // seek. + if (++stickyRecovers > 16 || s->avio == nullptr) return ERR_JAVA; + s->avio->error = 0; + continue; + } + result[0] = CODE_NEED_SEEK; + env->SetLongArrayRegion(out, 0, 7, result); + return CODE_NEED_SEEK; + } + 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; + + // Post-seek convergence is over once a packet flows; steady-state packet + // reads are sequential, so drop the replay cache until the next seek. + if (s->cacheActive) { + s->cacheActive = false; + cacheClear(s); + } + + 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_nativeConsumePendingSeek(JNIEnv* env, jobject thiz) { + StateLock lock; + if (gState == nullptr) return -9223372036854775807L - 1; // Long.MIN_VALUE + int64_t pending = gState->pendingSeek; + gState->pendingSeek = kNoPendingSeek; + return pending; +} + +// Re-establishes the AVIO position after the loader moved the input to +// `position`. Must only be called when the ExtractorInput is already there. +JNIEXPORT jint JNICALL +Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeResumeAfterSeek(JNIEnv* env, jobject thiz, jlong position) { + StateLock lock; + DemuxState* s = gState; + if (s == nullptr || s->avio == nullptr) return ERR_NOT_OPEN; + s->pendingSeek = kNoPendingSeek; + s->javaError = false; + int64_t result = avio_seek(s->avio, position, SEEK_SET); + if (result >= 0) return 0; + if (s->pendingSeek != kNoPendingSeek) return CODE_NEED_SEEK; + return static_cast(result); +} + +// Records a presentation-time seek (media3 timeline microseconds, i.e. +// start-time normalized). Executed by the next nativeReadPacket, which owns +// a bound input proxy and can defer through the loader; running +// avformat_seek_file here would read against a dead input. +JNIEXPORT void JNICALL +Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeSeekTo(JNIEnv* env, jobject thiz, jlong timeUs) { + StateLock lock; + DemuxState* s = gState; + if (s == nullptr || !s->opened) return; + s->pendingTimeSeekUs = timeUs; + s->seekAttempts = 0; + // Any packet parked for a CODE_GROW retry belongs to the pre-seek stream. + s->packetPending = false; +} + +} // 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 deleted file mode 100644 index bc9bfbc09..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt +++ /dev/null @@ -1,226 +0,0 @@ -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/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 969c89e8a..a53285e48 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,6 +64,7 @@ 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 @@ -71,6 +72,7 @@ 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 @@ -229,6 +231,12 @@ class ExoPlayerCore(private val activity: Activity) : private var trackSelector: DefaultTrackSelector? = null private var tunnelingUserEnabled: Boolean = true private var tunnelingDisabledForAudioCodec: Boolean = false + + // Tunnelled playback is driven by the audio codec's clock, so without a + // hardware audio decoder media3 never tunnels regardless of the flag — + // used to skip selector churn for no-op flips (see + // updateCurrentTunnelingState). True until an evaluation says otherwise. + private var selectedAudioHasHwDecoder: Boolean = true private var tunnelingDisabledForVideoCodec: Boolean = false private var tunnelingDisabledForDecodedPcm: Boolean = false private var tunnelingDisabledForAudioRecovery: Boolean = false @@ -555,6 +563,14 @@ class ExoPlayerCore(private val activity: Activity) : @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null + // Container-demuxer placement; read per media item when extractors are built. + @Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.AUTO + + 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 @@ -572,7 +588,10 @@ 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" + bufferTier: String = "auto", + // Which containers the FFmpeg demuxer takes ahead of media3's extractors. + // Wire values from [FfmpegDemuxerPolicy.Preference]; unrecognised resolves to auto. + demuxerMode: String = "auto" ): Boolean { if (isInitialized) { Log.d(TAG, "Already initialized") @@ -581,6 +600,7 @@ class ExoPlayerCore(private val activity: Activity) : tunnelingUserEnabled = tunnelingEnabled this.audioPassthroughEnabled = audioPassthroughEnabled + demuxerPreference = FfmpegDemuxerPolicy.fromWire(demuxerMode) this.dvMode = getConfiguredDvMode() DoviBridge.logSupportSummary(activity) Log.i( @@ -726,6 +746,11 @@ class ExoPlayerCore(private val activity: Activity) : // 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() @@ -735,39 +760,72 @@ class ExoPlayerCore(private val activity: Activity) : // composition object and blanks palette-only fade updates (#1953). val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler)) - // Wrap extractors: replace MatroskaExtractor with ASS+DV variant, - // wrap MP4 extractors with DV converter when enabled. - // Reads this.dvMode each time (not captured) so DV7→8.1 retry can - // change mode and reload without reinitializing the player. + // 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 = 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 -> + val currentDemuxerPreference = this.demuxerPreference + Log.i(TAG, "[init] extractors: demuxer=${currentDemuxerPreference.wireName}") + // media3 invokes the factory once per player session and reuses the + // extractor instances for every item, so both roles are always built + // and each sniff reads the live preference instead of a captured one. + val liveDemuxerPreference = { this@ExoPlayerCore.demuxerPreference } + val ffmpegPrimary = + listOfNotNull( + FfmpegExtractor.create( + FfmpegExtractor.Role.PRIMARY, + liveDemuxerPreference, + currentDvMode, + subtitleParserFactory, + handler + ) + ) + val ffmpegCatchAll = + listOfNotNull( + FfmpegExtractor.create( + FfmpegExtractor.Role.CATCH_ALL, + liveDemuxerPreference, + currentDvMode, + subtitleParserFactory, + handler + ) + ) + ( + ffmpegPrimary + 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 { - activeDoviMkvWrapper = it + activeDoviMp4Wrapper = it } - } else { - assExtractor } - // Wrap with approximate seeking for MKV files without Cues - CuelessSeekExtractorWrapper(inner) + else -> extractor } - doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { - DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message -> - emitLog(level, prefix, message) - }.also { - activeDoviMp4Wrapper = it - } - } - else -> extractor - } - }.toTypedArray() + } + ffmpegCatchAll + ).toTypedArray() } val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory) @@ -2699,6 +2757,18 @@ class ExoPlayerCore(private val activity: Activity) : private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean { if (shouldTunnel == currentTunneledPlayback) return false + // A switch to "off" that the selected audio decoder could never have + // honored anyway is transparent to media3: tunnelled playback needs a + // tunneling-capable audio codec (it owns the AV-sync clock), so a + // software decoder already ignored the flag. Writing the selector + // parameter regardless forces a renderer rebuild that can tear down a + // live codec mid-queueInputBuffer — observed as + // "queueInputBuffer ... Released state" right after tracks arrive from + // the ffmpeg demuxer. Record the state without the churn. + if (!shouldTunnel && !selectedAudioHasHwDecoder) { + currentTunneledPlayback = false + return false + } currentTunneledPlayback = shouldTunnel val speed = exoPlayer?.playbackParameters?.speed ?: 1f val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L @@ -2726,6 +2796,7 @@ class ExoPlayerCore(private val activity: Activity) : } val newDisabled = !hasHardwareAudioDecoder(mimeType) + selectedAudioHasHwDecoder = !newDisabled if (newDisabled != tunnelingDisabledForAudioCodec) { tunnelingDisabledForAudioCodec = newDisabled emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}") 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 5763bffa2..2356b154c 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,6 +258,7 @@ 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) @@ -296,7 +297,7 @@ class ExoPlayerPlugin : // on Auto because Dart derives one for mpv's demuxer, which shares the property, and // the fallback replay below needs it. val bufferSizeAuto = call.argument("bufferSizeAuto") ?: false - val tunnelingEnabled = call.argument("tunnelingEnabled") ?: true + val tunnelingEnabled = call.argument("tunnelingEnabled") ?: false val dvConversionMode = call.argument("dvConversionMode") ?: "auto" val audioPassthroughEnabled = call.argument("audioPassthroughEnabled") ?: false val assVideoLatencyFrames = call.argument("assVideoLatencyFrames") ?: 0 @@ -304,6 +305,7 @@ 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") ?: "auto" configuredBufferSizeBytes = bufferSizeBytes // Seed the request here rather than waiting for Dart's separate setAudioPassthrough // call, so a fallback raised before that arrives still derives audio-spdif correctly. @@ -348,7 +350,8 @@ class ExoPlayerPlugin : bufferSizeAuto = bufferSizeAuto, tunnelingEnabled = tunnelingEnabled, audioPassthroughEnabled = audioPassthroughEnabled, - bufferTier = bufferTier + bufferTier = bufferTier, + demuxerMode = demuxerMode ) if (!success) { if (playerCore === core) playerCore = null @@ -1136,6 +1139,18 @@ 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/FfmpegDemuxerJni.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt new file mode 100644 index 000000000..86423bf0b --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt @@ -0,0 +1,138 @@ +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 { + /** Re-entry point for the AVIO callbacks while a native call is in flight. */ + interface Input { + /** Current byte position of the underlying [androidx.media3.extractor.ExtractorInput]. */ + fun position(): Long + + /** + * Reads up to [length] bytes into [buf]. 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 + + /** Total input length in bytes, or `-1` when unknown. */ + fun length(): Long + + var lastError: String? + } + + // readPacket out[] indices. Deferral targets travel through + // [nativeConsumePendingSeek], not this array. + 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_NEED_SEEK = 2 + const val CODE_GROW = 3 + 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 (and resumes opening) the demuxer against [input]. Returns 0 on + * success, [CODE_NEED_SEEK] when the loader must move the input first, or a + * negative AVERROR. 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 + + /** Returns the recorded seek target, or [Long.MIN_VALUE] when none. */ + external fun nativeConsumePendingSeek(): Long + + /** Adopts [position] as the AVIO position; the input must already be there. */ + external fun nativeResumeAfterSeek(position: Long): Int + + /** + * Records a presentation-time seek (media3 timeline microseconds). The next + * [nativeReadPacket] executes it via avformat_seek_file, deferring through + * the loader as needed; the demuxer picks the byte position itself. + */ + external fun nativeSeekTo(timeUs: Long) + + /** Drops cached header blocks; called when the extractor binds a new source. */ + external fun nativeResetCache() +} 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 new file mode 100644 index 000000000..7829ae8a3 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt @@ -0,0 +1,45 @@ +package com.edde746.plezy.exoplayer + +/** + * Decides where the FFmpeg demuxer sits relative to media3's own extractors. + * + * media3's bundled extractors are strong for MP4/TS and 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 — all of + * which libavformat handles natively). "Auto" therefore puts FFmpeg first for + * those families and leaves everything else on media3, with an any-container + * FFmpeg fallback behind media3's list so exotic containers still play + * instead of failing outright. + */ +internal object FfmpegDemuxerPolicy { + enum class Preference(val wireName: String) { + AUTO("auto"), + FFMPEG_FIRST("ffmpeg"), + MEDIA3_ONLY("media3") + } + + /** + * Containers FFmpeg demuxes ahead of media3 under Auto. Probed short names + * from libavformat: "avi" covers AVI/DIVX, "asf" covers WMV, "mpeg" covers + * MPEG-PS and VOB, "matroska,webm" covers MKV and WebM. + */ + val FFMPEG_FIRST_CONTAINERS = setOf("avi", "asf", "mpeg", "matroska,webm") + + fun fromWire(value: String?): Preference = Preference.entries.firstOrNull { it.wireName == value } ?: Preference.AUTO + + /** + * Whether the FFmpeg extractor placed before media3's list accepts a probed + * container name. Under [Preference.FFMPEG_FIRST] it accepts everything, so + * the trailing catch-all becomes redundant but harmless. + */ + fun primaryAccepts(preference: Preference, probedContainer: String): Boolean = when (preference) { + Preference.MEDIA3_ONLY -> false + Preference.FFMPEG_FIRST -> true + Preference.AUTO -> probedContainer.lowercase() in FFMPEG_FIRST_CONTAINERS + } + + /** Whether the any-container FFmpeg fallback behind media3's list exists. */ + fun catchAllEnabled(preference: Preference): Boolean = preference != Preference.MEDIA3_ONLY +} 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 new file mode 100644 index 000000000..0f959332d --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt @@ -0,0 +1,600 @@ +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 protocol: see `ffmpeg_demuxer_jni.cc`. The extractor never moves the + * input itself; position divergences abort the native call, and Kotlin either + * returns RESULT_SEEK or resumes in place when the loader already positioned + * the input at the requested byte. + * + * [role] places the instance: [Role.PRIMARY] sits before media3's extractors + * and sniffs only what [FfmpegDemuxerPolicy] hands to FFmpeg; [Role.CATCH_ALL] + * sits behind them and accepts any container media3 could not sniff. + */ +@OptIn(UnstableApi::class) +internal class FfmpegExtractor private constructor( + private val role: Role, + private val preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference, + private val dvMode: DvConversionMode, + private val subtitleParserFactory: SubtitleParser.Factory, + private val assHandler: AssHandler +) : Extractor { + + enum class Role { PRIMARY, CATCH_ALL } + + companion object { + private const val TAG = "FfmpegExtractor" + private const val SNIFF_BYTES = 64 * 1024 + private const val MAX_RECONCILES = 24 + + // Loader round trips (RESULT_SEEK) allowed for one item's whole open + // phase; matroska needs ~3, avi with an end-of-file index ~4. + private const val MAX_OPEN_LOADER_ROUND_TRIPS = 96 + private const val INITIAL_PACKET_BUFFER_BYTES = 2 * 1024 * 1024 + private const val AUDIO_INDEX_INTERVAL_US = 500_000L + + /** Null when the native library is unavailable. */ + fun create( + role: Role, + preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference, + dvMode: DvConversionMode, + subtitleParserFactory: SubtitleParser.Factory, + assHandler: AssHandler + ): FfmpegExtractor? = if (FfmpegDemuxerJni.available) { + FfmpegExtractor(role, preferenceSupplier, dvMode, subtitleParserFactory, assHandler) + } 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 length(): Long { + val input = currentInput ?: return -1L + val length = input.length + return if (length == C.LENGTH_UNSET.toLong()) -1L else length + } + + 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 primaryStreamIndex = -1 + private var primaryStreamIsAudio = false + 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() + + // Seek index for the primary stream: (presentation time, input position) of + // video keyframes or periodic audio packets. The SeekMap reads it on the + // playback thread while the loader thread appends, hence the lock. + private val seekIndexTimes = ArrayList() + private val seekIndexPositions = ArrayList() + private val seekIndexLock = Any() + + 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. + val preference = preferenceSupplier() + val probed = probe(input) ?: return false + val accepted = when (role) { + Role.PRIMARY -> FfmpegDemuxerPolicy.primaryAccepts(preference, probed) + Role.CATCH_ALL -> FfmpegDemuxerPolicy.catchAllEnabled(preference) + } + if (accepted) Log.i(TAG, "sniff accepted $probed (role=$role)") + return accepted + } + + 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 header cache so this open cannot replay stale bytes. + FfmpegDemuxerJni.nativeResetCache() + openLoaderRoundTrips = 0 + // 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(input, seekPosition)) return Extractor.RESULT_SEEK + return readPacket(input, seekPosition) + } catch (e: Throwable) { + Log.w(TAG, "read threw at input=${input.position}", e) + throw e + } finally { + currentInput = null + } + } + + /** + * Hands the presentation-time target to the demuxer: the next read runs + * avformat_seek_file, which picks the byte position and resets libavformat's + * parse state (jumping the AVIO position under the demuxer would leave + * demuxer-private state pointing into the old stream). The byte position + * media3 supplies is only its SeekMap guess; the loader converges on the + * demuxer's choice through the normal deferral round trip. The DV + * converter's buffered NAL state resets here as well. + */ + override fun seek(position: Long, timeUs: Long) { + Log.i(TAG, "extractor seek position=$position 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() + if (opened) FfmpegDemuxerJni.nativeSeekTo(timeUs) + } + + override fun release() { + if (opened) { + opened = false + FfmpegDemuxerJni.nativeClose() + } + } + + // The in-call `reconciles` counter resets on every read() invocation, so it + // cannot bound RESULT_SEEK ping-pong across loader restarts. This budget + // spans the whole open of one item (reset in init) and turns a + // non-converging header phase into a clean fallback instead of a hang. + private var openLoaderRoundTrips = 0 + + /** Returns false when a loader seek was requested via [seekPosition]. */ + private fun openStreams(input: ExtractorInput, seekPosition: PositionHolder): Boolean { + var reconciles = 0 + while (true) { + when (val code = FfmpegDemuxerJni.nativeOpen(inputProxy)) { + 0 -> { + prepareTracks() + opened = true + Log.i(TAG, "ffmpeg demuxer ready: ${trackOutputs.count { it != null }} tracks") + return true + } + FfmpegDemuxerJni.CODE_NEED_SEEK -> { + val target = FfmpegDemuxerJni.nativeConsumePendingSeek() + Log.i(TAG, "open: NEED_SEEK target=$target inputPos=${input.position}") + if (target == Long.MIN_VALUE || target < 0) { + // Sticky AVIO error replayed without a fresh deferral: re-sync at + // the loader's current position and continue (bounded below). + if (++reconciles > MAX_RECONCILES) { + throw malformed("demuxer produced an invalid seek target: $target") + } + if (FfmpegDemuxerJni.nativeResumeAfterSeek(input.position) != 0) { + throw malformed("demuxer resync failed at ${input.position}") + } + continue + } + if (++reconciles > MAX_RECONCILES) throw malformed("too many demuxer repositions") + if (!reconcile(input, seekPosition, target)) { + if (++openLoaderRoundTrips > MAX_OPEN_LOADER_ROUND_TRIPS) { + throw malformed("demuxer open did not converge after $openLoaderRoundTrips loader seeks") + } + return false + } + } + 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 } + 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) { + builder.setInitializationData(listOf(extradata)) + } + + when (trackType) { + C.TRACK_TYPE_VIDEO -> { + // A Dolby Vision config record rides the stream as side data; the + // codecs string is what DoviConvertingTrackOutput keys on, exactly + // as it does for Matroska's CodecPrivate. + val doviProfile = numbers[FfmpegDemuxerJni.INFO_DOVI_PROFILE].toInt() + Log.i(TAG, "video track $index: doviProfile=$doviProfile mime=$mime") + if (doviProfile >= 0) { + val doviLevel = numbers[FfmpegDemuxerJni.INFO_DOVI_LEVEL].toInt().coerceAtLeast(0) + builder.setCodecs("dvh1.%02d.%02d".format(doviProfile, doviLevel)) + } + 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 + } + 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 + } + + primaryStreamIndex = if (primaryVideo >= 0) primaryVideo else primaryAudio + primaryStreamIsAudio = primaryVideo < 0 && primaryAudio >= 0 + synchronized(seekIndexLock) { + seekIndexTimes.clear() + seekIndexPositions.clear() + } + 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(input: ExtractorInput, seekPosition: PositionHolder): Int { + var reconciles = 0 + 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 + ) + recordSeekPoint(streamIndex, ptsUs, packetOut[FfmpegDemuxerJni.OUT_POSITION], isKeyframe) + return Extractor.RESULT_CONTINUE + } + FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT + FfmpegDemuxerJni.CODE_NEED_SEEK -> { + val target = FfmpegDemuxerJni.nativeConsumePendingSeek() + if (target == Long.MIN_VALUE || target < 0) { + // Sticky AVIO error replayed without a fresh deferral: resync in + // place (bounded by reconciles) instead of killing the stream. + if (++reconciles > MAX_RECONCILES || !reSync(input)) { + throw malformed("demuxer produced an invalid seek target: $target") + } + continue + } + if (++reconciles > MAX_RECONCILES) throw malformed("too many demuxer repositions") + if (!reconcile(input, seekPosition, target)) return Extractor.RESULT_SEEK + } + 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") + else -> throw malformed("ffmpeg demuxer read failed: $code") + } + } + } + + /** Re-establishes the AVIO position at the loader's current input position. */ + private fun reSync(input: ExtractorInput): Boolean { + val position = input.position + return position >= 0 && FfmpegDemuxerJni.nativeResumeAfterSeek(position) == 0 + } + + /** Returns true when the input is already at [target] and native resumed. */ + private fun reconcile(input: ExtractorInput, seekPosition: PositionHolder, target: Long): Boolean { + if (target == input.position) { + val code = FfmpegDemuxerJni.nativeResumeAfterSeek(target) + if (code != 0) throw malformed("demuxer resume failed: $code") + return true + } + seekPosition.position = target + return false + } + + private fun recordSeekPoint(streamIndex: Int, timeUs: Long, position: Long, keyframe: Boolean) { + if (streamIndex != primaryStreamIndex || position <= 0) return + if (!primaryStreamIsAudio && !keyframe) return + synchronized(seekIndexLock) { + val lastIndex = seekIndexTimes.size - 1 + if (lastIndex >= 0) { + if (timeUs <= seekIndexTimes[lastIndex]) return + if (primaryStreamIsAudio && timeUs - seekIndexTimes[lastIndex] < AUDIO_INDEX_INTERVAL_US) { + return + } + } + seekIndexTimes.add(timeUs) + seekIndexPositions.add(position) + } + } + + private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null) + + private inner class SeekMapImpl : SeekMap { + // Seeks are executed by the demuxer (avformat_seek_file), so seekability + // does not depend on the sample-derived index below — that index only + // improves the loader's initial byte guess. The timeline is published + // from this value once, at prepare time, when the index is still empty. + override fun isSeekable(): Boolean = this@FfmpegExtractor.durationUs != C.TIME_UNSET + + override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints { + synchronized(seekIndexLock) { + if (seekIndexTimes.isEmpty()) return SeekMap.SeekPoints(SeekPoint(0, 0)) + val index = seekIndexTimes.binarySearchFloor(timeUs) + if (index < 0) return SeekMap.SeekPoints(SeekPoint(seekIndexTimes[0], seekIndexPositions[0])) + val preceding = SeekPoint(seekIndexTimes[index], seekIndexPositions[index]) + if (index + 1 >= seekIndexTimes.size || seekIndexTimes[index] == timeUs) { + return SeekMap.SeekPoints(preceding) + } + val following = SeekPoint(seekIndexTimes[index + 1], seekIndexPositions[index + 1]) + return SeekMap.SeekPoints(preceding, following) + } + } + + // 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 + } +} + +/** Floor binary search over an ascending list; returns the greatest index with value <= target. */ +private fun List.binarySearchFloor(value: Long): Int { + var low = 0 + var high = size - 1 + var result = -1 + while (low <= high) { + val mid = (low + high) ushr 1 + if (this[mid] <= value) { + result = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return result +} 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 new file mode 100644 index 000000000..8328c8333 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt @@ -0,0 +1,46 @@ +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 deleted file mode 100644 index 01976aada..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt +++ /dev/null @@ -1,65 +0,0 @@ -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 new file mode 100644 index 000000000..4e05b95c3 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt @@ -0,0 +1,31 @@ +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/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt deleted file mode 100644 index 156a28c78..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 4d7139fc8..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt +++ /dev/null @@ -1,315 +0,0 @@ -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/FfmpegDemuxerPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt new file mode 100644 index 000000000..100678cb8 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt @@ -0,0 +1,75 @@ +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.AUTO, FfmpegDemuxerPolicy.fromWire("auto")) + assertEquals( + FfmpegDemuxerPolicy.Preference.FFMPEG_FIRST, + FfmpegDemuxerPolicy.fromWire("ffmpeg") + ) + assertEquals( + FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY, + FfmpegDemuxerPolicy.fromWire("media3") + ) + } + + @Test + fun unknownWireValuesResolveToAuto() { + assertEquals(FfmpegDemuxerPolicy.Preference.AUTO, FfmpegDemuxerPolicy.fromWire(null)) + assertEquals(FfmpegDemuxerPolicy.Preference.AUTO, FfmpegDemuxerPolicy.fromWire("")) + assertEquals(FfmpegDemuxerPolicy.Preference.AUTO, FfmpegDemuxerPolicy.fromWire("bogus")) + } + + @Test + fun autoHandsOnlyWeakContainersToFfmpeg() { + // Containers media3 has no working extractor for — AVI (#2052), ASF/WMV, + // MPEG-PS/VOB — plus Matroska, whose media3 extractor needed app-side + // patches (zlib subtitles, LATM audio, cueless seeking, font attachments) + // that libavformat covers natively. + assertTrue(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "avi")) + assertTrue(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "asf")) + assertTrue(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "mpeg")) + assertTrue(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "matroska,webm")) + // Case-insensitive: probed names are lowercased by the extractor. + assertTrue(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "AVI")) + + // Containers media3 handles well stay on media3's extractors. + assertFalse(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "mp4")) + assertFalse(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "mpegts")) + assertFalse(FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.AUTO, "ogg")) + } + + @Test + fun ffmpegFirstTakesEverythingFromMedia3() { + for (container in listOf("avi", "asf", "mpeg", "matroska,webm", "mp4", "mpegts", "flv")) { + assertTrue( + "expected ffmpeg-first to accept $container", + FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.FFMPEG_FIRST, container) + ) + } + } + + @Test + fun media3OnlyNeverUsesTheFfmpegDemuxer() { + for (container in listOf("avi", "asf", "mpeg", "matroska,webm", "mp4")) { + assertFalse( + FfmpegDemuxerPolicy.primaryAccepts(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY, container) + ) + } + assertFalse(FfmpegDemuxerPolicy.catchAllEnabled(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY)) + } + + @Test + fun catchAllFollowsPreference() { + assertTrue(FfmpegDemuxerPolicy.catchAllEnabled(FfmpegDemuxerPolicy.Preference.AUTO)) + assertTrue(FfmpegDemuxerPolicy.catchAllEnabled(FfmpegDemuxerPolicy.Preference.FFMPEG_FIRST)) + assertFalse(FfmpegDemuxerPolicy.catchAllEnabled(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY)) + } +} 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 new file mode 100644 index 000000000..8ac723ee6 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt @@ -0,0 +1,53 @@ +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/MatroskaLatmSupportTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt similarity index 58% rename from android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt rename to android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt index d918227ef..fbc23f1c2 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt @@ -5,14 +5,7 @@ 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 @@ -24,16 +17,17 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * 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. + * 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. * - * Robolectric provides real android.util.* implementations — MatroskaExtractor - * stores tracks in a SparseArray, which is a no-op stub on plain JVM. + * Robolectric provides real android.util.* implementations for media3's + * internals. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) -class MatroskaLatmSupportTest { +class LatmTrackOutputTest { private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray) @@ -71,14 +65,6 @@ class MatroskaLatmSupportTest { } } - 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 @@ -118,76 +104,46 @@ class MatroskaLatmSupportTest { return frames } - 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 -> {} - } - } - } + private fun placeholderFormat(): Format = Format.Builder() + .setId("1") + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .setLabel("LATM track") + .setLanguage("eng") + .build() @Test - fun unwrapsLoasAcmTrackToRawAac() { - val output = extractFixture() + fun unwrapsLoasFramesToRawAac() { + val delegate = FakeTrackOutput() + val output = LatmTrackOutput(delegate, 1) + output.format(placeholderFormat()) - assertEquals(1, output.tracks.size) - val track = output.tracks.values.first() + 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 + } - // 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() + // The placeholder format must be swallowed; the LATM-derived AAC format + // carries the AudioSpecificConfig and the original track metadata. + val format = delegate.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()) - // 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("expected ~40 samples, got ${delegate.samples.size}", delegate.samples.size in 35..40) + for (sample in delegate.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 @@ -205,19 +161,14 @@ class MatroskaLatmSupportTest { } @Test - fun preservesStreamMuxConfigAcrossExtractorSeek() { + fun preservesStreamMuxConfigAcrossReset() { 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( - Format.Builder() - .setId("1") - .setSampleMimeType(MimeTypes.AUDIO_UNKNOWN) - .build() - ) + output.format(placeholderFormat()) 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/MatroskaLateTracksTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt deleted file mode 100644 index b578a8a7d..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.media3.common.C -import androidx.media3.common.DataReader -import androidx.media3.common.Format -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.extractor.DefaultExtractorInput -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorInput -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.TrackOutput -import com.edde746.plezy.libass.media.AssHandler -import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class MatroskaLateTracksTest { - - private class CapturedTrack(val type: Int) : TrackOutput { - val timesUs = mutableListOf() - 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/ZlibInflatingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt deleted file mode 100644 index 35c29815f..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt +++ /dev/null @@ -1,248 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.media3.common.C -import androidx.media3.common.DataReader -import androidx.media3.common.Format -import androidx.media3.common.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 deleted file mode 100644 index 8fdc31396..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.edde746.plezy.exoplayer - -import java.io.ByteArrayOutputStream -import java.util.zip.Deflater -import java.util.zip.DeflaterOutputStream -import java.util.zip.Inflater -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertNull -import org.junit.Test - -/** - * Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint, - * timecode, flags) must survive byte-identically, the payload must inflate, and - * every block the rewriter cannot prove safe must pass through unchanged (null). - */ -class ZlibTextBlockTest { - - private val inflater = Inflater() - - private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload - - private fun deflate(bytes: ByteArray): ByteArray { - val target = ByteArrayOutputStream() - DeflaterOutputStream(target, Deflater()).use { it.write(bytes) } - return target.toByteArray() - } - - // Track 3 (varint 0x83), relative timecode 0x0102, no lacing. - private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00) - - @Test - fun inflatesUnlacedBlockPayloadAndPreservesHeader() { - val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray() - val data = block(unlacedHeader, deflate(dialogue)) - - val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!! - - assertArrayEquals(unlacedHeader + dialogue, rewritten) - } - - @Test - fun twoByteTrackNumberVarintIsPreserved() { - val dialogue = "1,,Default,,0,0,0,,hello".toByteArray() - val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298 - val data = block(header, deflate(dialogue)) - - val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!! - - assertArrayEquals(header + dialogue, rewritten) - } - - @Test - fun lacedBlockPassesThrough() { - val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set - val data = block(laced, deflate("payload".toByteArray())) - - assertNull(rewriteZlibTextBlock(data, data.size, inflater)) - } - - @Test - fun corruptStreamPassesThrough() { - val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib - - assertNull(rewriteZlibTextBlock(data, data.size, inflater)) - } - - @Test - fun truncatedStreamPassesThrough() { - val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray()) - val truncated = compressed.copyOf(compressed.size - 4) - val data = block(unlacedHeader, truncated) - - assertNull(rewriteZlibTextBlock(data, data.size, inflater)) - } - - @Test - fun headerOnlyBlockPassesThrough() { - assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater)) - } -} 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 deleted file mode 100644 index af4b81d41..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.media3.common.C -import androidx.media3.common.DataReader -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.extractor.DefaultExtractorInput -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.TrackOutput -import androidx.media3.extractor.text.CueDecoder -import androidx.media3.extractor.text.DefaultSubtitleParserFactory -import com.edde746.plezy.libass.media.AssHandler -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with - * `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that - * broke #2023) and an identical uncompressed mux — and verifies the zlib block - * payloads are inflated *before* MatroskaExtractor's subtitle sample assembly. - * - * Regenerate with: - * mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass - * mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass - * where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events. - * - * Robolectric provides real android.util/android.os implementations — - * MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which - * are no-op stubs on plain JVM. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class ZlibTextSubtitleExtractionTest { - - private class CapturedSample(val timeUs: Long, val data: ByteArray) - - private class FakeTrackOutput : TrackOutput { - val formats = mutableListOf() - 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 6998c8234..bbf92103a 100644 --- a/android/libass/build.gradle.kts +++ b/android/libass/build.gradle.kts @@ -58,4 +58,6 @@ 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 new file mode 100644 index 000000000..66f9c1710 --- /dev/null +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt @@ -0,0 +1,31 @@ +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 deleted file mode 100644 index 9a7bdb48e..000000000 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt +++ /dev/null @@ -1,169 +0,0 @@ -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 ed4836cfd..68c68be8e 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,21 +5,22 @@ 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 /** - * This class is only used by the overlay renderer. It's needed to get the start time of the subtitles. + * 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. */ @UnstableApi class AssSubtitleExtractorOutput( private val delegate: ExtractorOutput, - private val assHandler: AssHandler, - private val extractor: AssMatroskaExtractor + private val assHandler: AssHandler ) : 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 other format, so we wrap - // every subtitle track - AssTrackOutput(delegate.track(id, type), assHandler, extractor) + // 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) } 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 b68d6c05b..b2138e2a0 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,29 +1,50 @@ 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 com.edde746.plezy.libass.media.AssHandler -import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor +import java.io.EOFException import java.util.regex.Pattern /** - * This class is only used by the overlay renderer. It's needed to get the start time of the subtitles. + * 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. */ @UnstableApi class AssTrackOutput( private val delegate: TrackOutput, - private val assHandler: AssHandler, - private val extractor: AssMatroskaExtractor -) : TrackOutput by delegate { + 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 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() + override fun format(format: Format) { if (format.sampleMimeType == MimeTypes.TEXT_SSA || format.codecs == MimeTypes.TEXT_SSA) { isAss = true @@ -32,6 +53,34 @@ 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, @@ -39,26 +88,37 @@ class AssTrackOutput( offset: Int, cryptoData: TrackOutput.CryptoData? ) { - 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 - ) + 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 } 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()) { @@ -72,24 +132,32 @@ class AssTrackOutput( return timestampUs } - private fun findTokenIndex(array: ByteArray, tokenNumber: Int): Int { - if (tokenNumber == 0) return 0 + /** Index just past the [tokenNumber]th comma inside [from, to), or [from]. */ + private fun findTokenIndex(from: Int, to: Int, tokenNumber: Int): Int { var tokensFound = 0 - array.forEachIndexed { index, byte -> - if (byte == COMMA && ++tokensFound == tokenNumber) { + for (index in from until to) { + if (buf[index] == COMMA && ++tokensFound == tokenNumber) { return index + 1 } } - return 0 + return from } - private val Long.isValidTs - get() = this != C.TIME_UNSET + 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 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 deleted file mode 100644 index 9a8e2ee75..000000000 --- a/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -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 new file mode 100644 index 000000000..ac9e1a6a6 --- /dev/null +++ b/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt @@ -0,0 +1,157 @@ +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 4fd9f142d..e6c593a4c 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 b27a22a78..509d52cb1 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване", "dvConversionDv81Description": "Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1", "dvConversionHevcStripDescription": "Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "Питай за профил при отваряне на приложението", "requireProfileSelectionOnOpenDescription": "Показвай избор на профил всеки път при отваряне на приложението", "forceTvMode": "Принуди TV режим", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index c56817d21..110a0f92e 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 9eb075db6..26b0ac402 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 529fc8310..540904ba7 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -319,6 +319,11 @@ "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. FFmpeg covers more containers; media3 is the previous behavior.", + "demuxerAuto": "Auto (Recommended)", + "demuxerFfmpegFirst": "FFmpeg first", + "demuxerMedia3Only": "media3 only", "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 97b0e2a65..7f9407a9a 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 da380a0c7..f2ab0b3bc 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 07131d3c4..a4aaa0f68 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 618e6adb3..345c978a2 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 4a9c53715..45b05f010 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "ネイティブ DV7 を強制し、DV 変換の再試行を抑制します", "dvConversionDv81Description": "Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します", "dvConversionHevcStripDescription": "Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "アプリ起動時にプロフィールを確認", "requireProfileSelectionOnOpenDescription": "アプリを開くたびにプロフィール選択を表示", "forceTvMode": "TVモードを強制", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 784002c19..054b598eb 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "Ішкі DV7 режимін мәжбүрлеу", "dvConversionDv81Description": "Dolby Vision profile 8.1 форматына түрлендіру", "dvConversionHevcStripDescription": "Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "Ашқанда профильді сұрау", "requireProfileSelectionOnOpenDescription": "Қолданба ашылған сайын профильді таңдауды көрсету", "forceTvMode": "TV режимін мәжбүрлеу", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 93aac970e..f42075220 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다", "dvConversionDv81Description": "Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다", "dvConversionHevcStripDescription": "Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "앱 실행 시 프로필 선택", "requireProfileSelectionOnOpenDescription": "앱을 열 때마다 프로필 선택 화면을 표시합니다", "forceTvMode": "TV 모드 강제 사용", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index cc2fd720e..8cde931f8 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 17fdbbf8c..48eaceb9b 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 02beaea0e..b0006c2c5 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 1d3fcd7a6..f11494a94 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 96da38dfd..98ef71d2a 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "Принудительно использовать нативный DV7 и не повторять DV-конвертацию", "dvConversionDv81Description": "Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1", "dvConversionHevcStripDescription": "Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "Запрашивать профиль при запуске", "requireProfileSelectionOnOpenDescription": "Показывать выбор профиля при каждом открытии приложения", "forceTvMode": "Принудительный режим ТВ", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index be49db94b..83f6b2759 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: 40965 (1862 per locale) +/// Strings: 40970 (1862 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 54311655a..de472c0ab 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1035,6 +1035,21 @@ 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. FFmpeg covers more containers; media3 is the previous behavior.' + String get demuxerDescription => 'Which demuxer parses direct-played files. FFmpeg covers more containers; media3 is the previous behavior.'; + + /// en: 'Auto (Recommended)' + String get demuxerAuto => 'Auto (Recommended)'; + + /// en: 'FFmpeg first' + String get demuxerFfmpegFirst => 'FFmpeg first'; + + /// en: 'media3 only' + String get demuxerMedia3Only => 'media3 only'; + /// en: 'Ask for profile on app open' String get requireProfileSelectionOnOpen => 'Ask for profile on app open'; @@ -6737,6 +6752,11 @@ 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. FFmpeg covers more containers; media3 is the previous behavior.', + 'settings.demuxerAuto' => 'Auto (Recommended)', + 'settings.demuxerFfmpegFirst' => 'FFmpeg first', + 'settings.demuxerMedia3Only' => 'media3 only', 'settings.requireProfileSelectionOnOpen' => 'Ask for profile on app open', 'settings.requireProfileSelectionOnOpenDescription' => 'Show profile selection every time the app is opened', 'settings.forceTvMode' => 'Force TV mode', @@ -6937,13 +6957,13 @@ extension on Translations { 'mediaMenu.deleteShowFromServer' => 'Delete show from server', 'mediaMenu.deleteMovieFromServer' => 'Delete movie from server', 'mediaMenu.deleteEpisodeTitle' => 'Delete this episode?', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteSeasonTitle' => 'Delete this season?', 'mediaMenu.deleteShowTitle' => 'Delete this show?', 'mediaMenu.deleteMovieTitle' => 'Delete this movie?', 'mediaMenu.deleteEpisodeConfirm' => 'Delete episode', 'mediaMenu.deleteSeasonConfirm' => 'Delete season', - _ => null, - } ?? switch (path) { 'mediaMenu.deleteShowConfirm' => 'Delete show', 'mediaMenu.deleteMovieConfirm' => 'Delete movie', 'mediaMenu.deleteAnyway' => 'Delete anyway', @@ -7451,13 +7471,13 @@ extension on Translations { 'explore.status.ended' => 'Ended', 'explore.status.canceled' => 'Canceled', 'explore.status.upcoming' => 'Upcoming', + _ => null, + } ?? switch (path) { 'explore.episodeCount' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} episode', other: '${n} episodes', ), 'explore.cast' => 'Cast', 'explore.characters' => 'Characters', 'explore.addToWatchlist' => 'Add to Watchlist', 'explore.removeFromWatchlist' => 'Remove from Watchlist', - _ => null, - } ?? switch (path) { 'explore.addedToWatchlist' => 'Added to watchlist', 'explore.removedFromWatchlist' => 'Removed from watchlist', 'explore.watchlistUpdateFailed' => 'Couldn\'t update watchlist', @@ -7965,13 +7985,13 @@ extension on Translations { 'companionRemote.remote.seekForward' => 'Seek Fwd', 'companionRemote.remote.volume' => 'Volume', 'companionRemote.remote.volumeDown' => 'Down', + _ => null, + } ?? switch (path) { 'companionRemote.remote.volumeUp' => 'Up', 'companionRemote.remote.fullscreen' => 'Fullscreen', 'companionRemote.remote.subtitles' => 'Subtitles', 'companionRemote.remote.audio' => 'Audio', 'companionRemote.remote.searchHint' => 'Search on desktop...', - _ => null, - } ?? switch (path) { 'companionRemote.errors.noNetworkInterface' => 'No network interface found', 'companionRemote.errors.authenticationFailed' => 'Authentication failed', 'companionRemote.errors.serverStartFailed' => ({required Object error}) => 'Failed to start remote server: ${error}', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 121b09b3e..4d760482b 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 9eee68c22..c75f12a7b 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 0d23d8b8e..4a96152ba 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -319,6 +319,11 @@ "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": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "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 c99c4e0b6..169834dcb 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "強制使用原生 DV7 並停用 DV 轉換重試", "dvConversionDv81Description": "強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1", "dvConversionHevcStripDescription": "移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "開啟應用程式時要求選擇使用者", "requireProfileSelectionOnOpenDescription": "每次開啟應用程式時顯示使用者設定檔選擇畫面", "forceTvMode": "強制 TV 模式", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 2c7a0d463..36957cea4 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -319,6 +319,11 @@ "dvConversionNativeDescription": "强制原生 DV7 并禁止重试 DV 转换", "dvConversionDv81Description": "强制内联 RPU 转换为 Dolby Vision Profile 8.1", "dvConversionHevcStripDescription": "移除 Dolby Vision RPU/EL 层并呈现普通 HEVC", + "demuxer": "", + "demuxerDescription": "", + "demuxerAuto": "", + "demuxerFfmpegFirst": "", + "demuxerMedia3Only": "", "requireProfileSelectionOnOpen": "打开应用时选择用户资料", "requireProfileSelectionOnOpenDescription": "每次打开应用时都显示用户资料选择界面", "forceTvMode": "强制 TV 模式", diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 8615f424b..51ffb1397 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -15,8 +15,9 @@ class PlayerAndroid extends PlayerBase { int? _bufferSizeBytes; bool _bufferSizeIsAuto = false; String _bufferTier = 'auto'; - bool _tunnelingEnabled = true; + bool _tunnelingEnabled = false; String _dvConversionMode = 'auto'; + String _demuxerMode = 'auto'; bool _audioNormalizationEnabled = false; bool _audioPassthroughEnabled = false; bool _downmixEnabled = false; @@ -120,6 +121,7 @@ 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. @@ -357,6 +359,10 @@ 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 225823bb9..066f8abe8 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -88,6 +88,7 @@ class _PlaybackSettingsScreenState extends State { _audioDownmixTile(), if (downmixOn) _downmixCenterBoostTile(), if (downmixOn) _downmixNormalizeTile(), + if (exoActive) _demuxerModeTile(), if (exoActive) _dvConversionModeTile(), _bufferSizeTile(), if (exoActive) _playbackBufferTile(), @@ -464,6 +465,20 @@ 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.auto => t.settings.demuxerAuto, + DemuxerPreference.ffmpegFirst => t.settings.demuxerFfmpegFirst, + DemuxerPreference.media3Only => t.settings.demuxerMedia3Only, + }; + Widget _bufferSizeTile() { final bufferOptions = const [0, 64, 128, 256, 512, 1024]; return SettingSelectionTile( diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f05e860a2..779038edc 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1321,6 +1321,7 @@ 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 && useExoPlayer) || 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 e3fbeb2a8..587506943 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -113,6 +113,18 @@ extension DvConversionModePreferenceNativeValue on DvConversionModePreference { }; } +/// Which containers the Android FFmpeg demuxer takes ahead of media3's own +/// extractors. Wire values are read natively by `FfmpegDemuxerPolicy`. +enum DemuxerPreference { auto, ffmpegFirst, media3Only } + +extension DemuxerPreferenceNativeValue on DemuxerPreference { + String get nativeValue => switch (this) { + DemuxerPreference.auto => 'auto', + DemuxerPreference.ffmpegFirst => 'ffmpeg', + DemuxerPreference.media3Only => 'media3', + }; +} + enum PlaybackBufferTier { auto, large, extraLarge } extension PlaybackBufferTierNativeValue on PlaybackBufferTier { @@ -457,12 +469,17 @@ class SettingsService extends BaseSharedPreferencesService { static const enableDiscordRPC = BoolPref('enable_discord_rpc'); static const enableTraktWatchedSync = BoolPref('enable_trakt_watched_sync', defaultValue: true); static const matchContentFrameRate = BoolPref('match_content_frame_rate'); - static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: true); + static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: false); static const dvConversionMode = EnumPref( 'dv_conversion_mode', values: DvConversionModePreference.values, defaultValue: DvConversionModePreference.auto, ); + static const demuxerMode = EnumPref( + 'demuxer_mode', + values: DemuxerPreference.values, + defaultValue: DemuxerPreference.auto, + ); static const defaultQualityPreset = EnumPref( 'default_quality_preset', values: TranscodeQualityPreset.values, @@ -1003,6 +1020,7 @@ class SettingsService extends BaseSharedPreferencesService { specialsOrdering, useExoPlayer, startupSection, + demuxerMode, showExploreTab, alwaysKeepSidebarOpen, librariesSectionExpanded,