diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32f000509f3..ae530192998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -218,6 +218,7 @@ dependencies { implementation(libs.fragment.ktx) implementation(libs.bundles.lifecycle) implementation(libs.bundles.navigation) + implementation(libs.kotlinx.collections.immutable) // Design & UI implementation(libs.preference.ktx) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 4a2a103c513..8a98bd2972e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -362,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa LinkGenerator( listOf(BasicLink(url, name)), extract = true, - ) + id = url.hashCode() + ), 0 ) ) } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { @@ -559,9 +560,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa navView.isVisible = isNavVisible && !isLandscape() navHostFragment.apply { val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) - layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { - marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 - } + layoutParams = + (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { + marginStart = + if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 + } } /** @@ -570,7 +573,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * highlight the wrong one in UI. */ when (destination.id) { - in listOf(R.id.navigation_downloads, R.id.navigation_download_child, R.id.navigation_download_queue) -> { + in listOf( + R.id.navigation_downloads, + R.id.navigation_download_child, + R.id.navigation_download_queue + ) -> { navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt index d69619b45a1..56512377bae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt @@ -35,9 +35,11 @@ class PlayMirrorAction : VideoClickAction() { ) { //Implemented a generator to handle the single val activity = context as? Activity ?: return + val link = index?.let { result.links[it] } val generatorMirror = object : VideoGenerator(listOf(video)) { override val hasCache: Boolean = false override val canSkipLoading: Boolean = false + override fun getId(index: Int): Int = video.id override suspend fun generateLinks( clearCache: Boolean, @@ -47,7 +49,7 @@ class PlayMirrorAction : VideoClickAction() { offset: Int, isCasting: Boolean ): Boolean { - index?.let { callback(result.links[it] to null) } + index?.let { callback(link to null) } result.subs.forEach { subtitle -> subtitleCallback(subtitle) } return true } @@ -56,7 +58,7 @@ class PlayMirrorAction : VideoClickAction() { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generatorMirror, result.syncData + generatorMirror, 0, result.syncData ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index ed273a3cef2..f91d40f28e0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -334,6 +334,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, + offset = 0, isCasting = true ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index 884eebd6292..dae70ebd7ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -162,7 +162,8 @@ object DownloadButtonSetup { } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } + DownloadFileGenerator(items), + items.indexOfFirst { it.id == click.data.id } ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index be9f768a829..abc432ef959 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -148,7 +148,7 @@ class DownloadFragment : BaseFragment( val size = cards.currentDownloads.size + cards.queue.size val context = binding.root.context val baseText = context.getString(R.string.download_queue) - binding.downloadQueueText.text = if (size > 0) { + binding.downloadQueueText.text = if (size > 0) { "$baseText (${cards.currentDownloads.size}/$size)" } else { baseText @@ -349,7 +349,8 @@ class DownloadFragment : BaseFragment( listOf(BasicLink(url)), extract = true, refererUrl = referer, - ) + id = url.hashCode() + ), 0 ) ) dialog.dismissSafe(activity) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt index eb1bcd00d42..35f8dcfd8ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -14,12 +14,13 @@ import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFol import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( - episodes: List, - currentIndex: Int = 0 -) : VideoGenerator(episodes, currentIndex) { + episodes: List +) : VideoGenerator(episodes) { override val hasCache = false override val canSkipLoading = false + override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id + override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -28,7 +29,7 @@ class DownloadFileGenerator( offset: Int, isCasting: Boolean ): Boolean { - val meta = getCurrent(offset) ?: return false + val meta = videos.getOrNull(offset) ?: return false if (meta.uri == Uri.EMPTY) { // We do this here so that we only load it when diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index a52a3c64665..85db33fc094 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : NoVideoGenerator() { +) : NoVideoGenerator(null) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, 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 31123023524..9ee85a941af 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 @@ -131,6 +131,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.Serializable import java.util.Calendar +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean @OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { @@ -139,11 +142,14 @@ class GeneratorPlayer : FullScreenPlayer() { const val CHANNEL_ID = 7340 const val STOP_ACTION = "stopcs3" - private var lastUsedGenerator: IGenerator? = null - fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { + private val generators = ConcurrentHashMap>() + fun newInstance(generator: VideoGenerator<*>, index : Int, syncData: HashMap? = null): Bundle { Log.i(TAG, "newInstance = $syncData") - lastUsedGenerator = generator + val uuid = UUID.randomUUID().toString() + generators[uuid] = generator return Bundle().apply { + putString("uuid", uuid) + putInt("index", index) if (syncData != null) putSerializable("syncData", syncData) } } @@ -162,25 +168,21 @@ class GeneratorPlayer : FullScreenPlayer() { private lateinit var viewModel: PlayerGeneratorViewModel //by activityViewModels() private lateinit var sync: SyncViewModel - private var currentLinks: Set> = setOf() - private var currentSubs: Set = setOf() private var currentSelectedLink: Pair? = null private var currentSelectedSubtitles: SubtitleData? = null - private var currentMeta: Any? = null - private var nextMeta: Any? = null - private var isActive: Boolean = false + private val currentMeta: Any? get() = viewModel.state.generatorState?.meta + private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta + + private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) 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 allMeta: List? = null - private fun startLoading() { - player.release() - currentSelectedSubtitles = null - isActive = false - binding?.overlayLoadingSkipButton?.isVisible = false - binding?.playerLoadingOverlay?.isVisible = true + private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance()?.map { episode -> + // Refresh all the episodes watch duration + getViewPos(episode.id)?.let { data -> + episode.copy(position = data.position, duration = data.duration) + } ?: episode } private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean { @@ -213,7 +215,7 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerTracksBtt?.isVisible = tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1 // Only set the preferred language if it is available. - // Otherwise it may give some users audio track init failed! + // Otherwise, it may give some users audio track init failed! if (tracks.allAudioTracks.any { it.language == preferredAudioTrackLanguage }) { player.setPreferredAudioTrack(preferredAudioTrackLanguage) } @@ -232,7 +234,7 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun getPos(): Long { - val durPos = getViewPos(viewModel.getId()) ?: return 0L + val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L @@ -383,9 +385,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { - playerHostView?.exitFullscreen() - this@GeneratorPlayer.player.release() - activity?.popCurrentPage() + exitPlayer() } } } @@ -485,9 +485,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: Pair?, sameEpisode: Boolean) { + private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { if (link == null) return - + isPlayerActive.set(true) // manage UI binding?.playerLoadingOverlay?.isVisible = false val isTorrent = @@ -503,16 +503,7 @@ class GeneratorPlayer : FullScreenPlayer() { uiReset() currentSelectedLink = link - currentMeta = viewModel.getMeta() - nextMeta = viewModel.getNextMeta() - allMeta = viewModel.getAllMeta()?.filterIsInstance()?.map { episode -> - // Refresh all the episodes watch duration - getViewPos(episode.id)?.let { data -> - episode.copy(position = data.position, duration = data.duration) - } ?: episode - } // setEpisodes(viewModel.getAllMeta() ?: emptyList()) - isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -522,6 +513,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -530,9 +522,9 @@ class GeneratorPlayer : FullScreenPlayer() { startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, - currentSubs, + subtitles, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - currentSubs, settings = true, downloads = true + subtitles, settings = true, downloads = true ), preview = true ) @@ -545,13 +537,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun sortLinks(qualityProfile: Int): List> { - return currentLinks.sortedBy { - // negative because we want to sort highest quality first - -getLinkPriority(qualityProfile, it.first) - } - } - data class TempMetaData( var episode: Int? = null, var season: Int? = null, @@ -877,20 +862,18 @@ class GeneratorPlayer : FullScreenPlayer() { vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return - val selectedSubtitle = subtitleData.first() val ctx = context ?: return - - val subs = currentSubs + subtitleData + val selectedSubtitle = subtitleData.first() + viewModel.addSubtitles(subtitleData.toSet()) // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(subs) + player.setActiveSubtitles(viewModel.state.subtitles) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) setSubtitles(selectedSubtitle, false) - viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() selectSourceDialog = null @@ -989,7 +972,7 @@ class GeneratorPlayer : FullScreenPlayer() { } // checks for both a race condition and if any of the subs generated is new - if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) { + if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { hasSelectASubtitle = true runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) @@ -1012,7 +995,7 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - val currentSubtitles = sortSubs(currentSubs) + val currentSubtitles = sortSubs(viewModel.state.subtitles) val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) val binding = @@ -1054,7 +1037,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val currentLoadResponse = viewModel.getLoadResponse() + val currentLoadResponse = viewModel.state.generatorState?.response val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null @@ -1112,7 +1095,7 @@ class GeneratorPlayer : FullScreenPlayer() { var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - sortedUrls = sortLinks(qualityProfile) + sortedUrls = viewModel.state.sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true @@ -1277,16 +1260,28 @@ class GeneratorPlayer : FullScreenPlayer() { binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener - QualityProfileDialog( + val dialog = QualityProfileDialog( activity, R.style.DialogFullscreenPlayer, - currentLinks.mapNotNull { it.first?.let { extractorLink -> LinkSource(extractorLink) } }, + viewModel.state.links.mapNotNull { + it.first?.let { extractorLink -> + LinkSource( + extractorLink + ) + } + }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) - refreshLinks(profile.id) - }.show() + } + + dialog.setOnDismissListener { + viewModel.state.clearSortedLinksCache() + refreshLinks(currentQualityProfile) + } + + dialog.show() } binding.subtitlesEncodingFormat.apply { @@ -1430,11 +1425,12 @@ class GeneratorPlayer : FullScreenPlayer() { } var audioIndexStart = currentAudioTracks.indexOfFirst { track -> - track.id == tracks.currentAudioTrack?.id && - track.formatIndex == tracks.currentAudioTrack?.formatIndex + track.id == tracks.currentAudioTrack?.id && + track.formatIndex == tracks.currentAudioTrack?.formatIndex }.coerceAtLeast(0) - val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + val audioArrayAdapter = + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) audioArrayAdapter.addAll( currentAudioTracks.mapIndexed { _, track -> @@ -1442,7 +1438,9 @@ class GeneratorPlayer : FullScreenPlayer() { val language = ( track.language?.trim()?.let { raw -> fromTagToLanguageName(raw) - ?: fromTagToLanguageName(raw.replace('_','-').substringBefore('-').lowercase()) + ?: fromTagToLanguageName( + raw.replace('_', '-').substringBefore('-').lowercase() + ) ?: raw } ?: track.label @@ -1464,7 +1462,8 @@ class GeneratorPlayer : FullScreenPlayer() { } listOfNotNull( - language.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercaseChar() }, + language.takeIf { it.isNotBlank() } + ?.replaceFirstChar { it.uppercaseChar() }, channels.takeIf { it.isNotBlank() }, codec.takeIf { it.isNotBlank() }?.uppercase() ).joinToString(" • ") @@ -1492,7 +1491,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( - currentTrack?.language, + currentTrack?.language, currentTrack?.id, currentTrack?.formatIndex, ) @@ -1541,13 +1540,20 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun startPlayer() { - if (isActive) return // we don't want double load when you skip loading + // We don't want double load when you skip loading + if(isPlayerActive.get()) { + return + } - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } + // Atomic operation to prevent double loading + if (!isPlayerActive.compareAndSet(false, true)) { + return + } loadLink(links.first(), false) showPlayerMetadata() } @@ -1560,7 +1566,7 @@ class GeneratorPlayer : FullScreenPlayer() { val metaView = overlay.findViewById(R.id.player_movie_meta) val descView = overlay.findViewById(R.id.player_movie_overview) - val load = viewModel.getLoadResponse() ?: return + val load = viewModel.state.generatorState?.response ?: return val episode = currentMeta as? ResultEpisode titleView.text = load.name @@ -1602,7 +1608,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun nextEpisode() { if (viewModel.hasNextEpisode() == true) { isNextEpisode = true - player.release() + releasePlayer() viewModel.loadLinksNext() } } @@ -1610,18 +1616,18 @@ class GeneratorPlayer : FullScreenPlayer() { override fun prevEpisode() { if (viewModel.hasPrevEpisode() == true) { isNextEpisode = true - player.release() + releasePlayer() viewModel.loadLinksPrev() } } override fun hasNextMirror(): Boolean { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -1668,7 +1674,7 @@ class GeneratorPlayer : FullScreenPlayer() { val percentage = position * 100L / duration DataStoreHelper.setViewPosAndResume( - viewModel.getId(), + viewModel.state.generatorState?.id, position, duration, currentMeta, @@ -1720,14 +1726,18 @@ class GeneratorPlayer : FullScreenPlayer() { ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null if (downloads) { - return sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode(langCode) } + return sortSubs(subtitles).firstOrNull { + it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( + langCode + ) + } } if (!settings) return null return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } - + private fun autoSelectFromSettings(): Boolean { // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles @@ -1744,7 +1754,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( - currentSubs, settings = true, downloads = false + viewModel.state.subtitles, settings = true, downloads = false )?.let { sub -> if (setSubtitles(sub, false)) { player.saveData() @@ -1758,20 +1768,20 @@ class GeneratorPlayer : FullScreenPlayer() { return false } - private fun autoSelectFromDownloads(): Boolean { - if (player.getCurrentPreferredSubtitle() == null) { - getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> - context?.let { ctx -> - if (setSubtitles(sub, false)) { - player.saveData() - player.reloadPlayer(ctx) - player.handleEvent(CSPlayerEvent.Play) - return true - } - } - } + private fun autoSelectFromDownloads() { + if (player.getCurrentPreferredSubtitle() != null) { + return } - return false + val sub = + getAutoSelectSubtitle(viewModel.state.subtitles, settings = false, downloads = true) + ?: return + val ctx = context ?: return + if (!setSubtitles(sub, false)) { + return + } + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) } private fun autoSelectSubtitles() { @@ -1855,7 +1865,7 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle - playerBinding?.offlinePin?.isVisible = lastUsedGenerator is DownloadFileGenerator + playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator } fun setPlayerDimen(widthHeight: Pair?) { @@ -2049,8 +2059,9 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun isThereEpisodes(): Boolean { - val meta = allMeta - return !meta.isNullOrEmpty() && meta.size > 1 + // Checks if there is a second episode of type ResultEpisode + // => There exists more than 1 episode, and they are all ResultEpisode + return viewModel.state.generatorState?.allMeta?.getOrNull(1) as? ResultEpisode != null } override fun showEpisodesOverlay() { @@ -2062,7 +2073,7 @@ class GeneratorPlayer : FullScreenPlayer() { { episodeClick -> if (episodeClick.action == ACTION_CLICK_DEFAULT) { isNextEpisode = false - player.release() + releasePlayer() playerEpisodeOverlay.isGone = true episodeClick.position?.let { viewModel.loadThisEpisode(it) } } @@ -2081,7 +2092,7 @@ class GeneratorPlayer : FullScreenPlayer() { (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) // Scroll to current episode - viewModel.getCurrentIndex()?.let { index -> + viewModel.state.generatorState?.index?.let { index -> playerEpisodeList.scrollToPosition(index) // Ensure focus on tv if (isLayout(TV)) { @@ -2125,32 +2136,51 @@ class GeneratorPlayer : FullScreenPlayer() { } } + @MainThread + fun releasePlayer() { + player.release() + currentSelectedSubtitles = null + isPlayerActive.set(false) + binding?.overlayLoadingSkipButton?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = true + uiReset() + } + + fun exitPlayer() { + playerHostView?.exitFullscreen() + player.release() + activity?.popCurrentPage() + } + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java] - viewModel.attachGenerator(lastUsedGenerator) + + val uuid = savedInstanceState?.getString("uuid") ?: arguments?.getString("uuid") + val index = savedInstanceState?.getInt("index") ?: arguments?.getInt("index") + + viewModel.attachGenerator(generators[uuid], index) + unwrapBundle(savedInstanceState) unwrapBundle(arguments) super.onBindingCreated(binding, savedInstanceState) - - var langFilterList = listOf() - var filterSubByLang = false - context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) - showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) - showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) + showResolution = + settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) + showMediaInfo = + settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) updateForcedEncoding(ctx) - filterSubByLang = + viewModel.filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (filterSubByLang) { + if (viewModel.filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) - langFilterList = langFromPrefMedia?.mapNotNull { + viewModel.langFilterList = langFromPrefMedia?.mapNotNull { fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } @@ -2168,13 +2198,14 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.overlayLoadingSkipButton.setOnClickListener { - startPlayer() + // Mark as "success" early + viewModel.modifyState { + copy(loading = Resource.Success(true)) + } } binding.playerLoadingGoBack.setOnClickListener { - playerHostView?.exitFullscreen() - player.release() - activity?.popCurrentPage() + exitPlayer() } playerBinding?.downloadHeader?.setOnClickListener { @@ -2191,10 +2222,21 @@ class GeneratorPlayer : FullScreenPlayer() { player.addTimeStamps(stamps) } - observe(viewModel.loadingLinks) { - when (it) { + observe(viewModel.currentSubtitles) { subtitles -> + player.setActiveSubtitles(subtitles) + + // If the file is downloaded then do not select auto select the subtitles + // Downloaded subtitles cannot be selected immediately after loading since + // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles + // Resulting in unselecting the downloaded subtitle + if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() + } + } + observe(viewModel.loadingLinks) { loading -> + when (loading) { is Resource.Loading -> { - startLoading() + releasePlayer() } is Resource.Success -> { @@ -2206,30 +2248,28 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(it.errorString, Toast.LENGTH_LONG) + showToast(loading.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { - currentLinks = it - val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true + observe(viewModel.currentLinks) { links -> + val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true val wasGone = binding.overlayLoadingSkipButton.isGone binding.overlayLoadingSkipButton.apply { isVisible = turnVisible - val value = viewModel.currentLinks.value - if (value.isNullOrEmpty()) { + if (links.isEmpty()) { setText(R.string.skip_loading) } else { @SuppressLint("SetTextI18n") - text = "${context.getString(R.string.skip_loading)} (${value.size})" + text = "${context.getString(R.string.skip_loading)} (${links.size})" } } safe { - if (currentLinks.any { link -> + if (viewModel.state.links.any { link -> getLinkPriority(currentQualityProfile, link.first) >= QualityDataHelper.AUTO_SKIP_PRIORITY } @@ -2242,34 +2282,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.overlayLoadingSkipButton.requestFocus() } } - - observe(viewModel.currentSubs) { set -> - val setOfSub = mutableSetOf() - if (langFilterList.isNotEmpty() && filterSubByLang) { - Log.i("subfilter", "Filtering subtitle") - langFilterList.forEach { lang -> - Log.i("subfilter", "Lang: $lang") - setOfSub += set.filter { - it.originalName.contains(lang, ignoreCase = true) || - it.origin != SubtitleOrigin.URL - } - } - currentSubs = setOfSub - } else { - currentSubs = set - } - player.setActiveSubtitles(set) - - // If the file is downloaded then do not select auto select the subtitles - // Downloaded subtitles cannot be selected immediately after loading since - // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles - // Resulting in unselecting the downloaded subtitle - if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { - autoSelectSubtitles() - } - } } - } @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index 0a34feee30c..3ab46ce215a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -1,10 +1,7 @@ package com.lagradost.cloudstream3.ui.player -import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import kotlin.math.max -import kotlin.math.min val LOADTYPE_INAPP = setOf( ExtractorLinkType.VIDEO, @@ -28,71 +25,27 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -abstract class NoVideoGenerator : VideoGenerator(emptyList(), 0) { +abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { override val hasCache = false override val canSkipLoading = false + override fun getId(index: Int): Int? = id } -abstract class VideoGenerator(val videos: List, var videoIndex: Int = 0) : - IGenerator { +abstract class VideoGenerator(val videos: List) { + abstract val hasCache: Boolean + abstract val canSkipLoading: Boolean + abstract fun getId(index : Int) : Int? - override fun hasNext(): Boolean = videoIndex < videos.lastIndex - override fun hasPrev(): Boolean = videoIndex > 0 - override fun getAll(): List? = videos - override fun getCurrent(offset: Int): T? = videos.getOrNull(videoIndex + offset) - override fun next() { - if (hasNext()) { - videoIndex += 1 - } - } + fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex + fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 - override fun prev() { - if (hasPrev()) { - videoIndex -= 1 - } - } - - override fun goto(index: Int) { - videoIndex = min(videos.lastIndex, max(0, index)) - } - - override fun getCurrentId(): Int? { - return when (val current = getCurrent()) { - is ResultEpisode -> { - current.id - } - - is ExtractorUri -> { - current.id - } - - else -> null - } - } -} - -// TODO deprecate/remove IGenerator in favor of a more ergonomic and correct implementation -interface IGenerator { - val hasCache: Boolean - val canSkipLoading: Boolean - - fun hasNext(): Boolean - fun hasPrev(): Boolean - fun next() - fun prev() - fun goto(index: Int) - - fun getCurrentId(): Int? // this is used to save data or read data about this id - fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null - fun getAll(): List? // this us used to get the metadata about all entries, not needed - - /* not safe, must use try catch */ - suspend fun generateLinks( + @Throws + abstract suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int = 0, - isCasting: Boolean = false + offset: Int, + isCasting: Boolean ): Boolean } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt index 71513af2ce4..db06e26e9a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -40,7 +40,8 @@ class LinkGenerator( private val links: List, private val extract: Boolean = true, private val refererUrl: String? = null, -) : NoVideoGenerator() { + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -78,10 +79,8 @@ class LinkGenerator( class MinimalLinkGenerator( private val links: List, private val subs: List, - private val id: Int? = null -) : NoVideoGenerator() { - override fun getCurrentId(): Int? = id - + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt index eb9f5c249eb..ac25347b6bd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -1,7 +1,6 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity -import android.content.ContentUris import android.content.Intent import android.net.Uri import androidx.core.content.ContextCompat.getString @@ -19,8 +18,8 @@ object OfflinePlaybackHelper { LinkGenerator( listOf( BasicLink(url) - ) - ) + ), id = url.hashCode() + ), 0 ) ) } @@ -52,7 +51,7 @@ object OfflinePlaybackHelper { links, subs, if (id != -1) id else null, - ) + ), 0 ) ) return true @@ -73,11 +72,10 @@ object OfflinePlaybackHelper { name = name ?: getString(activity, R.string.downloaded_file), // well not the same as a normal id, but we take it as users may want to // play downloaded files and save the location - id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull() - ?.hashCode() + id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() ) ) - ) + ), 0 ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index 96468490ab3..049ed06d6ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -9,29 +9,137 @@ import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.videoskip.SkipAPI import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.jetbrains.annotations.Contract +import java.util.concurrent.ConcurrentHashMap + +typealias VideoLink = Pair + +data class GeneratorState( + val meta: Any?, + val nextMeta: Any?, + val allMeta: List<*>?, + val response: LoadResponse?, + val index: Int, + val id: Int?, +) + +/** Immutable state of all current links relevant to displaying the video */ +// @MustUseReturnValues +// @Immutable +data class VideoState( + val subtitles: PersistentSet = persistentSetOf(), + val links: PersistentSet = persistentSetOf(), + val stamps: PersistentList = persistentListOf(), + val loading: Resource = Resource.Loading(), + val generatorState: GeneratorState? = null, +) { + /** + * This acts as a local cache for sorted links that are not copied over by the copy constructor. + * + * sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation + * */ + private val sortedLinks: ConcurrentHashMap> = ConcurrentHashMap() + + fun clearSortedLinksCache() = sortedLinks.clear() + + // Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result + // It is by all standards, idempotent and by extension also pure as it has no "visible" side effect + /** Returns .links in the sorted order according to the qualityProfile. + * Use .links if order is not needed */ + @Contract(pure = true) + fun sortLinks(qualityProfile: Int): List { + return sortedLinks[qualityProfile] ?: links.sortedBy { link -> + // negative because we want to sort highest quality first + -getLinkPriority(qualityProfile, link.first) + }.also { value -> sortedLinks[qualityProfile] = value } + } + + @Contract(pure = true) + fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item)) + + @Contract(pure = true) + fun add(item: VideoLink): VideoState = copy(links = links.add(item)) + + @Contract(pure = true) + fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item)) + + @JvmName("addSubtitleData") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(subtitles = subtitles.addAll(items)) + + @JvmName("addVideoLink") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(links = links.addAll(items)) + + @JvmName("addVideoSkipStamp") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(stamps = stamps.addAll(items)) + + @Contract(pure = true) + fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item)) + + @JvmName("setSubtitleData") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(subtitles = items.toPersistentSet()) + + @JvmName("setVideoLink") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(links = items.toPersistentSet()) + + @JvmName("setVideoSkipStamp") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList()) +} class PlayerGeneratorViewModel : ViewModel() { companion object { const val TAG = "PlayViewGen" } - private var generator: IGenerator? = null + @Volatile + var generator: VideoGenerator<*>? = null + + @Volatile + private var episodeIndex: Int = 0 + + /** + * The state of the video player, only modify it by modifyState to make sure observe is called, + * and avoid concurrency issues. + * + * This value can be used without Synchronized or locking when reading, as all fields are immutable. + * */ + @Volatile + var state = VideoState() + private set private val _currentLinks = MutableLiveData>>(setOf()) val currentLinks: LiveData>> = _currentLinks - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + private val _currentSubtitles = MutableLiveData>(setOf()) + val currentSubtitles: LiveData> = _currentSubtitles private val _loadingLinks = MutableLiveData>() val loadingLinks: LiveData> = _loadingLinks @@ -39,6 +147,35 @@ class PlayerGeneratorViewModel : ViewModel() { private val _currentStamps = MutableLiveData>(emptyList()) val currentStamps: LiveData> = _currentStamps + /** + * Modifies the `state` variable safely, and with the correct observe behavior. + * + * Synchronized to avoid concurrency issues, and make this operation atomic. + * Otherwise, one update may be lost if they are done in parallel. + * */ + @Synchronized + fun modifyState(op: VideoState.() -> VideoState) { + val oldState = state + state = op.invoke(oldState) + + /** + * Only post the changed values, this makes sure we do not invoke the "observe" + * + * We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality + * to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged. + * */ + if (state.links !== oldState.links) + _currentLinks.postValue(state.links) + if (state.stamps !== oldState.stamps) + _currentStamps.postValue(state.stamps) + if (state.subtitles !== oldState.subtitles) + _currentSubtitles.postValue(state.subtitles) + + /** Normal equality here as it is not a collection */ + if (state.loading != oldState.loading) + _loadingLinks.postValue(state.loading) + } + private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -53,41 +190,32 @@ class PlayerGeneratorViewModel : ViewModel() { _currentSubtitleYear.postValue(year) } - fun getId(): Int? { - return generator?.getCurrentId() - } - - fun loadLinks(episode: Int) { - generator?.goto(episode) - loadLinks() - } - fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") - if (generator?.hasPrev() == true) { - generator?.prev() + if (generator?.hasPrev(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext() == true) { - generator?.next() + if (generator?.hasNext(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext() + return generator?.hasNext(episodeIndex) } fun hasPrevEpisode(): Boolean? { - return generator?.hasPrev() + return generator?.hasPrev(episodeIndex) } fun preLoadNextLinks() { - val id = getId() + val id = generator?.getId(episodeIndex) // Do not preload if already loading if (id == currentLoadingEpisodeId) return @@ -97,14 +225,15 @@ class PlayerGeneratorViewModel : ViewModel() { currentJob = viewModelScope.launch { try { - if (generator?.hasCache == true && generator?.hasNext() == true) { + if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { safeApiCall { generator?.generateLinks( sourceTypes = LOADTYPE_INAPP, clearCache = false, + isCasting = false, callback = {}, subtitleCallback = {}, - offset = 1 + offset = episodeIndex + 1 ) } } @@ -118,129 +247,132 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun getLoadResponse(): LoadResponse? { - return safe { (generator as? RepoLinkGenerator?)?.page } - } - - fun getMeta(): Any? { - return safe { generator?.getCurrent() } - } - - fun getAllMeta(): List? { - return safe { generator?.getAll() } - } - - fun getNextMeta(): Any? { - return safe { - if (generator?.hasNext() == false) return@safe null - generator?.getCurrent(offset = 1) - } - } - - fun loadThisEpisode(index:Int) { - generator?.goto(index) + fun loadThisEpisode(index: Int) { + episodeIndex = index loadLinks() } - fun getCurrentIndex():Int?{ - val repoGen = generator as? RepoLinkGenerator ?: return null - return repoGen.videoIndex - } - - fun attachGenerator(newGenerator: IGenerator?) { + fun attachGenerator(newGenerator: VideoGenerator<*>?, index: Int?) { if (generator == null) { generator = newGenerator + if (index != null) { + episodeIndex = index + } } } - private var extraSubtitles : MutableSet = mutableSetOf() - /** * If duplicate nothing will happen * */ - fun addSubtitles(file: Set) = synchronized(extraSubtitles) { - extraSubtitles += file - val current = _currentSubs.value ?: emptySet() - val next = extraSubtitles + current - - // if it is of a different size then we have added distinct items - if (next.size != current.size) { - // Posting will refresh subtitles which will in turn - // make the subs to english if previously unselected - _currentSubs.postValue(next) - } + fun addSubtitles(file: Set) { + val validFile = file.filter(::isValidSubtitle) + if (validFile.isNotEmpty()) + modifyState { + add(validFile) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { - //currentStampJob?.cancel() currentStampJob = ioSafe { - val meta = generator?.getCurrent() - val page = (generator as? RepoLinkGenerator?)?.page - if (page != null && meta is ResultEpisode) { - _currentStamps.postValue(listOf()) - _currentStamps.postValue( - SkipAPI.videoStamps( - page, - meta, - duration, - hasNextEpisode() ?: false - ) - ) + val genState = state.generatorState ?: return@ioSafe + val meta = genState.meta + val page = genState.response + val id = genState.id + if (page == null || meta !is ResultEpisode) { + return@ioSafe } + val stamps = SkipAPI.videoStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + + /** Avoid adding stamps to the wrong video */ + modifyState { + if (id != this.generatorState?.id) { + this + } else { + set(stamps) + } + } + } + } + + var langFilterList = listOf() + var filterSubByLang = false + + fun isValidSubtitle(subtitle: SubtitleData): Boolean { + if (langFilterList.isEmpty() || !filterSubByLang) { + return true + } + + /** Only filter out subtitles fetched online */ + if (subtitle.origin != SubtitleOrigin.URL) { + return true + } + + return langFilterList.any { lang -> + subtitle.originalName.contains(lang, ignoreCase = true) } } fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { Log.i(TAG, "loadLinks") currentJob?.cancel() + val index = episodeIndex currentJob = viewModelScope.launchSafe { - // if we load links then we clear the prev loaded links - synchronized(extraSubtitles) { - extraSubtitles.clear() + // Clear old data and reset the state + modifyState { + VideoState( + generatorState = generator?.let { gen -> + GeneratorState( + meta = gen.videos.getOrNull(index), + nextMeta = gen.videos.getOrNull(index + 1), + id = gen.getId(index), + response = (gen as? RepoLinkGenerator)?.page, + index = index, + allMeta = gen.videos + ) + } + ) } - val currentLinks = mutableSetOf>() - val currentSubs = mutableSetOf() - - // clear old data - _currentSubs.postValue(emptySet()) - _currentLinks.postValue(emptySet()) - // load more data - _loadingLinks.postValue(Resource.Loading()) + // Load more data val loadingState = safeApiCall { generator?.generateLinks( sourceTypes = sourceTypes, clearCache = forceClearCache, - callback = { - synchronized(currentLinks) { - currentLinks.add(it) - // Clone to prevent ConcurrentModificationException - safe { - // Extra safe since .toSet() iterates. - _currentLinks.postValue(currentLinks.toSet()) - } + callback = { link -> + modifyState { + add(link) } }, - subtitleCallback = { - synchronized(extraSubtitles) { - currentSubs.add(it) - safe { - _currentSubs.postValue(currentSubs + extraSubtitles) + isCasting = false, + offset = index, + subtitleCallback = { link -> + if (isValidSubtitle(link)) + modifyState { + add(link) } - } }) } - _loadingLinks.postValue(loadingState) - _currentLinks.postValue(currentLinks) - synchronized(extraSubtitles) { - _currentSubs.postValue(currentSubs + extraSubtitles) + if (!isActive) { + return@launchSafe } - } + /** Only mark as success if we have not skipped loading */ + modifyState { + when (loading) { + is Resource.Loading -> copy(loading = loadingState) + else -> this + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt index 0dddf58a1d7..0668a194bc3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import kotlin.math.max -import kotlin.math.min +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger data class Cache( val linkCache: MutableSet, @@ -23,9 +23,8 @@ data class Cache( class RepoLinkGenerator( episodes: List, - currentIndex: Int = 0, val page: LoadResponse? = null, -) : VideoGenerator(episodes, currentIndex) { +) : VideoGenerator(episodes) { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = @@ -34,6 +33,7 @@ class RepoLinkGenerator( override val hasCache = true override val canSkipLoading = true + override fun getId(index: Int): Int? = videos.getOrNull(index)?.id // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) @@ -48,7 +48,7 @@ class RepoLinkGenerator( offset: Int, isCasting: Boolean, ): Boolean { - val current = getCurrent(offset) ?: return false + val current = videos.getOrNull(offset) ?: return false val currentCache = synchronized(cache) { cache[current.apiName to current.id] ?: Cache( @@ -61,10 +61,12 @@ class RepoLinkGenerator( } } - // these act as a general filter to prevent duplication of links or names - val currentLinksUrls = mutableSetOf() // makes all urls unique - val currentSubsUrls = mutableSetOf() // makes all subs urls unique - val lastCountedSuffix = mutableMapOf() + // These act as a general filter to prevent duplication of links or names + // Avoid any possible ConcurrentModificationException + val currentLinksUrls = ConcurrentHashMap.newKeySet() + val currentSubsUrls = ConcurrentHashMap.newKeySet() + // Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen! + val lastCountedSuffix = ConcurrentHashMap() synchronized(currentCache) { val outdatedCache = @@ -75,7 +77,10 @@ class RepoLinkGenerator( currentCache.subtitleCache.clear() currentCache.saturated = false } else if (currentCache.linkCache.isNotEmpty()) { - Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago") + Log.d( + TAG, + "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago" + ) } // call all callbacks @@ -88,8 +93,7 @@ class RepoLinkGenerator( currentCache.subtitleCache.forEach { sub -> currentSubsUrls.add(sub.url) - val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u - lastCountedSuffix[sub.originalName] = suffixCount + lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() subtitleCallback(sub) } @@ -108,17 +112,15 @@ class RepoLinkGenerator( subtitleCallback = { file -> Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) { + if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { return@loadLinks } - currentSubsUrls.add(correctFile.url) // this part makes sure that all names are unique for UX - - val nameDecoded = correctFile.originalName.html().toString().trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` - - val suffixCount = lastCountedSuffix.getOrDefault(nameDecoded, 0u) +1u - lastCountedSuffix[nameDecoded] = suffixCount + val nameDecoded = correctFile.originalName.html().toString() + .trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` + val suffixCount = + lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet() val updatedFile = correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") @@ -132,10 +134,9 @@ class RepoLinkGenerator( }, callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (link.url.isBlank() || currentLinksUrls.contains(link.url)) { + if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { return@loadLinks } - currentLinksUrls.add(link.url) synchronized(currentCache) { if (currentCache.linkCache.add(link)) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index 70ca117432d..cfbacc5d13f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -559,7 +559,7 @@ class ResultFragmentTv : BaseFragment( ExtractorLinkGenerator( extractedTrailerLinks, emptyList() - ) + ), 0 ) ) } @@ -925,8 +925,12 @@ class ResultFragmentTv : BaseFragment( resultTvComingSoon.isVisible = d.comingSoon populateChips(resultTag, d.tags) - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) - val showCast = prefs.getBoolean(root.context.getString(R.string.show_cast_in_details_key), true) + val prefs = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) + val showCast = prefs.getBoolean( + root.context.getString(R.string.show_cast_in_details_key), + true + ) resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index cf563df8e78..7dfe3cf5988 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1,7 +1,8 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.* +import android.content.Context +import android.content.DialogInterface import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -10,24 +11,50 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.actions.AlwaysAskAction -import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.AnimeLoadResponse import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.IDownloadableMinimum +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.SimklSyncServices +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TrackerType +import com.lagradost.cloudstream3.TrailerData +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.VPNStatus +import com.lagradost.cloudstream3.actions.AlwaysAskAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.metaproviders.SyncRedirector import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert @@ -44,9 +71,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.IGenerator import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP @@ -105,8 +130,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadEpisodeMetadata import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.newExtractorLink @@ -423,7 +448,7 @@ fun SelectPopup.getOptions(context: Context): List { } data class ExtractedTrailerData( - var mirros: List>,//Pair of extracted trailer link and original trailer link + var mirros: List>,//Pair of extracted trailer link and original trailer link var subtitles: List = emptyList(), ) @@ -454,7 +479,7 @@ class ResultViewModel2 : ViewModel() { var currentRepo: APIRepository? = null private var currentId: Int? = null private var fillers: HashSet = hashSetOf() - private var generator: IGenerator? = null + private var generator: RepoLinkGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -1267,9 +1292,10 @@ class ResultViewModel2 : ViewModel() { subs += sub updatePage() }, - isCasting = isCasting + isCasting = isCasting, + offset = 0 ) - } catch (e: CancellationException) { + } catch (_: CancellationException) { // Do nothing } catch (e: Exception) { logError(e) @@ -1518,26 +1544,24 @@ class ResultViewModel2 : ViewModel() { ACTION_PLAY_EPISODE_IN_PLAYER -> { val list = HashMap(currentResponse?.syncData ?: emptyMap()) + val generator = generator ?: return + + // I know kinda shit to iterate all, but it is 100% sure to work + val index = generator.videos.indexOfFirst { value -> value.id == click.data.id } - generator?.also { - it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work - ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id } - ?.let { index -> - if (index >= 0) - it.goto(index) - } - } if (currentResponse?.type == TvType.CustomMedia) { - generator?.generateLinks( + generator.generateLinks( + offset = index, clearCache = true, - LOADTYPE_ALL, + isCasting = false, + sourceTypes = LOADTYPE_ALL, callback = {}, subtitleCallback = {}) } else { activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generator ?: return, list + generator, index,list ) ) } @@ -1807,7 +1831,7 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(data : LoadResponse) { + private suspend fun updateFillers(data: LoadResponse) { fillers = ioWorkSafe { FillerEpisodeCheck.getFillerEpisodes(data) } ?: hashSetOf() @@ -2429,26 +2453,34 @@ class ResultViewModel2 : ViewModel() { loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> list.amap { trailerData -> try { - val links = arrayListOf>() + val links = arrayListOf>() val subs = arrayListOf() if (!loadExtractor( trailerData.extractorUrl, trailerData.referer, { subs.add(it) }, - { links.add(Pair(it,trailerData.extractorUrl))}) && trailerData.raw + { + links.add( + Pair( + it, + trailerData.extractorUrl + ) + ) + }) && trailerData.raw ) { arrayListOf( Pair( newExtractorLink( - "", - "Trailer", - trailerData.extractorUrl, - type = INFER_TYPE - ) { - this.referer = trailerData.referer ?: "" - this.quality = Qualities.Unknown.value - this.headers = trailerData.headers - },trailerData.extractorUrl) + "", + "Trailer", + trailerData.extractorUrl, + type = INFER_TYPE + ) { + this.referer = trailerData.referer ?: "" + this.quality = Qualities.Unknown.value + this.headers = trailerData.headers + }, trailerData.extractorUrl + ) ) to arrayListOf() } else { links to subs diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index f1d7ed7427a..12fcc0c3329 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -2000,6 +2000,8 @@ object VideoDownloadManager { linkLoadingJob = ioSafe { generator.generateLinks( + offset = 0, + isCasting = false, clearCache = false, sourceTypes = LOADTYPE_INAPP_DOWNLOAD, callback = { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc65cc4ee3a..a97145c3f81 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ junitKtx = "1.3.0" junitVersion = "1.3.0" juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.20" +kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" material = "1.14.0-beta01" @@ -80,6 +81,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" }