From 75704c7a9bc8d9ae5b08ea88f7f793fcbce043b9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:43:34 +0200 Subject: [PATCH] test(player): cover tracks-after-clusters MKV seeking on device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canary asserts stock media3 1.11.0 still reports these files unseekable and snaps seeks to the start — when a media3 upgrade makes it fail, the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired. The second test drives the production wrapper stack and requires the seek to hold. --- .../assets/ffmpeg/matroska_tracks_at_end.mkv | Bin 0 -> 16571 bytes .../MatroskaLateTracksSeekDeviceTest.kt | 170 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 android/app/src/androidTest/assets/ffmpeg/matroska_tracks_at_end.mkv create mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt diff --git a/android/app/src/androidTest/assets/ffmpeg/matroska_tracks_at_end.mkv b/android/app/src/androidTest/assets/ffmpeg/matroska_tracks_at_end.mkv new file mode 100644 index 0000000000000000000000000000000000000000..9a6ad9f2a35e50854d5f72cd00955b6d490215f3 GIT binary patch literal 16571 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, + 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 + } +}