From 3fc229cd6cc57dd403ef1d0cdaed92760f3c74b2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:43:04 +0200 Subject: [PATCH] fix(android): tell external players to start unwatched items from the beginning With an external player on Plex, starting a never-watched episode could open a "resume playback?" prompt at the position the previous, unrelated file was exited. The intent carried no start hint when the item had no resume point, so players with their own bookmark store (Zidoo native player, VLC) consulted it - and every Plex part URL ends in the same `file.`, so those bookmarks collide across items. Send `from_start=true` on fresh launches and `from_start=false` beside `position` when resuming, matching the Zidoo integrations in jellyfin-androidtv-zidoo and PlexToZidoo. MX, Just Player and mpv ignore the extra. close #2223 --- .../edde746/plezy/ExternalPlayerChannel.kt | 13 +++++-- .../plezy/ExternalPlayerChannelTest.kt | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt b/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt index efb509bf5..291f65347 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt @@ -28,6 +28,12 @@ internal class ExternalPlayerChannel(private val activity: Activity) { private const val API_VLC_RESULT_POSITION = "extra_position" private const val API_VLC_RESULT_DURATION = "extra_duration" + // Honored by VLC and the native Zidoo player (com.android.gallery3d / + // com.zidoo.player). Without it, a launch with no resume point lets the + // player consult its own bookmark store, which on Zidoo collides across + // Plex items because every part URL ends in the same `file.` (#2223). + private const val API_VLC_FROM_START = "from_start" + private const val API_VIMU_TITLE = "forcename" private const val API_VIMU_SEEK_POSITION = "startfrom" private const val API_VIMU_RESUME = "forceresume" @@ -150,7 +156,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) { } } - private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?) + internal data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?) private fun resolveSource(filePath: String): Source { if (filePath.startsWith("http://") || filePath.startsWith("https://")) { @@ -168,7 +174,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) { return Source(uri, grantRead = true, fileName = file.name) } - private fun buildIntent( + internal fun buildIntent( source: Source, packageName: String?, startPositionMs: Long, @@ -181,6 +187,9 @@ internal class ExternalPlayerChannel(private val activity: Activity) { if (startPosition > 0) { putExtra(API_MX_RESULT_POSITION, startPosition) putExtra(API_VIMU_SEEK_POSITION, startPosition) + putExtra(API_VLC_FROM_START, false) + } else { + putExtra(API_VLC_FROM_START, true) } putExtra(API_MX_RETURN_RESULT, true) putExtra(API_MX_SECURE_URI, true) diff --git a/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt index 3ee03bf54..d4668f2be 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt @@ -2,8 +2,10 @@ package com.edde746.plezy import android.app.Activity import android.content.Intent +import android.net.Uri import io.flutter.plugin.common.MethodChannel import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -30,6 +32,40 @@ class ExternalPlayerChannelTest { assertEquals(false, result["playbackError"]) } + @Test + fun freshLaunchTellsPlayerToStartFromBeginning() { + val activity = Robolectric.buildActivity(Activity::class.java).get() + val channel = ExternalPlayerChannel(activity) + val source = ExternalPlayerChannel.Source( + Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"), + grantRead = false, + fileName = "file.mkv" + ) + + val intent = channel.buildIntent(source, packageName = null, startPositionMs = 0L, title = "Episode") + + assertTrue(intent.getBooleanExtra("from_start", false)) + assertFalse(intent.hasExtra("position")) + assertFalse(intent.hasExtra("startfrom")) + } + + @Test + fun resumeLaunchPassesPositionAndDisablesFromStart() { + val activity = Robolectric.buildActivity(Activity::class.java).get() + val channel = ExternalPlayerChannel(activity) + val source = ExternalPlayerChannel.Source( + Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"), + grantRead = false, + fileName = "file.mkv" + ) + + val intent = channel.buildIntent(source, packageName = null, startPositionMs = 90_000L, title = null) + + assertFalse(intent.getBooleanExtra("from_start", true)) + assertEquals(90_000, intent.getIntExtra("position", -1)) + assertEquals(90_000, intent.getIntExtra("startfrom", -1)) + } + @Test fun activityDestroyCompletesPendingChannelCall() { val activity = Robolectric.buildActivity(Activity::class.java).get()