diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/EpisodePlaybackSelection.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/EpisodePlaybackSelection.kt new file mode 100644 index 00000000000..04d4f601064 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/EpisodePlaybackSelection.kt @@ -0,0 +1,88 @@ +package com.lagradost.cloudstream3.ui.player + +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +internal data class EpisodeSourceSelection( + val source: String?, + val name: String, + val quality: Int?, + val type: ExtractorLinkType?, +) + +internal sealed interface EpisodeSubtitleSelection { + data class Selected( + val originalName: String, + val nameSuffix: String, + ) : EpisodeSubtitleSelection + + data object Disabled : EpisodeSubtitleSelection +} + +internal data class EpisodeSubtitleResolution( + val subtitle: SubtitleData?, + val selectionMatched: Boolean, + val subtitlesDisabled: Boolean, +) + +internal fun VideoLink.toEpisodeSourceSelection(): EpisodeSourceSelection? { + val link = first + if (link != null) { + return EpisodeSourceSelection(link.source, link.name, link.quality, link.type) + } + + val uri = second ?: return null + return EpisodeSourceSelection(null, uri.name, null, null) +} + +internal fun findEpisodeSource( + links: List, + selection: EpisodeSourceSelection?, +): VideoLink? { + return selection?.let { remembered -> + links.firstOrNull { it.toEpisodeSourceSelection() == remembered } + } +} + +internal fun createEpisodeSubtitleSelection( + subtitle: SubtitleData?, + subtitlesDisabled: Boolean, +): EpisodeSubtitleSelection? { + return when { + subtitle != null -> EpisodeSubtitleSelection.Selected( + subtitle.originalName, + subtitle.nameSuffix, + ) + + subtitlesDisabled -> EpisodeSubtitleSelection.Disabled + else -> null + } +} + +internal fun findEpisodeSubtitle( + subtitles: Set, + selection: EpisodeSubtitleSelection?, +): SubtitleData? { + val remembered = selection as? EpisodeSubtitleSelection.Selected ?: return null + return subtitles.firstOrNull { + it.originalName == remembered.originalName && + it.nameSuffix == remembered.nameSuffix + } +} + +internal fun resolveEpisodeSubtitle( + subtitles: Set, + selection: EpisodeSubtitleSelection?, + fallback: () -> SubtitleData?, +): EpisodeSubtitleResolution { + val rememberedSubtitle = findEpisodeSubtitle(subtitles, selection) + val subtitlesDisabled = selection == EpisodeSubtitleSelection.Disabled + return EpisodeSubtitleResolution( + subtitle = when { + subtitlesDisabled -> null + rememberedSubtitle != null -> rememberedSubtitle + else -> fallback() + }, + selectionMatched = rememberedSubtitle != null, + subtitlesDisabled = subtitlesDisabled, + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 91e3ff9708d..13bdc93a999 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -47,7 +47,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId @@ -96,7 +95,6 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText @@ -177,6 +175,7 @@ class GeneratorPlayer : FullScreenPlayer() { private var currentSelectedLink: Pair? = null private var currentSelectedSubtitles: SubtitleData? = null + private var nextEpisodeSelection: EpisodePlaybackSelection? = null private val currentMeta: Any? get() = viewModel.state.generatorState?.meta private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta @@ -184,6 +183,24 @@ class GeneratorPlayer : FullScreenPlayer() { private var isNextEpisode: Boolean = false // this is used to reset the watch time private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none + private var subtitlesDisabledByUser = false + + private data class EpisodePlaybackSelection( + val source: EpisodeSourceSelection?, + val subtitle: EpisodeSubtitleSelection?, + ) + + private fun rememberEpisodePlaybackSelection() { + val subtitle = player.getCurrentPreferredSubtitle() ?: currentSelectedSubtitles + nextEpisodeSelection = EpisodePlaybackSelection( + source = currentSelectedLink?.toEpisodeSourceSelection(), + subtitle = createEpisodeSubtitleSelection( + subtitle, + subtitlesDisabled = subtitlesDisabledByUser, + ), + ) + } + private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() ?.map { episode -> @@ -194,19 +211,12 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean { - // If subtitle is changed and user initiated -> Save the language - if (subtitle != currentSelectedSubtitles && userInitiated) { - val subtitleLanguageTagIETF = if (subtitle == null) { - "" // -> No Subtitles - } else { - subtitle.getIETF_tag() - } - - if (subtitleLanguageTagIETF != null) { - Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") - setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) - preferredAutoSelectSubtitles = subtitleLanguageTagIETF - } + if (userInitiated) { + nextEpisodeSelection = null + subtitlesDisabledByUser = subtitle == null + } + if (subtitle != null) { + subtitlesDisabledByUser = false } currentSelectedSubtitles = subtitle @@ -523,6 +533,22 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val (url, uri) = link val subtitles = viewModel.state.subtitles + val subtitleSelection = nextEpisodeSelection?.subtitle + val subtitleResolution = resolveEpisodeSubtitle(subtitles, subtitleSelection) { + getAutoSelectSubtitle(subtitles, settings = true, downloads = true) + } + val preferredSubtitle = + if (sameEpisode) currentSelectedSubtitles else subtitleResolution.subtitle + if (!sameEpisode) { + subtitlesDisabledByUser = subtitleResolution.subtitlesDisabled + } + currentSelectedSubtitles = preferredSubtitle + if (!sameEpisode && + (subtitleSelection !is EpisodeSubtitleSelection.Selected || + subtitleResolution.selectionMatched) + ) { + nextEpisodeSelection = null + } player.loadPlayer( ctx, sameEpisode, @@ -532,9 +558,7 @@ class GeneratorPlayer : FullScreenPlayer() { if (isNextEpisode) 0L else getPos() }, subtitles, - (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - subtitles, settings = true, downloads = true - ), + preferredSubtitle, preview = true ) } @@ -1574,7 +1598,19 @@ class GeneratorPlayer : FullScreenPlayer() { if (!isPlayerActive.compareAndSet(false, true)) { return } - loadLink(links.first(), false) + + val preferredSource = nextEpisodeSelection?.source + val preferredLink = findEpisodeSource(links, preferredSource) + if (preferredSource != null && + preferredLink == null && + viewModel.state.loading is Resource.Loading + ) { + isPlayerActive.set(false) + return + } + + nextEpisodeSelection = nextEpisodeSelection?.copy(source = null) + loadLink(preferredLink ?: links.first(), false) showPlayerMetadata() } @@ -1628,6 +1664,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun nextEpisode() { if (viewModel.hasNextEpisode() == true) { isNextEpisode = true + rememberEpisodePlaybackSelection() releasePlayer() viewModel.loadLinksNext() } @@ -1636,6 +1673,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun prevEpisode() { if (viewModel.hasPrevEpisode() == true) { isNextEpisode = true + rememberEpisodePlaybackSelection() releasePlayer() viewModel.loadLinksPrev() } @@ -1806,10 +1844,40 @@ class GeneratorPlayer : FullScreenPlayer() { player.handleEvent(CSPlayerEvent.Play) } + private fun autoSelectFromPreviousEpisode(): Boolean { + if (!isPlayerActive.get()) return false + val selection = nextEpisodeSelection ?: return false + if (selection.subtitle == EpisodeSubtitleSelection.Disabled) { + nextEpisodeSelection = null + return true + } + val subtitle = findEpisodeSubtitle( + viewModel.state.subtitles, + selection.subtitle, + ) ?: run { + if (selection.subtitle == null) { + nextEpisodeSelection = null + } + return false + } + nextEpisodeSelection = null + context?.let { ctx -> + if (setSubtitles(subtitle, false)) { + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) + } + } + return true + } + private fun autoSelectSubtitles() { //Log.i(TAG, "autoSelectSubtitles") safe { - if (!autoSelectFromSettings()) { + if (!subtitlesDisabledByUser && + !autoSelectFromPreviousEpisode() && + !autoSelectFromSettings() + ) { autoSelectFromDownloads() } } @@ -2095,6 +2163,7 @@ class GeneratorPlayer : FullScreenPlayer() { { episodeClick -> if (episodeClick.action == ACTION_CLICK_DEFAULT) { isNextEpisode = false + nextEpisodeSelection = null releasePlayer() playerEpisodeOverlay.isGone = true episodeClick.position?.let { viewModel.loadThisEpisode(it) } diff --git a/app/src/test/java/com/lagradost/cloudstream3/ui/player/EpisodePlaybackSelectionTest.kt b/app/src/test/java/com/lagradost/cloudstream3/ui/player/EpisodePlaybackSelectionTest.kt new file mode 100644 index 00000000000..b8037c75992 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/ui/player/EpisodePlaybackSelectionTest.kt @@ -0,0 +1,124 @@ +package com.lagradost.cloudstream3.ui.player + +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpisodePlaybackSelectionTest { + private fun source(quality: Int): VideoLink = runBlocking { + newExtractorLink( + source = "MailRu", + name = "MailRu", + url = "https://example.com/$quality.m3u8", + type = ExtractorLinkType.M3U8, + ) { + this.quality = quality + } to null + } + + private fun subtitle( + suffix: String = "1", + url: String = "https://example.com/subtitle.vtt", + ) = SubtitleData( + originalName = "English", + nameSuffix = suffix, + url = url, + origin = SubtitleOrigin.URL, + mimeType = "text/vtt", + headers = emptyMap(), + languageCode = "en", + ) + + @Test + fun `source selection preserves quality`() { + val selected = source(480) + val matching = source(480) + + assertSame( + matching, + findEpisodeSource( + listOf(source(1080), matching), + selected.toEpisodeSourceSelection(), + ), + ) + } + + @Test + fun `source selection rejects a different quality with the same name`() { + val selection = source(480).toEpisodeSourceSelection() + + assertNull(findEpisodeSource(listOf(source(1080)), selection)) + } + + @Test + fun `subtitle selection distinguishes unavailable from explicitly disabled`() { + assertNull(createEpisodeSubtitleSelection(null, subtitlesDisabled = false)) + val disabledSelection = + createEpisodeSubtitleSelection(null, subtitlesDisabled = true) + assertEquals( + EpisodeSubtitleSelection.Disabled, + disabledSelection, + ) + + val resolution = resolveEpisodeSubtitle(emptySet(), disabledSelection) { subtitle() } + assertNull(resolution.subtitle) + assertTrue(resolution.subtitlesDisabled) + } + + @Test + fun `selected subtitle takes priority over disabled state`() { + val selection = createEpisodeSubtitleSelection( + subtitle(), + subtitlesDisabled = true, + ) + + assertTrue(selection is EpisodeSubtitleSelection.Selected) + } + + @Test + fun `subtitle selection survives episode url changes`() { + val selected = subtitle(url = "https://example.com/episode-1.vtt") + val matching = subtitle(url = "https://example.com/episode-2.vtt") + + assertSame( + matching, + findEpisodeSubtitle( + setOf(matching), + createEpisodeSubtitleSelection(selected, subtitlesDisabled = false), + ), + ) + } + + @Test + fun `subtitle selection rejects a different suffix`() { + val selection = createEpisodeSubtitleSelection( + subtitle(suffix = "2"), + subtitlesDisabled = false, + ) + + assertNull(findEpisodeSubtitle(setOf(subtitle(suffix = "1")), selection)) + } + + @Test + fun `missing selected subtitle falls back to automatic language`() { + val automaticSubtitle = subtitle(suffix = "1") + val selection = createEpisodeSubtitleSelection( + subtitle(suffix = "2"), + subtitlesDisabled = false, + ) + val resolution = resolveEpisodeSubtitle( + setOf(automaticSubtitle), + selection, + ) { automaticSubtitle } + + assertSame(automaticSubtitle, resolution.subtitle) + assertFalse(resolution.selectionMatched) + assertFalse(resolution.subtitlesDisabled) + } +}