From de67bccb3931b5fd2204690e6c9a1b418607a04b Mon Sep 17 00:00:00 2001 From: osipovgleb Date: Mon, 26 Oct 2026 21:05:37 +0300 Subject: [PATCH] Fix: VLC doesn't restart playback from the beginning as expected (#2050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When resetting the watch state or choosing to start from the beginning, the "from_start" flag isn't passed to VLC. Because of that, VLC doesn't restart playback from the beginning as expected. This change adds the flag when needed so VLC starts from the correct position. ## AI or LLM usage я использовал Codex Co-authored-by: osipovgleb <19697793+osipovgleb@users.noreply.github.com> --- .../wholphin/ui/playback/PlayExternalPage.kt | 2 + .../ui/playback/PlayExternalViewModelTest.kt | 271 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 697e2e05..b17036b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -243,6 +243,8 @@ class PlayExternalViewModel } // VLC + // A zero position alone does not override VLC's saved resume position. + putExtra("from_start", positionMs == 0L) if (subtitleUrls.isNotEmpty()) { putExtra("subtitles_location", subtitleUrls.first().toString()) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt new file mode 100644 index 00000000..6e90af23 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -0,0 +1,271 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp +import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.subtitleApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.PlayStateApi +import org.jellyfin.sdk.api.operations.SubtitleApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.minutes + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class PlayExternalViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val context = mockk() + private val api = mockk() + private val userLibraryApi = mockk() + private val videosApi = mockk() + private val subtitleApi = mockk() + private val playStateApi = mockk(relaxed = true) + private val serverRepository = mockk() + private val itemPlaybackDao = mockk() + private val playlistCreator = mockk() + private val streamChoiceService = mockk() + private val navigationManager = mockk(relaxed = true) + private val userPreferencesService = mockk() + + private val movie = movie() + private val subtitleUrl = "http://localhost:8096/subtitles/0.srt" + private val videoUrl = "http://localhost:8096/video/stream" + private val mediaSource = + TestTracks + .Builder() + .addExternalSubtitle() + .buildMediaSourceInfo() + .let { source -> + source.copy( + id = "source-id", + runTimeTicks = 90.minutes.inWholeTicks, + mediaStreams = + source.mediaStreams!!.map { + it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English") + }, + ) + } + private val subtitle = mediaSource.mediaStreams!!.single() + private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity") + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + mockkStatic(::getExternalPlayers) + every { getExternalPlayers(context) } returns + listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString())) + every { api.userLibraryApi } returns userLibraryApi + every { api.videosApi } returns videosApi + every { api.subtitleApi } returns subtitleApi + every { api.playStateApi } returns playStateApi + every { serverRepository.currentUser } returns null + coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { streamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns subtitle + every { + videosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = mediaSource.id, + static = true, + ) + } returns videoUrl + every { + subtitleApi.getSubtitleUrl( + routeItemId = any(), + routeMediaSourceId = mediaSource.id!!, + routeIndex = subtitle.index, + routeFormat = "srt", + ) + } returns subtitleUrl + setupPlayer("") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + unmockkStatic(::getExternalPlayers) + WholphinDispatchers.reset() + } + + private fun setupPlayer(playerId: String) { + val preferences = + AppPreferences.getDefaultInstance().updatePlaybackPreferences { + externalPlayer = playerId + } + coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null) + } + + private fun createIntent(destination: Destination): Intent { + val viewModel = + PlayExternalViewModel( + SavedStateHandle(), + context, + api, + serverRepository, + itemPlaybackDao, + playlistCreator, + streamChoiceService, + navigationManager, + userPreferencesService, + ) + viewModel.init(destination) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(LoadingState.Success, viewModel.state.value.loading) + return viewModel.state.value.intent + } + + private fun assertStartFromBeginning(intent: Intent) { + assertEquals(true, intent.extras!!.get("from_start")) + assertEquals(0, intent.extras!!.get("position")) + } + + @Test + fun `Explicit VLC starts from beginning at zero position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertEquals(vlc, intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Implicit player starts VLC from beginning at zero position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Unavailable selected player falls back to implicit start from beginning`() = + runTest(testDispatcher) { + setupPlayer("missing.player/missing.player.Activity") + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Explicit VLC preserves positive resume position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertEquals(vlc, intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Implicit player preserves positive resume position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertNull(intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Playback list starts from beginning`() = + runTest(testDispatcher) { + val playlist = playlist() + coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist) + coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns + PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie)))) + + val intent = createIntent(Destination.PlaybackList(playlist.id)) + assertStartFromBeginning(intent) + } + + @Test + fun `VLC starts from beginning without subtitles or duration`() = + runTest(testDispatcher) { + coEvery { streamChoiceService.chooseSource(any(), any()) } returns + mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null) + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null + + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertStartFromBeginning(intent) + assertFalse(intent.hasExtra("subtitles_location")) + assertFalse(intent.hasExtra("extra_duration")) + assertFalse(intent.hasExtra("subs.enable")) + assertFalse(intent.hasExtra("forcedsrt")) + } + + @Test + fun `Existing player extras and playback-start reporting remain unchanged`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}" + val subtitleUri = Uri.parse(subtitleUrl) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(Uri.parse(videoUrl), intent.data) + assertEquals("video/*", intent.type) + assertEquals(title, intent.getStringExtra("title")) + assertEquals(42_000, intent.extras!!.get("position")) + assertTrue(intent.getBooleanExtra("return_result", false)) + assertTrue(intent.getBooleanExtra("secure_uri", false)) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs")) + assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name")) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable")) + assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location")) + assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration")) + assertEquals(42_000, intent.extras!!.get("startfrom")) + assertFalse(intent.getBooleanExtra("forceresume", true)) + assertEquals(title, intent.getStringExtra("forcename")) + assertEquals(subtitleUri, intent.getParcelableExtra("forcedsrt")) + coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) } + coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) } + } +}